Public: anyone can read this; the crew can change it.
Link health, probe and flow actions between forges (limit of #9451) #9564
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9561-link-health-probe-and-flow-actions-betwe → main 3 commits fork 4 threads · 4 unresolved
12 files
changed +399
−20
docs/design/areas/flows.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Flows | |
| 2 | ||
| 3 | No new component class. A flow line is the existing `row` with one `badge`: its text is the line's state ("2 changes behind", "up to date", "not read lately") and its tone follows the link's health (`attn` for news or not read lately, `danger` for diverged or unreadable, default otherwise). An action under the *Flows in* card is the existing secondary `btn`. |
docs/features/flows.md +7 −1
| @@ -8,4 +8,10 @@ | ||
| 8 | 8 | - **Bounded.** A run asks four packages at a time within 30 s total; a repository has one run at a time; one identity gets four runs a minute. Followed and upstream URLs are shown without userinfo, query or fragment. |
| 9 | 9 | - **Pins.** Only exact floors (`1.2.3`, `^1.2.3`, `~1.2.3`) are compared; tags and URLs are skipped. Names are validated before they reach a URL. |
| 10 | 10 | |
| 11 | Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; advisories and code scanning (and the release block on a new high finding); acting on a line in place (update, follow, send back); GitHub release tags; the all-repositories admin view; "last moved" for forks. Each is its own pull on top of `dep_latest` and `readFlows`. | |
| 11 | - **Link health (#9561).** Every line carries a health badge: healthy, news, diverged, unreadable, not read lately (no comparison in 90 minutes) or paused (`followHealth`, `src/flows/view.ts`). A mirror's own origin shows as a line from the head reading in `origin-ahead.md`. | |
| 12 | - **Probe.** The clock already compares heads (`checkDue` for the upstream and sources, `runAheadReads` for an origin); an admin's *Compare heads now* (`POST /:owner/:name/flows/probe`, `src/flows/probe.ts`) runs the same reads on ask: admin only, never an agent, one at a time, four a minute. | |
| 13 | - **Bounded.** A probe reads at most 8 enabled sources, four at a time, and answers within 30 s; it announces itself (`repo.links_probed`, readers of the repository) so an open page offers the refresh. An origin line is stale on the same 90-minute rule; every origin reading records when it was taken. | |
| 14 | - **Terminal.** The Flows screen shows the same lines with health tones and the same gates: `c` compares heads (admin), `p` pulls in what's new (writer, `takeFromOrigin`), `o` names the page that offers a pull to the parent. A guest sees only what they may read. | |
| 15 | - **Actions.** A writer of a mirror with news sees *Pull in what's new* (the existing `sync/take`, which refuses a diverged mirror); a writer of a fork sees *Offer what's useful* (the existing pull-to-parent page). | |
| 16 | ||
| 17 | Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; advisories and code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view; "last moved" for forks. Each is its own pull on top of `dep_latest` and `readFlows`. |
src/core/event_audience.ts +1
| @@ -139,6 +139,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 139 | 139 | "repo.created", |
| 140 | 140 | "repo.vision_kept", |
| 141 | 141 | "repo.forked", |
| 142 | "repo.links_probed", | |
| 142 | 143 | "repo.detached", |
| 143 | 144 | "repo.deleted", |
| 144 | 145 | "label.updated", |
src/flows/probe.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * The head-comparison probe (#9561): one door that re-reads every link a | |
| 3 | * repository follows — its upstream, its sources, its origin — so the flows | |
| 4 | * view shows a fresh health for each. The clock already runs the same reads | |
| 5 | * (`checkDue`, `runAheadReads`); this is the same read, on a person's ask. | |
| 6 | * Admin only, never an agent, one at a time per repository, a few a minute. | |
| 7 | */ | |
| 8 | ||
| 9 | import { checkSource, checkUpstream } from "../actions/upstream_follow.ts"; | |
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { getFollow } from "../core/upstream_follow.ts"; | |
| 12 | import { listSources, type FollowSource } from "../core/follow_sources.ts"; | |
| 13 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 14 | import type { Repo } from "../core/repos.ts"; | |
| 15 | import type { User } from "../core/users.ts"; | |
| 16 | import { readAhead } from "../origins/ahead.ts"; | |
| 17 | import { mayStartRadar } from "./start.ts"; | |
| 18 | ||
| 19 | export type Probed = { ok: true; read: number } | { ok: false; why: string }; | |
| 20 | ||
| 21 | /** The reads one probe makes; tests stand in for the network. */ | |
| 22 | export interface Reads { | |
| 23 | readonly origin: (app: App, repo: Repo) => Promise<boolean>; | |
| 24 | readonly upstream: (app: App, repo: Repo) => Promise<boolean>; | |
| 25 | readonly source: (app: App, repo: Repo, source: FollowSource) => Promise<boolean>; | |
| 26 | } | |
| 27 | ||
| 28 | const realReads: Reads = { | |
| 29 | origin: async (app, repo) => (await readAhead(app, repo)) !== undefined, | |
| 30 | upstream: async (app, repo) => (await checkUpstream(app, repo)).ok, | |
| 31 | source: async (app, repo, source) => (await checkSource(app, repo, source)).ok, | |
| 32 | }; | |
| 33 | ||
| 34 | /** Sources compared in one probe, how many at once, and the total time, as the radar's run is held. */ | |
| 35 | export const MAX_SOURCES = 8; | |
| 36 | export const LANES = 4; | |
| 37 | export const DEADLINE_MS = 30_000; | |
| 38 | ||
| 39 | const running = new Set<string>(); | |
| 40 | const defaultLimiter = new RateLimiter({ perMinute: 4, adminPerMinute: 4, enabled: true }); | |
| 41 | ||
| 42 | export async function probeLinks( | |
| 43 | app: App, | |
| 44 | repo: Repo, | |
| 45 | user: User | undefined, | |
| 46 | limiter: RateLimiter = defaultLimiter, | |
| 47 | reads: Reads = realReads, | |
| 48 | deadlineMs = DEADLINE_MS, | |
| 49 | ): Promise<Probed> { | |
| 50 | if (!mayStartRadar(app, repo, user)) return { ok: false, why: "Only an admin of this repository can do this." }; | |
| 51 | if (running.has(repo.id)) return { ok: false, why: "A comparison is already running for this repository." }; | |
| 52 | const verdict = limiter.take(`flows-probe:${user.id}`, "rw"); | |
| 53 | if (!verdict.allowed) return { ok: false, why: `Too many comparisons: try again in ${verdict.retryAfter}s.` }; | |
| 54 | running.add(repo.id); | |
| 55 | try { | |
| 56 | const stopAt = Date.now() + deadlineMs; | |
| 57 | let read = 0; | |
| 58 | const jobs: (() => Promise<boolean>)[] = []; | |
| 59 | if (repo.origin !== null) jobs.push(() => reads.origin(app, repo)); | |
| 60 | else { | |
| 61 | if (getFollow(app.db, repo.id)?.enabled === true) jobs.push(() => reads.upstream(app, repo)); | |
| 62 | for (const s of listSources(app.db, repo.id).filter((x) => x.enabled).slice(0, MAX_SOURCES)) | |
| 63 | jobs.push(() => reads.source(app, repo, s)); | |
| 64 | } | |
| 65 | // A few at a time; none starts once the deadline has passed, and a read still running | |
| 66 | // at the deadline is left to its own timeout while the probe answers. | |
| 67 | const lanes = Array.from({ length: Math.min(LANES, jobs.length) }, async () => { | |
| 68 | for (let job = jobs.shift(); job !== undefined && Date.now() < stopAt; job = jobs.shift()) { | |
| 69 | try { | |
| 70 | if (await job()) read++; | |
| 71 | } catch (error) { | |
| 72 | app.log.warn("link probe failed", { repo: repo.id, error: String(error) }); | |
| 73 | } | |
| 74 | } | |
| 75 | }); | |
| 76 | await Promise.race([Promise.all(lanes), Bun.sleep(deadlineMs)]); | |
| 77 | app.bus.append({ kind: "repo.links_probed", repoId: repo.id, payload: {} }); | |
| 78 | return { ok: true, read }; | |
| 79 | } finally { | |
| 80 | running.delete(repo.id); | |
| 81 | } | |
| 82 | } |
src/flows/view.ts +51 −4
| @@ -6,8 +6,23 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | import { canRead, getRepoById, visibleForks, type Repo } from "../core/repos.ts"; | |
| 9 | import { getCursor } from "../core/origins.ts"; | |
| 10 | import { canRead, canWrite, getRepoById, visibleForks, type Repo } from "../core/repos.ts"; | |
| 10 | 11 | import type { User } from "../core/users.ts"; |
| 12 | import { aheadWords, parseAhead } from "../origins/ahead.ts"; | |
| 13 | ||
| 14 | /** A followed link is stale when its last head comparison is older than this (three follow periods). */ | |
| 15 | export const STALE_MS = 3 * 30 * 60_000; | |
| 16 | ||
| 17 | /** How a link stands: `ok` level, `behind` has news, `diverged` both moved, `unread` could not be read, `stale` not read lately, `off` not following, `none` nothing to compare. */ | |
| 18 | export type Health = "ok" | "behind" | "diverged" | "unread" | "stale" | "off" | "none"; | |
| 19 | ||
| 20 | /** One thing a person may do about a link: a same-origin POST, or a link to a page. */ | |
| 21 | export interface FlowAction { | |
| 22 | readonly label: string; | |
| 23 | readonly href: string; | |
| 24 | readonly post: boolean; | |
| 25 | } | |
| 11 | 26 | |
| 12 | 27 | export interface FlowLine { |
| 13 | 28 | readonly kind: "origin" | "source" | "upstream" | "fork" | "mirror"; |
| @@ -18,6 +33,8 @@ export interface FlowLine { | ||
| 18 | 33 | /** When it last moved (ISO), or null. */ |
| 19 | 34 | readonly at: string | null; |
| 20 | 35 | readonly problem: boolean; |
| 36 | readonly health: Health; | |
| 37 | readonly action?: FlowAction; | |
| 21 | 38 | } |
| 22 | 39 | |
| 23 | 40 | export interface FlowView { |
| @@ -47,13 +64,28 @@ function behindWords(behind: number, error: string): string { | ||
| 47 | 64 | return behind === 0 ? "up to date" : `${behind} ${behind === 1 ? "change" : "changes"} behind`; |
| 48 | 65 | } |
| 49 | 66 | |
| 50 | export function readFlows(db: Database, repo: Repo, viewer: User | undefined): FlowView { | |
| 67 | /** Health of a followed link from its last comparison; `now` is injectable for tests. */ | |
| 68 | export function followHealth( | |
| 69 | r: { enabled: boolean; checkedAt: string | null; behind: number; error: string }, | |
| 70 | now: number, | |
| 71 | ): Health { | |
| 72 | if (!r.enabled) return "off"; | |
| 73 | if (r.error !== "") return "unread"; | |
| 74 | if (r.checkedAt === null || now - Date.parse(r.checkedAt) > STALE_MS) return "stale"; | |
| 75 | return r.behind > 0 ? "behind" : "ok"; | |
| 76 | } | |
| 77 | ||
| 78 | export function readFlows(db: Database, repo: Repo, viewer: User | undefined, now = Date.now()): FlowView { | |
| 79 | const writer = viewer !== undefined && viewer.kind !== "agent" && canWrite(db, repo, viewer); | |
| 51 | 80 | const inbound: FlowLine[] = []; |
| 52 | 81 | const outbound: FlowLine[] = []; |
| 53 | 82 | if (repo.forkParentId !== null) { |
| 54 | 83 | const parent = getRepoById(db, repo.forkParentId); |
| 55 | 84 | if (parent && canRead(db, parent, viewer)) { |
| 56 | inbound.push({ kind: "origin", label: `${parent.ownerName}/${parent.name}`, href: base(parent), state: "forked from", at: null, problem: false }); | |
| 85 | inbound.push({ | |
| 86 | kind: "origin", label: `${parent.ownerName}/${parent.name}`, href: base(parent), state: "forked from", at: null, problem: false, health: "none", | |
| 87 | ...(writer ? { action: { label: `Offer what's useful to ${parent.ownerName}/${parent.name}`, href: `${base(repo)}/pulls/new`, post: false } } : {}), | |
| 88 | }); | |
| 57 | 89 | } |
| 58 | 90 | } |
| 59 | 91 | const up = db |
| @@ -64,6 +96,7 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined): F | ||
| 64 | 96 | if (up) { |
| 65 | 97 | inbound.push({ |
| 66 | 98 | kind: "upstream", label: `${plainUrl(up.url)} (${up.branch})`, href: "", at: up.checked_at, problem: up.error !== "", |
| 99 | health: followHealth({ enabled: up.enabled !== 0, checkedAt: up.checked_at, behind: up.behind, error: up.error }, now), | |
| 67 | 100 | state: up.enabled === 0 ? "not following" : behindWords(up.behind, up.error), |
| 68 | 101 | }); |
| 69 | 102 | } |
| @@ -74,11 +107,24 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined): F | ||
| 74 | 107 | .all(repo.id)) { |
| 75 | 108 | inbound.push({ |
| 76 | 109 | kind: "source", label: `${plainUrl(s.url)} (${s.branch})`, href: "", at: s.checked_at, problem: s.error !== "", |
| 110 | health: followHealth({ enabled: s.enabled !== 0, checkedAt: s.checked_at, behind: s.behind, error: s.error }, now), | |
| 77 | 111 | state: s.enabled === 0 ? "not following" : behindWords(s.behind, s.error), |
| 78 | 112 | }); |
| 79 | 113 | } |
| 114 | if (repo.origin !== null) { | |
| 115 | const ahead = parseAhead(getCursor(db, repo.origin.originId, `repo:${repo.id}:ahead`)); | |
| 116 | const read = getCursor(db, repo.origin.originId, `repo:${repo.id}:ahead-read`); | |
| 117 | const seenAt = ahead?.at ?? read ?? null; | |
| 118 | const old = seenAt === null || now - Date.parse(seenAt) > STALE_MS; | |
| 119 | const health: Health = old ? "stale" : ahead !== undefined ? (ahead.diverged ? "diverged" : "behind") : "ok"; | |
| 120 | inbound.push({ | |
| 121 | kind: "origin", label: "its origin", href: "", at: seenAt, problem: ahead?.diverged === true, health, | |
| 122 | state: seenAt === null ? "not read yet" : old ? "not read lately" : ahead === undefined ? "up to date" : aheadWords(ahead), | |
| 123 | ...(writer && health === "behind" ? { action: { label: "Pull in what's new", href: `${base(repo)}/sync/take`, post: true } } : {}), | |
outdated
Origin health ignores age: any read cursor gives |
||
| 124 | }); | |
| 125 | } | |
| 80 | 126 | for (const f of visibleForks(db, repo.id, viewer)) { |
| 81 | outbound.push({ kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), state: "fork", at: null, problem: false }); | |
| 127 | outbound.push({ kind: "fork", label: `${f.ownerName}/${f.name}`, href: base(f), state: "fork", at: null, problem: false, health: "none" }); | |
| 82 | 128 | } |
| 83 | 129 | const mirror = db |
| 84 | 130 | .query<{ full_name: string; pushed_at: string | null; last_error: string }, [string]>( |
| @@ -89,6 +135,7 @@ export function readFlows(db: Database, repo: Repo, viewer: User | undefined): F | ||
| 89 | 135 | outbound.push({ |
| 90 | 136 | kind: "mirror", label: mirror.full_name, href: "", at: mirror.pushed_at, problem: mirror.last_error !== "", |
| 91 | 137 | state: mirror.last_error !== "" ? "last push failed" : "mirrored", |
| 138 | health: mirror.last_error !== "" ? "unread" : "ok", | |
| 92 | 139 | }); |
| 93 | 140 | } |
| 94 | 141 | return { inbound, outbound }; |
src/http/server.ts +2 −1
| @@ -151,7 +151,7 @@ import { reconnectSubmit } from "../web/pages/frozen.ts"; | ||
| 151 | 151 | import { activityAtom, changelogAtom } from "../web/feeds.ts"; |
| 152 | 152 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 153 | 153 | import { apiPage, appApiPage } from "../web/pages/api.ts"; |
| 154 | import { flowsCheck, flowsPage } from "../web/pages/flows.ts"; | |
| 154 | import { flowsCheck, flowsPage, flowsProbe } from "../web/pages/flows.ts"; | |
| 155 | 155 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 156 | 156 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 157 | 157 | import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| @@ -1144,6 +1144,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1144 | 1144 | router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit); |
| 1145 | 1145 | router.add("GET", "/:owner/:name/flows", flowsPage); |
| 1146 | 1146 | router.add("POST", "/:owner/:name/flows/check", flowsCheck); |
| 1147 | router.add("POST", "/:owner/:name/flows/probe", flowsProbe); | |
| 1147 | 1148 | router.add("GET", "/:owner/:name/bets", betsPage); |
| 1148 | 1149 | router.add("POST", "/:owner/:name/bets/propose", betPropose); |
| 1149 | 1150 | router.add("GET", "/:owner/:name/bets/:id", betPage); |
src/origins/ahead.ts +4 −3
| @@ -50,10 +50,10 @@ const aheadKey = (repoId: string): string => `repo:${repoId}:ahead`; | ||
| 50 | 50 | /** The last reading of a mirror, or undefined when there is none or it is level. */ |
| 51 | 51 | export function aheadOf(app: App, repo: Repo): Ahead | undefined { |
| 52 | 52 | if (repo.origin === null) return undefined; |
| 53 | return parse(getCursor(app.db, repo.origin.originId, aheadKey(repo.id))); | |
| 53 | return parseAhead(getCursor(app.db, repo.origin.originId, aheadKey(repo.id))); | |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | function parse(value: string | undefined): Ahead | undefined { | |
| 56 | export function parseAhead(value: string | undefined): Ahead | undefined { | |
| 57 | 57 | const [count, tip, diverged, at] = (value ?? "").split(" "); |
| 58 | 58 | const n = Number(count); |
| 59 | 59 | if (!Number.isInteger(n) || n < 1 || !isSafeOid(tip ?? "") || at === undefined) return undefined; |
| @@ -87,6 +87,7 @@ async function read(app: App, repo: Repo): Promise<Ahead | undefined> { | ||
| 87 | 87 | const fetched = await fetchRefFrom(dir, link.cloneUrl, `refs/heads/${repo.defaultBranch}`, authEnv); |
| 88 | 88 | if (!fetched.ok) return undefined; |
| 89 | 89 | const tip = fetched.value; |
| 90 | setCursor(app.db, link.origin.id, `repo:${repo.id}:ahead-read`, new Date().toISOString()); | |
| 90 | 91 | const count = tip === main ? 0 : await aheadCount(dir, main, tip); |
| 91 | 92 | const key = aheadKey(repo.id); |
| 92 | 93 | if (count === 0) { |
| @@ -185,7 +186,7 @@ export function mirrorsBehind(app: App, viewer: User | undefined): { repo: Repo; | ||
| 185 | 186 | const out: { repo: Repo; ahead: Ahead }[] = []; |
| 186 | 187 | for (const row of rows) { |
| 187 | 188 | const repo = getRepoById(app.db, row.id); |
| 188 | const ahead = parse(row.value); | |
| 189 | const ahead = parseAhead(row.value); | |
| 189 | 190 | if (repo !== undefined && ahead !== undefined && canRead(app.db, repo, viewer)) out.push({ repo, ahead }); |
| 190 | 191 | } |
| 191 | 192 | return out; |
src/tui/screens/flows.ts +39 −5
| @@ -5,20 +5,24 @@ | ||
| 5 | 5 | * decides nothing of its own. Access is read at every draw. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { takeFromOrigin } from "../../actions/origins.ts"; | |
| 8 | 9 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 9 | 10 | import { readRadar, type DepLine } from "../../flows/radar.ts"; |
| 10 | 11 | import { pinsOf } from "../../flows/run.ts"; |
| 12 | import { probeLinks } from "../../flows/probe.ts"; | |
| 11 | 13 | import { mayStartRadar, startRadar } from "../../flows/start.ts"; |
| 12 | import { readFlows, type FlowLine } from "../../flows/view.ts"; | |
| 14 | import { readFlows, type FlowLine, type Health } from "../../flows/view.ts"; | |
| 13 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 16 | import { binding } from "../app/keys.ts"; |
| 15 | 17 | import type { Session } from "../session.ts"; |
| 16 | import { ListState, drawList, runAsync, type ListRow } from "./common.ts"; | |
| 18 | import { ListState, drawList, live, runAsync, type ListRow } from "./common.ts"; | |
| 19 | ||
| 20 | const HEALTH_TONE: Partial<Record<Health, "danger" | "attn">> = { diverged: "danger", unread: "danger", behind: "attn", stale: "attn" }; | |
| 17 | 21 | |
| 18 | 22 | const flowRow = (side: string) => (l: FlowLine): ListRow => ({ |
| 19 | 23 | title: `${side} ${l.label}`, |
| 20 | 24 | secondary: l.state, |
| 21 | ...(l.problem ? { tone: "danger" as const } : {}), | |
| 25 | ...(l.problem ? { tone: "danger" as const } : HEALTH_TONE[l.health] ? { tone: HEALTH_TONE[l.health]! } : {}), | |
| 22 | 26 | meta: l.at === null ? "never moved" : l.at.slice(0, 16).replace("T", " "), |
| 23 | 27 | }); |
| 24 | 28 | |
| @@ -38,18 +42,48 @@ export function flowsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 38 | 42 | const view = readFlows(f.db, repo, f.user); |
| 39 | 43 | return [...view.inbound.map(flowRow("in ")), ...deps.map(depRow), ...view.outbound.map(flowRow("out"))]; |
| 40 | 44 | }; |
| 41 | const load = async (): Promise<void> => { | |
| 45 | /** The actions the page would offer this person: the same reads decide. */ | |
| 46 | const actions = () => (visible() ? readFlows(f.db, repo, f.user).inbound.flatMap((l) => (l.action ? [l.action] : [])) : []); | |
| 47 | const load = async (signal?: AbortSignal): Promise<void> => { | |
| 48 | if (signal) live(f, signal, { repoId: repo.id, kinds: ["repo.links_probed", "repo.upstream_checked"] }, () => ctx.invalidate()); | |
| 42 | 49 | deps = visible() ? readRadar(f.db, repo.id, await pinsOf(f.app, repo)) : []; |
| 43 | 50 | ctx.invalidate(); |
| 44 | 51 | }; |
| 45 | 52 | return { |
| 46 | 53 | title: "Flows", |
| 47 | keys: [binding(["j", "k"], "move cursor"), ...(mayStartRadar(f.app, repo, f.user) ? [binding(["r"], "read upstream now")] : []), binding(["esc"], "back")], | |
| 54 | keys: [binding(["j", "k"], "move cursor"), ...(mayStartRadar(f.app, repo, f.user) ? [binding(["r"], "read upstream now"), binding(["c"], "compare heads now")] : []), ...(actions().some((a) => a.post) ? [binding(["p"], "pull in what's new")] : []), ...(actions().some((a) => !a.post) ? [binding(["o"], "offer what's useful")] : []), binding(["esc"], "back")], | |
| 48 | 55 | load, |
| 49 | 56 | render(area, s) { |
| 50 | 57 | drawList(s, area, ctx.theme.c, state, rows()); |
| 51 | 58 | }, |
| 52 | 59 | key(e) { |
| 60 | if (e.ctrl || e.alt) return state.key(e, rows().length); | |
| 61 | if (e.key === "c" && mayStartRadar(f.app, repo, f.user)) { | |
| 62 | runAsync(ctx, async () => { | |
| 63 | const probed = await probeLinks(f.app, repo, f.user); | |
| 64 | if (probed.ok) ctx.toast(`Compared ${probed.read} links.`); | |
| 65 | else ctx.toast(probed.why, "danger"); | |
| 66 | await load(); | |
| 67 | }); | |
| 68 | return true; | |
| 69 | } | |
| 70 | if (e.key === "p" && actions().some((a) => a.post)) { | |
| 71 | runAsync(ctx, async () => { | |
| 72 | const done = await takeFromOrigin(f.app, f.user, repo); | |
| 73 | if (!done.ok) ctx.toast("that could not be taken", "danger"); | |
| 74 | else if (done.value.kind === "conflict") ctx.toast("The mirror holds commits the origin lacks: nothing moved.", "danger"); | |
| 75 | else if (done.value.kind === "unread") ctx.toast("The origin could not be read just now.", "danger"); | |
| 76 | else ctx.toast("Took what's new from the origin."); | |
| 77 | await load(); | |
| 78 | }); | |
| 79 | return true; | |
| 80 | } | |
| 81 | if (e.key === "o") { | |
| 82 | const offer = actions().find((a) => !a.post); | |
| 83 | if (!offer) return false; | |
| 84 | ctx.toast(`Open ${offer.href} on the web to offer it.`); | |
| 85 | return true; | |
| 86 | } | |
| 53 | 87 | if (e.key === "r") { |
| 54 | 88 | runAsync(ctx, async () => { |
| 55 | 89 | const started = await startRadar(f.app, repo, f.user); |
src/web/live.ts +1 −1
| @@ -304,7 +304,7 @@ export const liveClientJs = /* js */ ` | ||
| 304 | 304 | 'label.created', 'label.updated', 'label.deleted', |
| 305 | 305 | 'run.queued', 'run.started', 'run.finished', 'app.serving', |
| 306 | 306 | 'app.created', 'app.updated', 'app.deleted', 'app.stopped', |
| 307 | 'repo.upstream_checked', 'repo.source_added', 'repo.source_removed', 'repo.upstream_follow', 'repo.following', 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 307 | 'repo.upstream_checked', 'repo.links_probed', 'repo.source_added', 'repo.source_removed', 'repo.upstream_follow', 'repo.following', 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed', | |
| 308 | 308 | ]; |
| 309 | 309 | var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean); |
| 310 | 310 | var watching = [repo].concat(extra).filter(function (r, i, all) { |
src/web/pages/flows.ts +31 −4
| @@ -10,23 +10,34 @@ | ||
| 10 | 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 11 | 11 | import { readRadar } from "../../flows/radar.ts"; |
| 12 | 12 | import { pinsOf } from "../../flows/run.ts"; |
| 13 | import { probeLinks } from "../../flows/probe.ts"; | |
| 13 | 14 | import { mayStartRadar, startRadar } from "../../flows/start.ts"; |
| 14 | import { readFlows, type FlowLine } from "../../flows/view.ts"; | |
| 15 | import { readFlows, type FlowAction, type FlowLine, type Health } from "../../flows/view.ts"; | |
| 15 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 17 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 17 | 18 | import { readForm } from "../form.ts"; |
| 18 | 19 | import { html, type Html } from "../html.ts"; |
| 19 | 20 | import { flashTo, page } from "../layout.ts"; |
| 20 | import { badge, btn, card, listCard, row, time } from "../ui.ts"; | |
| 21 | import { badge, btn, type Tone, card, listCard, row, time } from "../ui.ts"; | |
| 21 | 22 | |
| 22 | 23 | const lineRow = (l: FlowLine): Html => |
| 23 | 24 | row({ |
| 24 | 25 | ...(l.href === "" ? {} : { href: l.href }), |
| 25 | 26 | title: html`${l.label}`, |
| 26 | 27 | meta: html`${l.at === null ? "never moved" : time(l.at)}`, |
| 27 | trail: badge(l.state, { tone: l.problem ? "danger" : "default" }), | |
| 28 | trail: badge(l.state, { tone: TONE[l.health] }), | |
outdated
Two badges per row. Use one status badge, and keep the extra words out of the tone map unless DESIGN.md names them. |
||
| 28 | 29 | }); |
| 29 | 30 | |
| 31 | /** A link's tone from its health: `attn` and `danger` are Tone values the badge already has. */ | |
| 32 | const TONE: Record<Health, Tone> = { | |
| 33 | ok: "default", behind: "attn", diverged: "danger", unread: "danger", stale: "attn", off: "default", none: "default", | |
| 34 | }; | |
| 35 | ||
| 36 | const actionForm = (a: FlowAction): Html => | |
| 37 | a.post | |
| 38 | ? html`<form method="post" action="${a.href}" class="mt">${btn(a.label, { variant: "secondary" })}</form>` | |
| 39 | : html`<p class="mt">${btn(a.label, { variant: "secondary", href: a.href })}</p>`; | |
| 40 | ||
| 30 | 41 | function side(title: string, lines: readonly Html[], empty: string): Html { |
| 31 | 42 | return card(lines.length === 0 ? html`<p class="muted">${empty}</p>` : listCard([...lines]), { head: html`${title}`, pad: lines.length === 0 }); |
| 32 | 43 | } |
| @@ -40,6 +51,7 @@ export async function flowsPage(ctx: Ctx): Promise<Response> { | ||
| 40 | 51 | const flows = readFlows(ctx.app.db, repo, ctx.auth.user); |
| 41 | 52 | const deps = readRadar(ctx.app.db, repo.id, await pinsOf(ctx.app, repo)); |
| 42 | 53 | const admin = mayStartRadar(ctx.app, repo, ctx.auth.user); |
| 54 | const actions = flows.inbound.flatMap((l) => (l.action ? [actionForm(l.action)] : [])); | |
| 43 | 55 | const depRows = deps.map((d) => |
| 44 | 56 | row({ |
| 45 | 57 | title: html`${d.name}`, |
| @@ -54,12 +66,14 @@ export async function flowsPage(ctx: Ctx): Promise<Response> { | ||
| 54 | 66 | <div class="split"> |
| 55 | 67 | <div> |
| 56 | 68 | ${side("Flows in", flows.inbound.map(lineRow), "Nothing flows in: no origin and no followed source.")} |
| 69 | ${actions} | |
| 57 | 70 | ${side("Packages", depRows, "No pinned packages.")} |
| 58 | 71 | </div> |
| 59 | 72 | <div>${side("Flows out", flows.outbound.map(lineRow), "Nothing flows out: no forks and no mirror.")}</div> |
| 60 | 73 | </div> |
| 61 | 74 | ${admin |
| 62 | ? html`<form method="post" action="${repoBase(repo)}/flows/check" class="mt">${btn("Read upstream releases now", { variant: "secondary" })}</form>` | |
| 75 | ? html`<form method="post" action="${repoBase(repo)}/flows/probe" class="mt">${btn("Compare heads now", { variant: "secondary" })}</form> | |
| 76 | <form method="post" action="${repoBase(repo)}/flows/check" class="mt">${btn("Read upstream releases now", { variant: "secondary" })}</form>` | |
| 63 | 77 | : null}`; |
| 64 | 78 | const response = page({ ctx, title: `Flows · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }); |
| 65 | 79 | notePhase(ctx.request, "render", performance.now() - rendering); |
| @@ -78,3 +92,16 @@ export async function flowsCheck(ctx: Ctx): Promise<Response> { | ||
| 78 | 92 | const n = started.read; |
| 79 | 93 | return flashTo(ctx, back, { message: n === 0 ? "No exact pins to read." : `Read upstream for ${n} ${n === 1 ? "package" : "packages"}.` }); |
| 80 | 94 | } |
| 95 | ||
| 96 | // POST /:owner/:name/flows/probe | |
| 97 | export async function flowsProbe(ctx: Ctx): Promise<Response> { | |
| 98 | const repo = loadRepo(ctx); | |
| 99 | if (!repo) return notFoundPage(ctx); | |
| 100 | const form = await readForm(ctx, "rw"); | |
| 101 | if (form instanceof Response) return form; | |
| 102 | const back = `${repoBase(repo)}/flows`; | |
| 103 | const probed = await probeLinks(ctx.app, repo, ctx.auth.user); | |
| 104 | if (!probed.ok) return flashTo(ctx, back, { tone: "danger", message: probed.why }); | |
| 105 | const n = probed.read; | |
| 106 | return flashTo(ctx, back, { message: n === 0 ? "No link could be compared." : `Compared ${n} ${n === 1 ? "link" : "links"}.` }); | |
| 107 | } |
test/flows-tui.test.ts +15
| @@ -78,6 +78,21 @@ describe("flows in the terminal", () => { | ||
| 78 | 78 | reader.stop(); |
| 79 | 79 | }); |
| 80 | 80 | |
| 81 | test("compare heads is offered to an admin and not to a reader or a guest", async () => { | |
| 82 | const admin = boot(ada); | |
| 83 | await admin.open(); | |
| 84 | expect(admin.text()).toContain("compare heads now"); | |
| 85 | admin.stop(); | |
| 86 | for (const who of [eve, undefined]) { | |
| 87 | const other = boot(who); | |
| 88 | await other.open(); | |
| 89 | expect(other.text()).not.toContain("compare heads now"); | |
| 90 | await other.press("c"); | |
| 91 | expect(other.text()).not.toContain("Compared"); | |
| 92 | other.stop(); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 81 | 96 | test("a private repository shows a guest nothing", async () => { |
| 82 | 97 | forge.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?").run(repo.id); |
| 83 | 98 | const guest = boot(undefined); |
test/flows.test.ts +163 −1
| @@ -11,8 +11,10 @@ import { readRadar, refreshRadar } from "../src/flows/radar.ts"; | ||
| 11 | 11 | import { compareSemver, gapOf, parseSemver } from "../src/flows/semver.ts"; |
| 12 | 12 | import { RateLimiter } from "../src/core/ratelimit.ts"; |
| 13 | 13 | import { getUserById, type User } from "../src/core/users.ts"; |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { MAX_SOURCES, probeLinks, type Reads } from "../src/flows/probe.ts"; | |
| 14 | 16 | import { startRadar } from "../src/flows/start.ts"; |
| 15 | import { plainUrl, readFlows } from "../src/flows/view.ts"; | |
| 17 | import { followHealth, plainUrl, readFlows } from "../src/flows/view.ts"; | |
| 16 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 19 | import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts"; |
| 18 | 20 | import { seedPreview } from "../src/spawn/preview-seed.ts"; |
| @@ -180,3 +182,163 @@ describe("the page", () => { | ||
| 180 | 182 | } |
| 181 | 183 | }); |
| 182 | 184 | }); |
| 185 | ||
| 186 | describe("link health and actions (#9561)", () => { | |
| 187 | const repoNow = () => getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 188 | const admin = () => getUserById(app.db, app.db.query<{ id: string }, [string]>("SELECT id FROM user WHERE username = ?").get(PREVIEW_ADMIN)!.id)!; | |
| 189 | const now = Date.parse("2026-11-08T12:00:00Z"); | |
| 190 | ||
| 191 | test("followHealth names each standing", () => { | |
| 192 | const at = "2026-11-08T11:50:00Z"; | |
| 193 | expect(followHealth({ enabled: false, checkedAt: at, behind: 3, error: "" }, now)).toBe("off"); | |
| 194 | expect(followHealth({ enabled: true, checkedAt: at, behind: 0, error: "x" }, now)).toBe("unread"); | |
| 195 | expect(followHealth({ enabled: true, checkedAt: null, behind: 0, error: "" }, now)).toBe("stale"); | |
| 196 | expect(followHealth({ enabled: true, checkedAt: "2026-11-08T06:00:00Z", behind: 0, error: "" }, now)).toBe("stale"); | |
| 197 | expect(followHealth({ enabled: true, checkedAt: at, behind: 2, error: "" }, now)).toBe("behind"); | |
| 198 | expect(followHealth({ enabled: true, checkedAt: at, behind: 0, error: "" }, now)).toBe("ok"); | |
| 199 | }); | |
| 200 | ||
| 201 | test("a mirror behind its origin shows news and Pull in what's new, for a writer only", () => { | |
| 202 | const repo = repoNow(); | |
| 203 | const mirrored = { ...repo, origin: { ...(repo.origin ?? {}), originId: "o9" } } as typeof repo; | |
| 204 | app.db.exec("PRAGMA foreign_keys = OFF"); | |
| 205 | app.db.query("INSERT OR REPLACE INTO origin_cursor (origin_id, key, value) VALUES ('o9', ?1, ?2)").run(`repo:${repo.id}:ahead`, `3 ${"a".repeat(40)} 0 2026-11-08T11:00:00Z`); | |
| 206 | const mine = readFlows(app.db, mirrored, admin(), now).inbound.find((l) => l.label === "its origin")!; | |
| 207 | expect(mine.health).toBe("behind"); | |
| 208 | expect(mine.action).toEqual({ label: "Pull in what's new", href: `/${PREVIEW_ADMIN}/${PREVIEW_REPO}/sync/take`, post: true }); | |
| 209 | const guest = readFlows(app.db, mirrored, undefined, now).inbound.find((l) => l.label === "its origin")!; | |
| 210 | expect(guest.health).toBe("behind"); | |
| 211 | expect(guest.action).toBeUndefined(); | |
| 212 | app.db.query(`UPDATE origin_cursor SET value = ?1 WHERE origin_id = 'o9'`).run(`3 ${"a".repeat(40)} 1 2026-11-08T11:00:00Z`); | |
| 213 | const diverged = readFlows(app.db, mirrored, admin(), now).inbound.find((l) => l.label === "its origin")!; | |
| 214 | expect(diverged.health).toBe("diverged"); | |
| 215 | expect(diverged.action).toBeUndefined(); | |
| 216 | app.db.query("DELETE FROM origin_cursor WHERE origin_id = 'o9'").run(); | |
| 217 | app.db.exec("PRAGMA foreign_keys = ON"); | |
| 218 | }); | |
| 219 | ||
| 220 | test("the probe is admin only and a cross-origin POST changes nothing", async () => { | |
| 221 | const repo = repoNow(); | |
| 222 | const stranger = { ...admin(), id: "nobody", isAdmin: false } as User; | |
| 223 | expect(await probeLinks(app, repo, stranger)).toEqual({ ok: false, why: "Only an admin of this repository can do this." }); | |
| 224 | expect(await probeLinks(app, repo, undefined)).toMatchObject({ ok: false }); | |
| 225 | const said = `u1|trevato|admin|${Date.now() + 60_000}`; | |
| 226 | const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 227 | const url = `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`; | |
| 228 | const page = await (await fetch(url, { headers })).text(); | |
| 229 | expect(page).toContain("Compare heads now"); | |
| 230 | const post = await fetch(`${url}/probe`, { method: "POST", redirect: "manual", headers: { ...headers, origin: "https://evil.example" } }); | |
| 231 | expect(post.status).toBe(403); | |
| 232 | }); | |
| 233 | ||
outdated
This returns early and asserts nothing when there is one repo. Build a real parent and fork. |
||
| 234 | test("a fork's writer is offered a pull to its parent; a guest is not", async () => { | |
| 235 | const parent = repoNow(); | |
| 236 | const made = await createRepo(app, admin(), { name: "kid", owner: PREVIEW_ADMIN, visibility: "public", description: "" }); | |
| 237 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 238 | app.db.query("UPDATE repo SET fork_parent_id = ?1 WHERE id = ?2").run(parent.id, made.value.id); | |
| 239 | const fork = getRepo(app.db, PREVIEW_ADMIN, "kid")!; | |
| 240 | const mine = readFlows(app.db, fork, admin(), now).inbound.find((l) => l.state === "forked from")!; | |
| 241 | expect(mine.label).toBe(`${PREVIEW_ADMIN}/${PREVIEW_REPO}`); | |
| 242 | expect(mine.action).toEqual({ label: `Offer what's useful to ${PREVIEW_ADMIN}/${PREVIEW_REPO}`, href: `/${PREVIEW_ADMIN}/kid/pulls/new`, post: false }); | |
| 243 | expect(readFlows(app.db, fork, undefined, now).inbound.find((l) => l.state === "forked from")!.action).toBeUndefined(); | |
| 244 | const agent = { ...admin(), kind: "agent" } as User; | |
| 245 | expect(readFlows(app.db, fork, agent, now).inbound.find((l) => l.state === "forked from")!.action).toBeUndefined(); | |
| 246 | }); | |
| 247 | ||
| 248 | test("an origin line is ok when read lately, stale when read long ago, not read yet when never", () => { | |
| 249 | const repo = repoNow(); | |
| 250 | const mirrored = { ...repo, origin: { ...(repo.origin ?? {}), originId: "o8" } } as typeof repo; | |
| 251 | const line = () => readFlows(app.db, mirrored, undefined, now).inbound.find((l) => l.label === "its origin")!; | |
| 252 | expect([line().health, line().state]).toEqual(["stale", "not read yet"]); | |
| 253 | app.db.exec("PRAGMA foreign_keys = OFF"); | |
| 254 | const put = (at: string) => app.db.query("INSERT OR REPLACE INTO origin_cursor (origin_id, key, value) VALUES ('o8', ?1, ?2)").run(`repo:${repo.id}:ahead-read`, at); | |
| 255 | put("2026-11-08T11:50:00Z"); | |
| 256 | expect([line().health, line().state]).toEqual(["ok", "up to date"]); | |
| 257 | put("2026-11-08T06:00:00Z"); | |
| 258 | expect([line().health, line().state]).toEqual(["stale", "not read lately"]); | |
| 259 | app.db.query("DELETE FROM origin_cursor WHERE origin_id = 'o8'").run(); | |
| 260 | app.db.exec("PRAGMA foreign_keys = ON"); | |
| 261 | }); | |
| 262 | ||
| 263 | describe("the probe", () => { | |
| 264 | const reads = (log: string[], over: Partial<Reads> = {}): Reads => ({ | |
| 265 | origin: async () => (log.push("origin"), true), | |
| 266 | upstream: async () => (log.push("upstream"), true), | |
| 267 | source: async (_a, _r, s) => (log.push(`source:${s.id}`), true), | |
| 268 | ...over, | |
| 269 | }); | |
| 270 | const limiter = (n: number) => new RateLimiter({ perMinute: n, adminPerMinute: n, enabled: true }); | |
| 271 | const addSources = (repoId: string, n: number) => { | |
| 272 | for (let i = 0; i < n; i++) | |
| 273 | app.db.query("INSERT OR IGNORE INTO repo_source (id, repo_id, url, branch, by_id, enabled, created_at) VALUES (?1, ?2, 'https://example.com/p.git', 'main', 'u1', 1, '2026-11-03T00:00:00Z')").run(`p${i}`, repoId); | |
| 274 | }; | |
| 275 | ||
| 276 | test("a successful probe reads the enabled sources and announces itself", async () => { | |
| 277 | const repo = repoNow(); | |
| 278 | addSources(repo.id, 2); | |
| 279 | const log: string[] = []; | |
| 280 | const seen: string[] = []; | |
| 281 | const off = app.bus.subscribe((e) => seen.push(e.kind)); | |
| 282 | const out = await probeLinks(app, repo, admin(), limiter(4), reads(log)); | |
| 283 | off(); | |
| 284 | expect(out.ok).toBe(true); | |
| 285 | expect(log).toContain("source:p0"); | |
| 286 | expect(log).toContain("source:s1"); | |
| 287 | expect(seen).toContain("repo.links_probed"); | |
| 288 | }); | |
| 289 | ||
| 290 | test("an agent is refused and reads nothing", async () => { | |
| 291 | const log: string[] = []; | |
| 292 | const agent = { ...admin(), kind: "agent" } as User; | |
| 293 | expect(await probeLinks(app, repoNow(), agent, limiter(4), reads(log))).toEqual({ ok: false, why: "Only an admin of this repository can do this." }); | |
| 294 | expect(log).toEqual([]); | |
| 295 | }); | |
| 296 | ||
| 297 | test("one at a time, and four a minute", async () => { | |
| 298 | const repo = repoNow(); | |
| 299 | let release!: () => void; | |
| 300 | const gate = new Promise<void>((r) => (release = r)); | |
| 301 | const slow = reads([], { source: async () => { await gate; return true; } }); | |
| 302 | const lim = limiter(4); | |
| 303 | const first = probeLinks(app, repo, admin(), lim, slow); | |
| 304 | await Promise.resolve(); | |
| 305 | expect(await probeLinks(app, repo, admin(), lim, slow)).toEqual({ ok: false, why: "A comparison is already running for this repository." }); | |
| 306 | release(); | |
| 307 | expect((await first).ok).toBe(true); | |
| 308 | const tight = limiter(1); | |
| 309 | await probeLinks(app, repo, admin(), tight, reads([])); | |
| 310 | const third = await probeLinks(app, repo, admin(), tight, reads([])); | |
| 311 | expect(!third.ok && third.why).toMatch(/^Too many comparisons/); | |
| 312 | }); | |
| 313 | ||
| 314 | test("it is capped in sources and bounded in time", async () => { | |
| 315 | const repo = repoNow(); | |
| 316 | addSources(repo.id, 12); | |
| 317 | const log: string[] = []; | |
| 318 | await probeLinks(app, repo, admin(), limiter(4), reads(log)); | |
| 319 | expect(log.filter((l) => l.startsWith("source:")).length).toBeLessThanOrEqual(MAX_SOURCES); | |
| 320 | const hang = reads([], { source: () => new Promise<boolean>(() => {}) }); | |
| 321 | const t0 = Date.now(); | |
| 322 | const out = await probeLinks(app, repo, admin(), limiter(4), hang, 60); | |
| 323 | expect(out.ok).toBe(true); | |
| 324 | expect(Date.now() - t0).toBeLessThan(2000); | |
| 325 | }); | |
| 326 | ||
| 327 | test("a failing read does not stop the others", async () => { | |
| 328 | const repo = repoNow(); | |
| 329 | const log: string[] = []; | |
| 330 | const out = await probeLinks(app, repo, admin(), limiter(4), reads(log, { upstream: async () => { throw new Error("boom"); } })); | |
| 331 | expect(out.ok).toBe(true); | |
| 332 | expect(log.some((l) => l.startsWith("source:"))).toBe(true); | |
| 333 | }); | |
| 334 | ||
| 335 | test("the route flashes the result for an admin", async () => { | |
| 336 | const said = `u1|trevato|admin|${Date.now() + 60_000}`; | |
| 337 | const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 338 | const url = `${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`; | |
| 339 | const post = await fetch(`${url}/probe`, { method: "POST", redirect: "manual", headers: { ...headers, origin: servers.url } }); | |
| 340 | expect(post.status).toBe(303); | |
| 341 | expect(post.headers.get("location")).toBe(`/${PREVIEW_ADMIN}/${PREVIEW_REPO}/flows`); | |
| 342 | }); | |
| 343 | }); | |
| 344 | }); |
Sequential, unbounded network reads over every source. Add a total time budget and a cap, as the radar run has.