Public: anyone can read this; the crew can change it.
A gate whose install downloads from the npm registry says so in its log with the bytes, and /capacity names the repository that fetched most in the last hour #8394
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8382-a-gate-whose-install-downloads-from-the → main 2 commits fork
14 files
changed +373
−83
docs/MCP.md +1 −1
| @@ -62,7 +62,7 @@ for a tunnel and a forge that sleeps. | ||
| 62 | 62 | | `board_write` | `{ slug, board }` (person admins): write a whole board under a slug, made or replaced: `{ title, range, refresh, panels: [{ title, chart, query, w, h }] }` | yes | |
| 63 | 63 | | `boards` | `{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step` | | |
| 64 | 64 | | `branches` | `{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: "tags"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes | |
| 65 | | `capacity` | `{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour`; only the first six are settable by `key`); with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes | | |
| 65 | | `capacity` | `{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`; only the first six are settable by `key`); with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes | | |
| 66 | 66 | | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes | |
| 67 | 67 | | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes | |
| 68 | 68 | | `cloud_account` | `{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says` | yes | |
docs/METRICS.md +13 −5
| @@ -76,12 +76,20 @@ by run, so the series stay one per host). They show the bun cache working | ||
| 76 | 76 | stays near zero, and a cold one is hundreds of megabytes an hour (#7541). |
| 77 | 77 | Without Prometheus, the same hour is read four ways: |
| 78 | 78 | |
| 79 | - MCP `capacity` (forge admin) answers `egress.npmBytesLastHour` and the ten | |
| 80 | busiest `egress.destinations` with their totals and last hour. | |
| 81 | - `/settings/capacity` prints it in the Egress connections card. | |
| 79 | - MCP `capacity` (forge admin) answers `egress.npmBytesLastHour`, | |
| 80 | `egress.npmTopRepoLastHour` (the repository whose contained installs the | |
| 81 | registry sent most this hour, attributed by the cell's own meter, never by | |
| 82 | install output; null when none) and the ten busiest `egress.destinations` | |
| 83 | with their totals and last hour. | |
| 84 | - `/settings/capacity` prints it, with the top repository, in the Egress | |
| 85 | connections card. | |
| 82 | 86 | - The terminal's Egress connections screen (About, `c`, site admin) prints the |
| 83 | same line above the open connections. | |
| 87 | same line, with the top repository, above the open connections; all three | |
| 88 | read `egressHour`. | |
| 84 | 89 | - Each check's install step in a cell logs one line, `install fetched 0 B from |
| 85 | 90 | the registry` on a warm cache: what the cell's egress proxy received. A check |
| 86 | 91 | outside a cell logs none: its repository's bun cache is shared, so no figure |
| 87 | there would be its own. | |
| 92 | there would be its own. A contained check, gate or app install that received | |
| 93 | more than 1 MiB from `registry.npmjs.org` ends its log with `install fetched | |
| 94 | N MiB from the npm registry; the shared cache missed` (#8382), failed or | |
| 95 | aborted installs included. |
src/actions/capacity.ts +14 −3
| @@ -22,7 +22,8 @@ import { | ||
| 22 | 22 | type CapacityChange, |
| 23 | 23 | type CapacityKey, |
| 24 | 24 | } from "../core/capacity.ts"; |
| 25 | import { egressTotals, npmBytesLastHour, type EgressTotal } from "../apps/egress.ts"; | |
| 25 | import { egressTotals, npmBytesLastHour, npmTopRepoLastHour, type EgressTotal } from "../apps/egress.ts"; | |
| 26 | import { getRepoById } from "../core/repos.ts"; | |
| 26 | 27 | import { passkeyIds } from "../core/passkeys.ts"; |
| 27 | 28 | import type { User } from "../core/users.ts"; |
| 28 | 29 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -57,13 +58,23 @@ export interface CapacityRow { | ||
| 57 | 58 | */ |
| 58 | 59 | export interface EgressHour { |
| 59 | 60 | readonly npmBytesLastHour: number; |
| 61 | /** | |
| 62 | * The repository whose contained installs the registry sent most this | |
| 63 | * hour (#8382), as `owner/name`, attributed by the cell that fetched; | |
| 64 | * null when no metered install fetched, or its repository is gone. | |
| 65 | */ | |
| 66 | readonly npmTopRepoLastHour: { readonly repo: string; readonly bytes: number } | null; | |
| 60 | 67 | /** The ten destinations that sent most this hour, with their totals since boot. */ |
| 61 | 68 | readonly destinations: readonly EgressTotal[]; |
| 62 | 69 | } |
| 63 | 70 | |
| 64 | export function egressHour(now = Date.now()): EgressHour { | |
| 71 | export function egressHour(db: Database, now = Date.now()): EgressHour { | |
| 72 | const top = npmTopRepoLastHour(now); | |
| 73 | const repo = top === undefined ? undefined : getRepoById(db, top.repoId); | |
| 65 | 74 | return { |
| 66 | 75 | npmBytesLastHour: npmBytesLastHour(now), |
| 76 | npmTopRepoLastHour: | |
| 77 | top === undefined || repo === undefined ? null : { repo: `${repo.ownerName}/${repo.name}`, bytes: top.bytes }, | |
| 67 | 78 | destinations: egressTotals(now).slice(0, 10), |
| 68 | 79 | }; |
| 69 | 80 | } |
| @@ -104,7 +115,7 @@ export function listCapacity( | ||
| 104 | 115 | ...changedOf(app.db, key), |
| 105 | 116 | })), |
| 106 | 117 | changes: capacityChanges(app.db), |
| 107 | egress: egressHour(), | |
| 118 | egress: egressHour(app.db), | |
| 108 | 119 | }); |
| 109 | 120 | } |
| 110 | 121 |
src/apps/egress.ts +96 −19
| @@ -129,8 +129,51 @@ interface Moved { | ||
| 129 | 129 | const MOVED_MAX = 256; |
| 130 | 130 | const HOUR_MINUTES = 60; |
| 131 | 131 | const moved = new Map<string, Moved>(); |
| 132 | /** label -> bytes in, for the labels someone is metering now (`meterEgress`). */ | |
| 133 | const meters = new Map<string, { bytesIn: number; holders: number }>(); | |
| 132 | /** | |
| 133 | * label -> bytes in, for the labels someone is metering now (`meterEgress`), | |
| 134 | * and the repository whose cell the label is: the one thing that says whose | |
| 135 | * registry bytes these are (#8382), set by the caller that opened the cell. | |
| 136 | */ | |
| 137 | const meters = new Map<string, { bytesIn: number; npmIn: number; holders: number; repoId: string | undefined }>(); | |
| 138 | /** | |
| 139 | * Registry bytes per repository, minute by minute over the last hour | |
| 140 | * (#8382). Only a metered install with a repository lands here; past | |
| 141 | * `NPM_REPOS_MAX` repositories the quietest is dropped first. | |
| 142 | */ | |
| 143 | const npmByRepo = new Map<string, Moved>(); | |
| 144 | const NPM_REPOS_MAX = 256; | |
| 145 | ||
| 146 | function ring(): Moved { | |
| 147 | return { bytesIn: 0, bytesOut: 0, minute: Array(HOUR_MINUTES).fill(-1), minuteIn: Array(HOUR_MINUTES).fill(0) }; | |
| 148 | } | |
| 149 | ||
| 150 | /** Add `bytesIn` to `m` at minute `minute`. */ | |
| 151 | function addMinute(m: Moved, minute: number, bytesIn: number): void { | |
| 152 | const slot = minute % HOUR_MINUTES; | |
| 153 | if (m.minute[slot] !== minute) { | |
| 154 | m.minute[slot] = minute; | |
| 155 | m.minuteIn[slot] = 0; | |
| 156 | } | |
| 157 | m.minuteIn[slot] = (m.minuteIn[slot] ?? 0) + bytesIn; | |
| 158 | } | |
| 159 | ||
| 160 | function recordNpmRepo(repoId: string, bytesIn: number, minute: number): void { | |
| 161 | let m = npmByRepo.get(repoId); | |
| 162 | if (m === undefined) { | |
| 163 | if (npmByRepo.size >= NPM_REPOS_MAX) { | |
| 164 | let quietest: string | undefined; | |
| 165 | let least = Infinity; | |
| 166 | for (const [id, r] of npmByRepo) { | |
| 167 | const hour = lastHour(r, minute); | |
| 168 | if (hour < least) [quietest, least] = [id, hour]; | |
| 169 | } | |
| 170 | if (quietest !== undefined) npmByRepo.delete(quietest); | |
| 171 | } | |
| 172 | npmByRepo.set(repoId, (m = ring())); | |
| 173 | } | |
| 174 | m.bytesIn += bytesIn; | |
| 175 | addMinute(m, minute, bytesIn); | |
| 176 | } | |
| 134 | 177 | |
| 135 | 178 | /** Count `bytesIn` received from and `bytesOut` sent to `destination`. */ |
| 136 | 179 | export function recordEgress( |
| @@ -145,19 +188,17 @@ export function recordEgress( | ||
| 145 | 188 | ? destination |
| 146 | 189 | : "other"; |
| 147 | 190 | let m = moved.get(key); |
| 148 | if (m === undefined) | |
| 149 | moved.set(key, (m = { bytesIn: 0, bytesOut: 0, minute: Array(HOUR_MINUTES).fill(-1), minuteIn: Array(HOUR_MINUTES).fill(0) })); | |
| 191 | if (m === undefined) moved.set(key, (m = ring())); | |
| 150 | 192 | m.bytesIn += bytesIn; |
| 151 | 193 | m.bytesOut += bytesOut; |
| 152 | 194 | const minute = Math.floor(now / 60_000); |
| 153 | const slot = minute % HOUR_MINUTES; | |
| 154 | if (m.minute[slot] !== minute) { | |
| 155 | m.minute[slot] = minute; | |
| 156 | m.minuteIn[slot] = 0; | |
| 157 | } | |
| 158 | m.minuteIn[slot] = (m.minuteIn[slot] ?? 0) + bytesIn; | |
| 195 | addMinute(m, minute, bytesIn); | |
| 159 | 196 | const meter = meters.get(label); |
| 160 | if (meter !== undefined) meter.bytesIn += bytesIn; | |
| 197 | if (meter === undefined) return; | |
| 198 | meter.bytesIn += bytesIn; | |
| 199 | if (destination !== NPM_REGISTRY || bytesIn === 0) return; | |
| 200 | meter.npmIn += bytesIn; | |
| 201 | if (meter.repoId !== undefined) recordNpmRepo(meter.repoId, bytesIn, minute); | |
| 161 | 202 | } |
| 162 | 203 | |
| 163 | 204 | export interface EgressTotal { |
| @@ -198,23 +239,58 @@ export function npmBytesLastHour(now = Date.now()): number { | ||
| 198 | 239 | return m === undefined ? 0 : lastHour(m, Math.floor(now / 60_000)); |
| 199 | 240 | } |
| 200 | 241 | |
| 242 | /** The repository that received most from the npm registry over the last hour, by id. */ | |
| 243 | export interface NpmTopRepo { | |
| 244 | readonly repoId: string; | |
| 245 | readonly bytes: number; | |
| 246 | } | |
| 247 | ||
| 248 | /** The metered repository the registry sent most to in the last hour; none when no metered install fetched. */ | |
| 249 | export function npmTopRepoLastHour(now = Date.now()): NpmTopRepo | undefined { | |
| 250 | const minute = Math.floor(now / 60_000); | |
| 251 | let top: NpmTopRepo | undefined; | |
| 252 | for (const [repoId, m] of npmByRepo) { | |
| 253 | const bytes = lastHour(m, minute); | |
| 254 | if (bytes > 0 && (top === undefined || bytes > top.bytes || (bytes === top.bytes && repoId < top.repoId))) | |
| 255 | top = { repoId, bytes }; | |
| 256 | } | |
| 257 | return top; | |
| 258 | } | |
| 259 | ||
| 260 | export interface EgressMeter { | |
| 261 | /** Stop counting; answers the bytes received from every destination. */ | |
| 262 | stop(): number; | |
| 263 | /** Bytes received from the npm registry alone, up to `stop()`. */ | |
| 264 | npm(): number; | |
| 265 | } | |
| 266 | ||
| 201 | 267 | /** |
| 202 | 268 | * Count what `label`'s proxy receives from now until `stop()`, which answers |
| 203 | 269 | * the bytes in. Only labels being metered are kept, and each goes with its |
| 204 | * last holder, so a run label never outlives the step that asked. | |
| 270 | * last holder, so a run label never outlives the step that asked. `repoId` | |
| 271 | * is the repository whose cell `label` names, from the caller that opened | |
| 272 | * it: the registry bytes are attributed to it, never to anything the | |
| 273 | * install printed. | |
| 205 | 274 | */ |
| 206 | export function meterEgress(label: string): { stop(): number } { | |
| 207 | const meter = meters.get(label) ?? { bytesIn: 0, holders: 0 }; | |
| 275 | export function meterEgress(label: string, repoId?: string): EgressMeter { | |
| 276 | const meter = meters.get(label) ?? { bytesIn: 0, npmIn: 0, holders: 0, repoId }; | |
| 277 | // An earlier holder that knew no repository must not lose a later one's. | |
| 278 | meter.repoId ??= repoId; | |
| 208 | 279 | meters.set(label, meter); |
| 209 | 280 | meter.holders++; |
| 210 | 281 | const start = meter.bytesIn; |
| 211 | let stopped = false; | |
| 282 | const npmStart = meter.npmIn; | |
| 283 | let stopped: { bytes: number; npm: number } | undefined; | |
| 212 | 284 | return { |
| 213 | 285 | stop() { |
| 214 | const bytes = meter.bytesIn - start; | |
| 215 | if (!stopped && --meter.holders === 0) meters.delete(label); | |
| 216 | stopped = true; | |
| 217 | return bytes; | |
| 286 | if (stopped === undefined) { | |
| 287 | stopped = { bytes: meter.bytesIn - start, npm: meter.npmIn - npmStart }; | |
| 288 | if (--meter.holders === 0) meters.delete(label); | |
| 289 | } | |
| 290 | return stopped.bytes; | |
| 291 | }, | |
| 292 | npm() { | |
| 293 | return stopped?.npm ?? meter.npmIn - npmStart; | |
| 218 | 294 | }, |
| 219 | 295 | }; |
| 220 | 296 | } |
| @@ -222,6 +298,7 @@ export function meterEgress(label: string): { stop(): number } { | ||
| 222 | 298 | /** For tests: forget every cumulative count. */ |
| 223 | 299 | export function resetEgressTotals(): void { |
| 224 | 300 | moved.clear(); |
| 301 | npmByRepo.clear(); | |
| 225 | 302 | } |
| 226 | 303 | |
| 227 | 304 | /** The ports a declared host may be reached on when its entry names none. */ |
src/apps/process.ts +11 −4
| @@ -19,7 +19,7 @@ import { isReservedAppEnvName } from "../core/app_env.ts"; | ||
| 19 | 19 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 20 | import { spellings, stripForgeDirs } from "../runs/paths.ts"; |
| 21 | 21 | import { seconds } from "./timing.ts"; |
| 22 | import { cellInstall, prefillInstallCache, withLock } from "../checks/install.ts"; | |
| 22 | import { cellInstall, meteredInstall, prefillInstallCache, withLock } from "../checks/install.ts"; | |
| 23 | 23 | import { |
| 24 | 24 | commandPid, |
| 25 | 25 | killTree, |
| @@ -96,6 +96,8 @@ export interface CellBase { | ||
| 96 | 96 | readonly egressMode?: "enforce" | "report" | undefined; |
| 97 | 97 | /** Whose cell, for the machine sampler: `app:<slug>/<env>`. */ |
| 98 | 98 | readonly label?: string | undefined; |
| 99 | /** Whose repository the cell runs, for the npm registry tally (`apps/egress.ts`). */ | |
| 100 | readonly repoId?: string | undefined; | |
| 99 | 101 | } |
| 100 | 102 | |
| 101 | 103 | export type StartStage = "install" | "test" | "start" | "ready"; |
| @@ -451,9 +453,14 @@ async function install( | ||
| 451 | 453 | const plan = installPlan(command, input, env, filled); |
| 452 | 454 | const step = (): Promise<Result<void, string>> => |
| 453 | 455 | runStep("install", plan.command, { cwd: input.dir, env: plan.env, log: input.log, signal: input.signal, cell: plan.cell }); |
| 454 | if (plan.lock === undefined) return step(); | |
| 455 | const held = await withLock(plan.lock, step, { signal: input.signal }); | |
| 456 | return held.ok ? held.value : held; | |
| 456 | if (plan.lock !== undefined) { | |
| 457 | const held = await withLock(plan.lock, step, { signal: input.signal }); | |
| 458 | return held.ok ? held.value : held; | |
| 459 | } | |
| 460 | // A contained install is metered by its cell's label (#8382): more than | |
| 461 | // a MiB from the registry is the shared cache missing, and says so, even | |
| 462 | // when the install failed or was aborted. | |
| 463 | return meteredInstall(plan.cell?.label, input.cell?.repoId, step, input.log); | |
| 457 | 464 | } |
| 458 | 465 | |
| 459 | 466 | /** |
src/apps/runtime.ts +1
| @@ -1485,6 +1485,7 @@ export class AppsRuntime { | ||
| 1485 | 1485 | // Likewise an admin's flip of the egress mode. |
| 1486 | 1486 | egressMode: egressSetting(this.app.db), |
| 1487 | 1487 | label: `app:${record.slug}/${env}`, |
| 1488 | repoId: record.repoId, | |
| 1488 | 1489 | }; |
| 1489 | 1490 | } |
| 1490 | 1491 |
src/checks/install.ts +41
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | |
| 19 | 19 | import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 20 | 20 | import { tmpdir } from "node:os"; |
| 21 | import { meterEgress } from "../apps/egress.ts"; | |
| 21 | 22 | import { dirname, join } from "node:path"; |
| 22 | 23 | import { formatBytes } from "../core/storage.ts"; |
| 23 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -46,6 +47,46 @@ export function installFetchedLine(bytes: number): string { | ||
| 46 | 47 | return `install fetched ${formatBytes(Math.max(0, bytes))} from the registry`; |
| 47 | 48 | } |
| 48 | 49 | |
| 50 | /** More than this from the npm registry in one install is the shared cache missing (#8382). */ | |
| 51 | export const NPM_MISS_BYTES = 1024 * 1024; | |
| 52 | ||
| 53 | /** | |
| 54 | * The line a contained install's log ends with when its cell received more | |
| 55 | * than `NPM_MISS_BYTES` from the npm registry; none under it. | |
| 56 | */ | |
| 57 | export function npmMissLine(npmBytes: number): string | undefined { | |
| 58 | if (!(npmBytes > NPM_MISS_BYTES)) return undefined; | |
| 59 | const mib = npmBytes / NPM_MISS_BYTES; | |
| 60 | return `install fetched ${mib < 10 ? mib.toFixed(1) : Math.round(mib)} MiB from the npm registry; the shared cache missed`; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Run a contained install `step` with its cell's egress proxy metered by | |
| 65 | * `label`, the registry's bytes attributed to `repoId` (#8382). After the | |
| 66 | * step — in `finally`, so a failed, aborted or throwing install logs too — | |
| 67 | * `write` gets `installFetchedLine` when `fetched` asks for it (a check's | |
| 68 | * #7546 line), then `npmMissLine` when the registry sent more than a MiB. | |
| 69 | * With no `label` the install is uncontained: nothing is metered or logged. | |
| 70 | */ | |
| 71 | export async function meteredInstall<T>( | |
| 72 | label: string | undefined, | |
| 73 | repoId: string | undefined, | |
| 74 | step: () => Promise<T>, | |
| 75 | write: (line: string) => void, | |
| 76 | options: { readonly fetched?: boolean } = {}, | |
| 77 | ): Promise<T> { | |
| 78 | if (label === undefined) return step(); | |
| 79 | const meter = meterEgress(label, repoId); | |
| 80 | try { | |
| 81 | return await step(); | |
| 82 | } finally { | |
| 83 | const fetched = meter.stop(); | |
| 84 | if (options.fetched === true) write(installFetchedLine(fetched)); | |
| 85 | const missed = npmMissLine(meter.npm()); | |
| 86 | if (missed !== undefined) write(missed); | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 49 | 90 | export type InstallOutcome = |
| 50 | 91 | | { readonly ok: true } |
| 51 | 92 | | { readonly ok: false; readonly error: string; readonly infrastructure: boolean }; |
src/checks/service.ts +42 −43
| @@ -110,13 +110,12 @@ import { JOB_VANISHED, runJobStep } from "./job_step.ts"; | ||
| 110 | 110 | import { |
| 111 | 111 | cellInstall, |
| 112 | 112 | checkInstallCache, |
| 113 | installFetchedLine, | |
| 113 | meteredInstall, | |
| 114 | 114 | installWithRetry, |
| 115 | 115 | prefillInstallCache, |
| 116 | 116 | withLock, |
| 117 | 117 | } from "./install.ts"; |
| 118 | 118 | import { evictSoon, lend } from "./install_evict.ts"; |
| 119 | import { meterEgress } from "../apps/egress.ts"; | |
| 120 | 119 | import { checkStage, type GateStage } from "./gate_reason.ts"; |
| 121 | 120 | import { proofsInFlight } from "./proof.ts"; |
| 122 | 121 | import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts"; |
| @@ -1232,48 +1231,48 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1232 | 1231 | // What the cell's proxy received, for the one line that shows the cache |
| 1233 | 1232 | // working (#7546). Uncontained, nothing is logged: the shared cache |
| 1234 | 1233 | // is not metered, and its growth is wrong under concurrent checks. |
| 1235 | // Stopped in `finally`, so a throw never leaves the label metered. | |
| 1236 | const meter = cell === undefined ? undefined : meterEgress(`check:${run.id}`); | |
| 1237 | let fetched: number | undefined; | |
| 1238 | let installed: Awaited<ReturnType<typeof installWithRetry>>; | |
| 1239 | try { | |
| 1240 | installed = await installWithRetry( | |
| 1241 | async (log) => { | |
| 1242 | if (shared === undefined) { | |
| 1243 | if (warm !== undefined) { | |
| 1244 | const fill = await prefillInstallCache({ | |
| 1245 | cache: warm, | |
| 1246 | dir, | |
| 1247 | env, | |
| 1248 | log, | |
| 1249 | signal, | |
| 1250 | timeoutMs: manifest.timeout * 1000, | |
| 1251 | }); | |
| 1252 | if (!fill.ok) return fill; | |
| 1253 | filled = fill.value; | |
| 1254 | void evictSoon(warm, process.env); | |
| 1234 | // Stopped in `finally`, so a throw never leaves the label metered; | |
| 1235 | // over a MiB from the npm registry adds the miss line (#8382). | |
| 1236 | const installed = await meteredInstall( | |
| 1237 | cell === undefined ? undefined : `check:${run.id}`, | |
| 1238 | repo.id, | |
| 1239 | () => | |
| 1240 | installWithRetry( | |
| 1241 | async (log) => { | |
| 1242 | if (shared === undefined) { | |
| 1243 | if (warm !== undefined) { | |
| 1244 | const fill = await prefillInstallCache({ | |
| 1245 | cache: warm, | |
| 1246 | dir, | |
| 1247 | env, | |
| 1248 | log, | |
| 1249 | signal, | |
| 1250 | timeoutMs: manifest.timeout * 1000, | |
| 1251 | }); | |
| 1252 | if (!fill.ok) return fill; | |
| 1253 | filled = fill.value; | |
| 1254 | void evictSoon(warm, process.env); | |
| 1255 | } | |
| 1256 | return once(log); | |
| 1255 | 1257 | } |
| 1256 | return once(log); | |
| 1257 | } | |
| 1258 | mkdirSync(shared, { recursive: true }); | |
| 1259 | const held = await withLock( | |
| 1260 | `${shared}.lock`, | |
| 1261 | async () => { | |
| 1262 | const done = await once(log); | |
| 1263 | if (done.ok) lend(shared, dir); | |
| 1264 | return done; | |
| 1265 | }, | |
| 1266 | { signal }, | |
| 1267 | ); | |
| 1268 | if (held.ok && held.value.ok) void evictSoon(shared, process.env); | |
| 1269 | return held.ok ? held.value : held; | |
| 1270 | }, | |
| 1271 | { log: write, signal, retryMs: options.installRetryMs }, | |
| 1272 | ); | |
| 1273 | } finally { | |
| 1274 | fetched = meter?.stop(); | |
| 1275 | } | |
| 1276 | if (fetched !== undefined) write(installFetchedLine(fetched)); | |
| 1258 | mkdirSync(shared, { recursive: true }); | |
| 1259 | const held = await withLock( | |
| 1260 | `${shared}.lock`, | |
| 1261 | async () => { | |
| 1262 | const done = await once(log); | |
| 1263 | if (done.ok) lend(shared, dir); | |
| 1264 | return done; | |
| 1265 | }, | |
| 1266 | { signal }, | |
| 1267 | ); | |
| 1268 | if (held.ok && held.value.ok) void evictSoon(shared, process.env); | |
| 1269 | return held.ok ? held.value : held; | |
| 1270 | }, | |
| 1271 | { log: write, signal, retryMs: options.installRetryMs }, | |
| 1272 | ), | |
| 1273 | write, | |
| 1274 | { fetched: true }, | |
| 1275 | ); | |
| 1277 | 1276 | if (!installed.ok) |
| 1278 | 1277 | return { |
| 1279 | 1278 | ok: false, |
src/http/mcp/tools/capacity.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "capacity", |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour`; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 8 | doc: "`{ key?, value?, dryRun? }` (forge admin): the forge's capacity and guards, live (crew and checks concurrency, check shards, cell egress, the new-account guard, the sign-in edge, queue depth, child forges, `egress.npmBytesLastHour` and `npmTopRepoLastHour`; only the first six are settable by `key`); with `key` and `value` (`\"\"` restores the default), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token", | |
| 9 | 9 | description: |
| 10 | 10 | "Forge capacity and guards, live. With `key` and `value` (\"\" restores default), plans.", |
| 11 | 11 | inputSchema: { |
src/tui/screens/egress.ts +8 −3
| @@ -11,7 +11,8 @@ import { | ||
| 11 | 11 | revokeAppEgress, |
| 12 | 12 | type EgressRow, |
| 13 | 13 | } from "../../actions/egress_grants.ts"; |
| 14 | import { egressSnapshot, npmBytesLastHour } from "../../apps/egress.ts"; | |
| 14 | import { egressHour } from "../../actions/capacity.ts"; | |
| 15 | import { egressSnapshot } from "../../apps/egress.ts"; | |
| 15 | 16 | import { bytes } from "../../system/format.ts"; |
| 16 | 17 | import type { Repo } from "../../core/repos.ts"; |
| 17 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -117,7 +118,8 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 117 | 118 | /** |
| 118 | 119 | * The egress connections open right now, per app and destination: the |
| 119 | 120 | * Capacity page's card, for a site admin, under the bytes the npm registry |
| 120 | * sent over the last hour (#7546). Read again on every draw, so it moves as | |
| 121 | * sent over the last hour (#7546) and the repository it sent most to (#8382), | |
| 122 | * both from `egressHour`, as the page and MCP read them. Read again on every draw, so it moves as | |
| 121 | 123 | * connections do; anyone else is offered nothing. |
| 122 | 124 | */ |
| 123 | 125 | export function connectionsScreen(f: Session, ctx: Ctx): Screen { |
| @@ -138,7 +140,10 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 138 | 140 | state.clamp(rows.length); |
| 139 | 141 | const [npm, head, list] = split(area, "v", [len(1), len(1), fill(1)]); |
| 140 | 142 | if (f.user?.isAdmin === true) { |
| 141 | const sent = `The npm registry sent ${bytes(npmBytesLastHour())} over the last hour.`; | |
| 143 | const hour = egressHour(f.app.db); | |
| 144 | const top = hour.npmTopRepoLastHour; | |
| 145 | const most = top === null ? "." : `, most to ${top.repo} (${bytes(top.bytes)}).`; | |
| 146 | const sent = `The npm registry sent ${bytes(hour.npmBytesLastHour)} over the last hour${most}`; | |
| 142 | 147 | s.text(npm!.x, npm!.y, truncate(sent, npm!.w), style(c.mutedFg, c.bg), npm!.x + npm!.w); |
| 143 | 148 | } |
| 144 | 149 | const note = |
src/web/pages/capacity.ts +7 −1
| @@ -160,7 +160,13 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 160 | 160 | const open = egressSnapshot(); |
| 161 | 161 | // The bun cache at work (#7546): a warm cache keeps this near zero. |
| 162 | 162 | const npm = html`The npm registry sent |
| 163 | <span class="mono">${bytes(egress.npmBytesLastHour)}</span> over the last hour.`; | |
| 163 | <span class="mono">${bytes(egress.npmBytesLastHour)}</span> over the last hour${ | |
| 164 | egress.npmTopRepoLastHour === null | |
| 165 | ? "." | |
| 166 | : html`, most to | |
| 167 | <span class="mono">${egress.npmTopRepoLastHour.repo}</span> | |
| 168 | (<span class="mono">${bytes(egress.npmTopRepoLastHour.bytes)}</span>).` | |
| 169 | }`; | |
| 164 | 170 | const connections = card( |
| 165 | 171 | open.length === 0 |
| 166 | 172 | ? html`<div class="card-body"> |
test/capacity.test.ts +19 −1
| @@ -18,7 +18,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 18 | 18 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | import { enableApp } from "../src/actions/apps.ts"; |
| 21 | import { NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts"; | |
| 21 | import { meterEgress, NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts"; | |
| 22 | 22 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 23 | 23 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 24 | 24 | import { |
| @@ -161,6 +161,24 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 161 | 161 | resetEgressTotals(); |
| 162 | 162 | }); |
| 163 | 163 | |
| 164 | test("MCP capacity and the page name the repository the registry sent most this hour (#8382)", async () => { | |
| 165 | resetEgressTotals(); | |
| 166 | const made = await createRepo(app, alice, { name: "npm-heavy", visibility: "public" }); | |
| 167 | if (!made.ok) throw new Error(String(made.error)); | |
| 168 | const meter = meterEgress("check:heavy", made.value.id); | |
| 169 | recordEgress("check:heavy", NPM_REGISTRY, 5 * 1024 * 1024, 0); | |
| 170 | meter.stop(); | |
| 171 | const read = await mcp(aliceRo, {}); | |
| 172 | const egress = (JSON.parse(read.text) as { egress: { npmTopRepoLastHour: { repo: string; bytes: number } | null } }).egress; | |
| 173 | expect(egress.npmTopRepoLastHour).toEqual({ repo: "alice/npm-heavy", bytes: 5 * 1024 * 1024 }); | |
| 174 | const cookie = await login("alice"); | |
| 175 | const page = await (await fetch(`${servers.url}/settings/capacity`, { headers: { cookie } })).text(); | |
| 176 | expect(page).toMatch(/id="egress-npm"[\s\S]*?alice\/npm-heavy[\s\S]*?5\.0 MiB/); | |
| 177 | resetEgressTotals(); | |
| 178 | const none = await mcp(aliceRo, {}); | |
| 179 | expect((JSON.parse(none.text) as { egress: { npmTopRepoLastHour: unknown } }).egress.npmTopRepoLastHour).toBeNull(); | |
| 180 | }); | |
| 181 | ||
| 164 | 182 | test("a member, an agent and nobody are told it is not there, at every door", async () => { |
| 165 | 183 | expect(listCapacity(app, bob).ok).toBe(false); |
| 166 | 184 | expect(listCapacity(app, agent).ok).toBe(false); |
test/egress-admin.test.ts +5 −2
| @@ -13,7 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 13 | 13 | import { listEgressGrants } from "../src/core/egress_grants.ts"; |
| 14 | 14 | import type { Repo } from "../src/core/repos.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | import { NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts"; | |
| 16 | import { meterEgress, NPM_REGISTRY, recordEgress, resetEgressTotals, startEgress } from "../src/apps/egress.ts"; | |
| 17 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 18 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -144,7 +144,9 @@ describe("egress grants in the terminal", () => { | ||
| 144 | 144 | client.write(`CONNECT 127.0.0.1:${camera.port} HTTP/1.1\r\n\r\n`); |
| 145 | 145 | while (!reply.includes("200")) await Bun.sleep(10); |
| 146 | 146 | resetEgressTotals(); |
| 147 | const meter = meterEgress("check:r", repo.id); | |
| 147 | 148 | recordEgress("check:r", NPM_REGISTRY, 3_000_000, 0); |
| 149 | meter.stop(); | |
| 148 | 150 | const admin = boot(ada); |
| 149 | 151 | const other = boot(bob); |
| 150 | 152 | try { |
| @@ -153,7 +155,8 @@ describe("egress grants in the terminal", () => { | ||
| 153 | 155 | await settleUntil(admin.tui, "app:cam/main"); |
| 154 | 156 | expect(admin.text()).toContain(`127.0.0.1:${camera.port} · 1 open`); |
| 155 | 157 | // The registry's hour, as the Capacity page and MCP `capacity` read it (#7546). |
| 156 | expect(admin.text()).toContain("The npm registry sent 2.9 MiB over the last hour."); | |
| 158 | // …and the repository it sent most to, from the same `egressHour` (#8382). | |
| 159 | expect(admin.text()).toContain("The npm registry sent 2.9 MiB over the last hour, most to ada/arm (2.9 MiB)."); | |
| 157 | 160 | await other.openAbout(); |
| 158 | 161 | await other.press("c"); |
| 159 | 162 | expect(other.text()).not.toContain("Egress connections"); |
test/egress-npm-repo.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * A contained install that fetches more than a MiB from the npm registry | |
| 3 | * says the shared cache missed, and the registry's bytes are kept per | |
| 4 | * repository for the last hour (#8382), attributed by the cell's meter — | |
| 5 | * never by anything the install printed. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 9 | import { meterEgress, NPM_REGISTRY, npmTopRepoLastHour, recordEgress, resetEgressTotals } from "../src/apps/egress.ts"; | |
| 10 | import { meteredInstall, npmMissLine } from "../src/checks/install.ts"; | |
| 11 | ||
| 12 | beforeEach(resetEgressTotals); | |
| 13 | afterEach(resetEgressTotals); | |
| 14 | ||
| 15 | const T = Date.UTC(2026, 10, 2, 12, 0, 0); | |
| 16 | const MIN = 60_000; | |
| 17 | const MiB = 1024 * 1024; | |
| 18 | ||
| 19 | /** One metered install: what its proxy received, then the line its log ends with. */ | |
| 20 | function install(label: string, repoId: string | undefined, received: [string, number][], now = T): string | undefined { | |
| 21 | const meter = meterEgress(label, repoId); | |
| 22 | for (const [destination, bytes] of received) recordEgress(label, destination, bytes, 0, now); | |
| 23 | meter.stop(); | |
| 24 | return npmMissLine(meter.npm()); | |
| 25 | } | |
| 26 | ||
| 27 | test("an install over a MiB from the registry ends with the miss line; one under it does not", () => { | |
| 28 | expect(install("check:big", "r1", [[NPM_REGISTRY, 3 * MiB]])).toBe( | |
| 29 | "install fetched 3.0 MiB from the npm registry; the shared cache missed", | |
| 30 | ); | |
| 31 | expect(install("check:small", "r1", [[NPM_REGISTRY, 512 * 1024]])).toBeUndefined(); | |
| 32 | expect(install("check:edge", "r1", [[NPM_REGISTRY, MiB]])).toBeUndefined(); | |
| 33 | }); | |
| 34 | ||
| 35 | test("only the registry's bytes count toward the miss, not other hosts'", () => { | |
| 36 | expect(install("check:gh", "r1", [["github.com:443", 50 * MiB], [NPM_REGISTRY, 10]])).toBeUndefined(); | |
| 37 | }); | |
| 38 | ||
| 39 | test("bytes after stop() are not the install's", () => { | |
| 40 | const meter = meterEgress("check:late", "r1"); | |
| 41 | recordEgress("check:late", NPM_REGISTRY, 100, 0, T); | |
| 42 | meter.stop(); | |
| 43 | recordEgress("check:late", NPM_REGISTRY, 9 * MiB, 0, T); | |
| 44 | expect(meter.npm()).toBe(100); | |
| 45 | }); | |
| 46 | ||
| 47 | test("two repositories' installs are attributed to each, and the top one is named", () => { | |
| 48 | install("check:a", "repo-a", [[NPM_REGISTRY, 2 * MiB]]); | |
| 49 | install("app:b/pr-1", "repo-b", [[NPM_REGISTRY, 3 * MiB]]); | |
| 50 | install("check:a2", "repo-a", [[NPM_REGISTRY, 2 * MiB]]); | |
| 51 | expect(npmTopRepoLastHour(T)).toEqual({ repoId: "repo-a", bytes: 4 * MiB }); | |
| 52 | // An hour on, repo-a's minute has aged out while repo-b fetched again. | |
| 53 | install("app:b/pr-1", "repo-b", [[NPM_REGISTRY, 1]], T + 60 * MIN); | |
| 54 | expect(npmTopRepoLastHour(T + 60 * MIN)).toEqual({ repoId: "repo-b", bytes: 1 }); | |
| 55 | }); | |
| 56 | ||
| 57 | test("unmetered or repository-less traffic is attributed to no repository", () => { | |
| 58 | recordEgress("check:nobody", NPM_REGISTRY, 9 * MiB, 0, T); | |
| 59 | install("check:anon", undefined, [[NPM_REGISTRY, 9 * MiB]]); | |
| 60 | expect(npmTopRepoLastHour(T)).toBeUndefined(); | |
| 61 | }); | |
| 62 | ||
| 63 | test("a later holder's repository is kept when the first holder of the label had none", () => { | |
| 64 | const first = meterEgress("app:x/main"); | |
| 65 | const second = meterEgress("app:x/main", "repo-x"); | |
| 66 | recordEgress("app:x/main", NPM_REGISTRY, 2 * MiB, 0, T); | |
| 67 | second.stop(); | |
| 68 | first.stop(); | |
| 69 | expect(npmTopRepoLastHour(T)).toEqual({ repoId: "repo-x", bytes: 2 * MiB }); | |
| 70 | }); | |
| 71 | ||
| 72 | // The step both the check/gate site (`checks/service.ts`) and the app and | |
| 73 | // preview site (`apps/process.ts`) run their contained install through. | |
| 74 | test("a metered install's log ends with the miss line, after the fetched line a check asks for", async () => { | |
| 75 | const lines: string[] = []; | |
| 76 | const result = await meteredInstall( | |
| 77 | "check:run-1", | |
| 78 | "repo-c", | |
| 79 | async () => { | |
| 80 | recordEgress("check:run-1", NPM_REGISTRY, 5 * MiB, 0, T); | |
| 81 | return "done"; | |
| 82 | }, | |
| 83 | (line) => lines.push(line), | |
| 84 | { fetched: true }, | |
| 85 | ); | |
| 86 | expect(result).toBe("done"); | |
| 87 | expect(lines).toEqual([ | |
| 88 | "install fetched 5.2 MB from the registry", | |
| 89 | "install fetched 5.0 MiB from the npm registry; the shared cache missed", | |
| 90 | ]); | |
| 91 | expect(npmTopRepoLastHour(T)).toEqual({ repoId: "repo-c", bytes: 5 * MiB }); | |
| 92 | }); | |
| 93 | ||
| 94 | test("an app install logs only the miss line, and logs it when the install throws", async () => { | |
| 95 | const lines: string[] = []; | |
| 96 | const failing = meteredInstall( | |
| 97 | "app:y/pr-2", | |
| 98 | "repo-y", | |
| 99 | async () => { | |
| 100 | recordEgress("app:y/pr-2", NPM_REGISTRY, 2 * MiB, 0, T); | |
| 101 | throw new Error("aborted"); | |
| 102 | }, | |
| 103 | (line) => lines.push(line), | |
| 104 | ); | |
| 105 | await expect(failing).rejects.toThrow("aborted"); | |
| 106 | expect(lines).toEqual(["install fetched 2.0 MiB from the npm registry; the shared cache missed"]); | |
| 107 | }); | |
| 108 | ||
| 109 | test("an uncontained install is not metered and logs nothing", async () => { | |
| 110 | const lines: string[] = []; | |
| 111 | await meteredInstall(undefined, "repo-z", async () => recordEgress("", NPM_REGISTRY, 9 * MiB, 0, T), (l) => lines.push(l), { fetched: true }); | |
| 112 | expect(lines).toEqual([]); | |
| 113 | expect(npmTopRepoLastHour(T)).toBeUndefined(); | |
| 114 | }); |
new above
offline