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

An admin can hold new work on one file: the picker starts no build whose issue names it, and the hold says who set it #6528

Merged opened this

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

Merged

scout/forge:scout/6522-an-admin-can-hold-new-work-on-one-file-t → main 5 commits fork 7 threads · 7 unresolved

  • Merged. scout/6522-an-admin-can-hold-new-work-on-one-file-t was merged into main as c86f12e.

Review

Sign in to review this change.
17 files changed +662 −12
docs/MCP.md +1 −1
@@ -77,7 +77,7 @@ for a tunnel and a forge that sleeps.
7777 | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | |
7878 | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes |
7979 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
80 | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes |
80 | `hold` | `{ app, pull | path, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false` | yes |
8181 | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown | yes |
8282 | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes |
8383 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found | |
docs/features/file-holds.md added +21
@@ -0,0 +1,21 @@
1 # Hold new work on a file
2
3 An admin of a repository can hold new work on one path. While it is held, the
4 picker starts no build whose issue names that path (the same `hot_file` hold a
5 file in 5 open pulls gets), and the feed says `#12 waits: alice holds new work
6 on src/http/mcp.ts.` Nothing already running is cut. Releasing the path frees
7 the issues.
8
9 - **Page:** the repository's Settings, "Held files" card (admins only): lists each
10 hold with who set it and when, and the issues it keeps waiting; one form holds
11 a path, a button releases it.
12 - **MCP:** `hold { app, path, release?, dryRun? }` (no `pull`); plans unless
13 `dryRun: false`; with no `path` it lists the holds.
14 - **Terminal:** the "Held files" command. `owner/name path` holds,
15 `release owner/name path` frees, `owner/name` lists.
16
17 A path is relative, has no `..`, `.` or empty parts, and is under 300
18 characters. The hold names the signed-in admin; it is never taken from the request.
19
20 An agent that administers the repository may hold a file, unlike a pull hold,
21 which is people-only: the hold names its setter, so it is never silent.
src/actions/file_holds.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * Holding new work on a file and letting it go (`crew/file_holds.ts`, #6522).
3 *

Note: agents that administer the repository may hold files, unlike hold { pull }, which is people-only. That's fine, since the hold names its setter. Say it in docs/features/file-holds.md.

4 * An admin of the repository decides, a person or an agent that is one; a
5 * reader and a stranger get the one `not_found` every admin door gives.
6 * `by` is the verified actor, never a value from the request. `dry` checks
7 * everything and writes nothing.
8 */
9
10 import type { App } from "../app.ts";
11 import { canAdmin, canRead, type Repo } from "../core/repos.ts";
12 import { getUserById, type User } from "../core/users.ts";
13 import {
14 checkHoldPath,
15 heldFiles,
16 holdFile,
17 releaseFile,
18 type FileHold,
19 } from "../crew/file_holds.ts";
20 import { err, ok, type Result } from "../kernel/result.ts";
21
22 export type FileHoldError = "unauthorized" | "not_found" | "invalid_path";
23
24 /** A hold with the name of the person who set it. */
25 export interface NamedHold extends FileHold {
26 readonly byName: string;
27 }
28
29 function nameHold(app: App, hold: FileHold): NamedHold {
30 return { ...hold, byName: getUserById(app.db, hold.by)?.username ?? "someone" };
31 }
32
33 function requireAdmin(app: App, actor: User | undefined, repo: Repo): Result<User, FileHoldError> {
34 if (!canRead(app.db, repo, actor)) return err("not_found");
35 if (!actor) return err("unauthorized");
36 return canAdmin(app.db, repo, actor) ? ok(actor) : err("not_found");
37 }
38
39 /** Every file held in `repo`, for an admin of it; anyone else is told nothing. */
40 export function listFileHolds(app: App, actor: User | undefined, repo: Repo): Result<NamedHold[], FileHoldError> {
41 const admin = requireAdmin(app, actor, repo);
42 if (!admin.ok) return admin;
43 return ok(heldFiles(app.db, repo.id).map((h) => nameHold(app, h)));
44 }
45
46 /** Hold new work on `path`; the hold names `actor`. */
47 export function holdFileAs(
48 app: App,
49 actor: User | undefined,
50 repo: Repo,
51 path: unknown,
52 dry = false,
53 ): Result<NamedHold, FileHoldError> {
54 const admin = requireAdmin(app, actor, repo);
55 if (!admin.ok) return admin;
56 const checked = checkHoldPath(path);
57 if (!checked.ok) return err("invalid_path");
58 if (dry) {
59 const standing = heldFiles(app.db, repo.id).find((h) => h.path === checked.value);
60 return ok(nameHold(app, standing ?? { repoId: repo.id, path: checked.value, by: admin.value.id, at: new Date().toISOString() }));
61 }
62 const held = holdFile(app.db, repo.id, checked.value, admin.value.id);
63 if (!held.ok) return err("invalid_path");
64 app.bus.append({
65 kind: "repo.file_held",
66 repoId: repo.id,
67 actorId: admin.value.id,
68 payload: { path: held.value.path },
69 });
70 return ok(nameHold(app, held.value));
71 }
72
73 /** Lift the hold on `path`; whether there was one. */
74 export function releaseFileAs(
75 app: App,
76 actor: User | undefined,
77 repo: Repo,
78 path: unknown,
79 dry = false,
80 ): Result<boolean, FileHoldError> {
81 const admin = requireAdmin(app, actor, repo);
82 if (!admin.ok) return admin;
83 const checked = checkHoldPath(path);
84 if (!checked.ok) return err("invalid_path");
85 if (dry) return ok(heldFiles(app.db, repo.id).some((h) => h.path === checked.value));
86 const released = releaseFile(app.db, repo.id, checked.value);
87 if (released)
88 app.bus.append({
89 kind: "repo.file_released",
90 repoId: repo.id,
91 actorId: admin.value.id,
92 payload: { path: checked.value },
93 });
94 return ok(released);
95 }
src/core/event_audience.ts +4 −1
@@ -168,7 +168,10 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
168168 // address is often a private repository's.
169169 kind === "repo.upstream_follow" ||
170170 kind === "repo.upstream_checked" ||
171 kind === "repo.crew_outsiders"
171 kind === "repo.crew_outsiders" ||
172 // A file held against new work is its admins' book-keeping (#6522).
173 kind === "repo.file_held" ||
174 kind === "repo.file_released"
172175 )
173176 return "admins";
174177 // A fleet operation spans repositories; its receipt is its actor's. So is
src/core/migrations/20261026180000-file-hold.ts added +12
@@ -0,0 +1,12 @@
1 // One row per file an admin holds new work on (#6522, crew/file_holds.ts): the
2 // picker starts no build whose issue names the path, and the hold says who set
3 // it and when. Additive.
4 export default `
5 CREATE TABLE file_hold (
6 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
7 path TEXT NOT NULL,
8 by TEXT NOT NULL,
9 at TEXT NOT NULL,
10 PRIMARY KEY (repo_id, path)
11 );
12 `;
src/crew/autonomy.ts +8 −1
@@ -381,7 +381,14 @@ export async function driveRepo(
381381 const sorted = () => heldBy(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active);
382382 let held = sorted();
383383 if (freeParked(app, repo, by, held) > 0) held = sorted();
384 holdHot(repo.id, held.by.hot_file, now);
384 holdHot(
385 repo.id,
386 held.by.hot_file,
387 now,
388 new Map(
389 [...held.pinned].map(([n, h]) => [n, { path: h.path, byName: getUserById(app.db, h.by)?.username ?? "someone" }]),

Minor: this repeats nameHold from actions/file_holds.ts. Export one helper so the 'someone' fallback can't drift.

390 ),
391 );
385392 const ready = held.startable;
386393 if (slotted.length >= paced.slots) {
387394 // The builders are busy; the planner can still refill the map, when
src/crew/file_holds.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * Hold new work on one file (#6522): an admin names a repository path and the
3 * picker starts no build whose issue names it (`held.ts`, `hot_file`), until
4 * the hold is released. A hold keeps who set it and when.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 /** The longest path a hold takes. */
10 export const MAX_HOLD_PATH = 300;
11
12 export interface FileHold {
13 readonly repoId: string;
14 readonly path: string;
15 readonly by: string;
16 readonly at: string;
17 }
18
19 export type HoldResult = { ok: true; value: FileHold } | { ok: false; error: string };
20
21 /** The path as held, or why it cannot be: relative, no `..`, under 300 characters. */
22 export function checkHoldPath(raw: unknown): { ok: true; value: string } | { ok: false; error: string } {
23 if (typeof raw !== "string") return { ok: false, error: "path must be text" };
24 const path = raw.trim();
25 if (path === "") return { ok: false, error: "path is empty" };
26 if (path.length > MAX_HOLD_PATH) return { ok: false, error: `path is longer than ${MAX_HOLD_PATH} characters` };
27 if (/[\0-\x1f\x7f\\]/.test(path)) return { ok: false, error: "path has a control character or backslash" };
28 if (path.startsWith("/")) return { ok: false, error: "path must be relative" };
29 if (path.split("/").some((part) => part === ".." || part === "." || part === ""))
30 return { ok: false, error: "path must not contain `..`, `.` or empty parts" };
31 return { ok: true, value: path };
32 }
33
34 /** Hold new work on `path` in `repoId`, set by user `by`; holding it again keeps the first hold. */
35 export function holdFile(db: Database, repoId: string, path: unknown, by: string, now = new Date()): HoldResult {
36 const checked = checkHoldPath(path);
37 if (!checked.ok) return checked;
38 const at = now.toISOString();
39 db.query(`INSERT OR IGNORE INTO file_hold (repo_id, path, by, at) VALUES (?1, ?2, ?3, ?4)`).run(
40 repoId,
41 checked.value,
42 by,

Nothing outside the test calls this. It needs an admin-gated page, tty and MCP caller, which check who is asking and record by from the verified identity.

43 at,
44 );
45 const row = db
46 .query<{ repo_id: string; path: string; by: string; at: string }, [string, string]>(
47 `SELECT repo_id, path, by, at FROM file_hold WHERE repo_id = ?1 AND path = ?2`,
48 )
49 .get(repoId, checked.value)!;
50 return { ok: true, value: { repoId: row.repo_id, path: row.path, by: row.by, at: row.at } };
51 }
52
53 /** Lift the hold on `path`; whether there was one. */
54 export function releaseFile(db: Database, repoId: string, path: unknown): boolean {
55 const checked = checkHoldPath(path);
56 if (!checked.ok) return false;
57 return db.query(`DELETE FROM file_hold WHERE repo_id = ?1 AND path = ?2`).run(repoId, checked.value).changes > 0;
58 }
59
60 /** Every file held in `repoId`, oldest hold first. */
61 export function heldFiles(db: Database, repoId: string): FileHold[] {
62 return db
63 .query<{ repo_id: string; path: string; by: string; at: string }, [string]>(
64 `SELECT repo_id, path, by, at FROM file_hold WHERE repo_id = ?1 ORDER BY at, path`,
65 )
66 .all(repoId)
67 .map((r) => ({ repoId: r.repo_id, path: r.path, by: r.by, at: r.at }));
68 }
src/crew/held.ts +17 −3
@@ -21,7 +21,8 @@
2121 * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`).
2222 * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`).
2323 * - `hot_file`: a file it names is touched by `HOT_PULLS` or more open
24 * pulls; it starts when that pile drains under the limit (`hot_files.ts`).
24 * pulls, or an admin holds it (`file_holds.ts`); it starts when that pile
25 * drains under the limit or the hold is released (`hot_files.ts`).
2526 *
2627 * The direction issue is the planner's standing issue and never a
2728 * builder's; it is left out, not counted.
@@ -41,7 +42,8 @@ import { proposedWork, speaksForCrew } from "./direct.ts";
4142 import { capReached, runCapOf } from "./guard.ts";
4243 import { interruptionOf } from "./interrupted.ts";
4344 import { PARK_STRIKES, parkedCount } from "./parks.ts";
44 import { hotFiles, hotOverlap } from "./hot_files.ts";
45 import { heldFiles, type FileHold } from "./file_holds.ts";
46 import { expectedFiles, hotFiles, hotOverlap } from "./hot_files.ts";
4547 import { coveredByRedFix } from "./red_fix.ts";
4648
4749 export const HOLDS = [
@@ -67,6 +69,8 @@ export interface Held {
6769 readonly startable: Ranked[];
6870 /** Each held issue under the first filter that holds it. */
6971 readonly by: Readonly<Record<HoldName, readonly Issue[]>>;
72 /** Of the `hot_file` ones, those an admin's hold keeps: issue number to the hold that does. */
73 readonly pinned: ReadonlyMap<number, FileHold>;
7074 }
7175
7276 /**
@@ -125,6 +129,11 @@ export function heldBy(
125129 );
126130 const handed = withPlanner(app.db, repo);
127131 const hot = hotFiles(repo.id);
132 const pins = new Map(heldFiles(app.db, repo.id).map((h) => [h.path, h]));
133 const pinned = new Map<number, FileHold>();
134 // The hold an admin set on a file the issue names, if any: the first by hold order.
135 const pinOf = (issue: Issue): FileHold | undefined =>
136 pins.size === 0 ? undefined : expectedFiles(issue).map((f) => pins.get(f)).find((h) => h !== undefined);
128137 const holdOf = (issue: Issue): HoldName | null => {
129138 if (busyIssues.has(issue.id)) return "running";
130139 if (proposed.has(issue.number)) return "open_pull";
@@ -135,6 +144,11 @@ export function heldBy(
135144 if (atRunCap(app, repo, issue)) return "run_cap";
136145 if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice";
137146 if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix";
147 const pin = pinOf(issue);
148 if (pin !== undefined) {
149 pinned.set(issue.number, pin);
150 return "hot_file";
151 }
138152 if (hotOverlap(issue, hot).length > 0) return "hot_file";
139153 return null;
140154 };
@@ -149,7 +163,7 @@ export function heldBy(
149163 if (hold === null) startable.push(r);
150164 else by[hold].push(r.issue);
151165 }
152 return { startable, by };
166 return { startable, by, pinned };
153167 }
154168
155169 /** The count per filter, in `HOLDS` order. */
src/crew/hot_files.ts +13 −2
@@ -174,6 +174,8 @@ export interface HotHold {
174174 readonly number: number;
175175 readonly title: string;
176176 readonly files: readonly HotFile[];
177 /** The admin's hold (`file_holds.ts`) that keeps this build, with the setter's name. */
178 readonly pin?: { readonly path: string; readonly byName: string };
177179 }
178180
179181 /** The pulls too wide to count as the picker last saw them, per repository, held builds or not. */
@@ -188,7 +190,12 @@ export function standingWide(repoId: string): { at: string; wide: readonly WideP
188190 const standing = new Map<string, { at: string; holds: readonly HotHold[]; wide: readonly WidePull[] }>();
189191
190192 /** Remember the builds the picker holds on hot files in `repoId` now; none clears it. */
191 export function holdHot(repoId: string, issues: readonly Issue[], at = new Date()): void {
193 export function holdHot(
194 repoId: string,
195 issues: readonly Issue[],
196 at = new Date(),
197 pins: ReadonlyMap<number, { path: string; byName: string }> = new Map(),
198 ): void {
192199 const wide = widePulls(repoId);
193200 if (wide.length === 0) standingWideBy.delete(repoId);
194201 else standingWideBy.set(repoId, { at: at.toISOString(), wide });
@@ -199,7 +206,10 @@ export function holdHot(repoId: string, issues: readonly Issue[], at = new Date(
199206 const hot = hotFiles(repoId);
200207 standing.set(repoId, {
201208 at: at.toISOString(),
202 holds: issues.map((i) => ({ number: i.number, title: i.title, files: hotOverlap(i, hot) })),
209 holds: issues.map((i) => {
210 const pin = pins.get(i.number);
211 return { number: i.number, title: i.title, files: hotOverlap(i, hot), ...(pin === undefined ? {} : { pin }) };
212 }),
203213 wide,
204214 });
205215 }
@@ -221,6 +231,7 @@ export function hotWords(hot: ReadonlyMap<string, number>): string {
221231
222232 /** One sentence for a held build: `#12 waits: src/http/mcp.ts is in 6 open pulls (hot at 5).` */
223233 export function hotSays(hold: HotHold): string {
234 if (hold.pin !== undefined) return `#${hold.number} waits: ${hold.pin.byName} holds new work on ${hold.pin.path}.`;
224235 const files = hold.files
225236 .slice(0, 3)
226237 .map((f) => `${f.path} is in ${f.pulls} open pulls`)
src/http/mcp/tools/hold.ts +49 −4
@@ -1,17 +1,61 @@
1 import { holdFileAs, listFileHolds, releaseFileAs } from "../../../actions/file_holds.ts";
2 import { scopeAllows } from "../../../core/tokens.ts";
13 import { holdPull, releasePull } from "../../../actions/holds.ts";
24 import { getPull } from "../../../core/pulls.ts";
3 import { good, bad, type Tool, str, APP_ARG, planRepo, isAnswer } from "../shared.ts";
5 import { good, bad, type Answer, type Tool, str, APP_ARG, planRepo, isAnswer, type Args } from "../shared.ts";
6 import type { Ctx } from "../../context.ts";
7 import type { Repo } from "../../../core/repos.ts";
8 import type { User } from "../../../core/users.ts";
9
10 /** `hold` without a pull: new work on a file (#6522), an admin's. */
11 function fileHold(ctx: Ctx, user: User, repo: Repo, args: Args): Answer {
12 const refused = bad("forbidden", "holding files is for an admin of this app");
13 const invalid = bad("invalid", "path is relative, without .., under 300 characters");
outdated

Low: with no path, this lists the holds even when the caller sent release: true or why. A caller who forgot path gets a list back and may think the release worked. Return invalid when release/why comes without path.

14 if (args["path"] === undefined) {
15 if (args["release"] !== undefined || args["why"] !== undefined)
16 return bad("invalid", "release and why need a path; send path, or neither to list");
17 const listed = listFileHolds(ctx.app, user, repo);
18 if (!listed.ok) return refused;
19 return good({
20 says: listed.value.length === 0 ? "No file is held." : `${listed.value.length} file(s) held.`,
21 holds: listed.value.map((h) => ({ path: h.path, by: h.byName, at: h.at })),
22 });
23 }
24 const path = str(args["path"]);
outdated

High: from here on, a real write needs only rw scope. The settings page needs an admin-scope session for the same write (adminForm → readForm(ctx, "admin")), and repo_settings/hooks need admin scope too. Gate it: if (!dry && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "holding files needs an admin token"); Then add a test with an admin's rw token.

25 const dry = args["dryRun"] !== false;
26 const hint = "Nothing changed; send dryRun: false to do it.";
27 if (!dry && !scopeAllows(ctx.auth.scope ?? "ro", "admin"))
28 return bad("forbidden", "holding files needs an admin token");
29 if (args["release"] === true) {
30 const released = releaseFileAs(ctx.app, user, repo, path, dry);
31 if (!released.ok) return released.error === "invalid_path" ? invalid : refused;
32 return good({
33 says: !released.value ? `${path} is not held.` : dry ? `Would release ${path}. ${hint}` : `Released ${path}.`,
34 dryRun: dry,
35 });
36 }
37 const held = holdFileAs(ctx.app, user, repo, path, dry);
38 if (!held.ok) return held.error === "invalid_path" ? invalid : refused;
39 return good({
40 says: dry
41 ? `Would hold new work on ${held.value.path}. ${hint}`
42 : `Held new work on ${held.value.path}: no build starts on an issue that names it.`,
43 hold: { path: held.value.path, by: held.value.byName, at: held.value.at },
44 dryRun: dry,
45 });
46 }
447
548 export const tool: Tool = {
649 name: "hold",
7 doc: "`{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false`",
50 doc: "`{ app, pull | path, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; or with `path` (no `pull`) hold new work on a file so the picker starts no build whose issue names it, or list the holds and who set each; an admin of the app; plans unless `dryRun: false`",
851 description:
9 "Hold a pull (`why`) or release it (`release: true`); held, none reviews or lands it. Writers.",
52 "Hold a pull (`why`) or `release` it; or `path`: hold new work on a file. Neither: list file holds.",
1053 inputSchema: {
1154 type: "object",
1255 properties: {
1356 app: APP_ARG,
1457 pull: { type: "integer" },
58 path: { type: "string", description: "A file: relative, no `..`." },
1559 why: {
1660 type: "string",
1761 description: "Why, one line.",
@@ -21,13 +65,14 @@ export const tool: Tool = {
2165 },
2266 dryRun: { type: "boolean" },
2367 },
24 required: ["app", "pull"],
68 required: ["app"],
2569 },
2670 writes: true,
2771 dryByDefault: true,
2872 run(ctx, user, args) {
2973 const repo = planRepo(ctx, user, args);
3074 if (isAnswer(repo)) return repo;
75 if (args["pull"] === undefined) return fileHold(ctx, user, repo, args);
3176 const n = args["pull"];
3277 const pull =
3378 typeof n === "number" && Number.isInteger(n) && n > 0
src/http/server.ts +2
@@ -339,6 +339,7 @@ import {
339339 agentUpdate,
340340 crewPage,
341341 } from "../web/pages/crew.ts";
342 import { fileHoldUpdate } from "../web/pages/file_holds.ts";
342343 import { newPullPage, newPullSubmit } from "../web/pages/pulls.ts";
343344 import {
344345 pullPage,
@@ -1441,6 +1442,7 @@ export function buildRouter(): Router<Handler> {
14411442 );
14421443 router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage);
14431444 router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate);
1445 router.add("POST", "/:owner/:name/settings/holds", fileHoldUpdate);
14441446 router.add("POST", "/:owner/:name/settings/upstream", followUpstreamUpdate);
14451447 router.add("POST", "/:owner/:name/settings/upstream/check", followUpstreamCheck);
14461448 router.add("GET", "/:owner/:name/settings/labels", labelsPage);
src/tui/forge.ts +9
@@ -74,6 +74,7 @@ import { queuesFor, systemFor } from "../system/telemetry.ts";
7474 import { live as liveMachine } from "../system/live.ts";
7575 import { releasesScreen } from "./screens/releases.ts";
7676 import { mayRead } from "../release/gate.ts";
77 import { fileHoldsPrompt } from "./screens/fileholds.ts";
7778 import { stalledScreen } from "./screens/stalled.ts";
7879 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
7980 import { limbsScreen } from "./screens/limbs.ts";
@@ -360,6 +361,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
360361 hint: "pulls whose check is red or was never asked for; enter re-runs",
361362 run: () => ctx.push(stalledScreen(session, ctx)),
362363 });
364 // Hold new work on a file: the settings card's twin, admin-checked by the action itself.
365 if (session.signedIn)
366 commands.push({
367 id: "file-holds",
368 title: "Held files",
369 hint: "hold new work on a file, release it, or list the holds",
370 run: () => ctx.push(fileHoldsPrompt(session, ctx, blank("Held files"))),
371 });
363372 // What outside agents tried, as Settings → Access says it: a site admin's (#1768).
364373 if (session.user?.isAdmin === true && session.user.kind !== "agent")
365374 commands.push({
src/tui/screens/fileholds.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Hold new work on a file, from the terminal (#6522): one line, the same
3 * actions the settings page and the MCP tool call, so the admin check and
4 * the hold's `by` are theirs.
5 *
6 * owner/name path hold new work on path
7 * release owner/name path let it go
8 * owner/name list the holds, who set them and when
9 */
10
11 import { holdFileAs, listFileHolds, releaseFileAs } from "../../actions/file_holds.ts";
12 import { getRepo } from "../../core/repos.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import type { Session } from "../session.ts";
15 import { promptScreen } from "./common.ts";
16
17 const SAYS = {
18 unauthorized: "Sign in first.",
19 not_found: "No such repository, or you do not administer it.",
20 invalid_path: "A path is relative, without .., under 300 characters.",
21 };
22
23 /** Run one line of the prompt; the sentence to show, as a toast or an error. */
24 export function fileHoldLine(f: Session, text: string): { ok: boolean; says: string } {
25 const words = text.trim().split(/\s+/).filter((w) => w !== "");
26 const release = words[0] === "release";
27 if (release) words.shift();
28 const [slug, path, ...extra] = words;
29 const [owner, name, ...rest] = (slug ?? "").split("/");
30 const repo = owner && name && rest.length === 0 ? getRepo(f.db, owner, name) : undefined;
31 if (repo === undefined || extra.length > 0 || (release && path === undefined))
32 return { ok: false, says: "Say: owner/name path, release owner/name path, or owner/name to list." };
33 if (path === undefined) {
34 const listed = listFileHolds(f.app, f.user, repo);
35 if (!listed.ok) return { ok: false, says: SAYS[listed.error] };
36 return {
37 ok: true,
38 says: listed.value.length === 0 ? "No file is held." : listed.value.map((h) => `${h.path} (${h.byName}, ${h.at.slice(0, 16)})`).join("; "),
39 };
40 }
41 if (release) {
42 const done = releaseFileAs(f.app, f.user, repo, path);
43 return done.ok ? { ok: true, says: done.value ? `${path} is free again.` : `${path} was not held.` } : { ok: false, says: SAYS[done.error] };
44 }
45 const held = holdFileAs(f.app, f.user, repo, path);
46 return held.ok ? { ok: true, says: `New work on ${held.value.path} is held.` } : { ok: false, says: SAYS[held.error] };
47 }
48
49 export function fileHoldsPrompt(f: Session, ctx: Ctx, parent: Screen): Screen {
50 return promptScreen(ctx, parent, {
51 title: "Held files",
52 label: "owner/name path · release owner/name path · owner/name",
53 hint: "no build starts on an issue that names a held path",
54 onSubmit: (text) => {
55 const done = fileHoldLine(f, text);
56 if (!done.ok) return done.says;
57 ctx.toast(done.says);
58 return undefined;
59 },
60 });
61 }
src/web/pages/file_holds.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * POST /:owner/:name/settings/holds — hold new work on a file, or release it.
3 * Through `adminForm`: an admin of the repository, same-origin, admin scope;
4 * the hold names the signed-in admin (`actions/file_holds.ts`).
5 */
6
7 import { holdFileAs, releaseFileAs } from "../../actions/file_holds.ts";
8 import type { Ctx } from "../../http/context.ts";
9 import { repoBase } from "../chrome.ts";
10 import { field } from "../form.ts";
11 import { flashTo } from "../layout.ts";
12 import { adminForm } from "./settings.ts";
13
14 export async function fileHoldUpdate(ctx: Ctx): Promise<Response> {
15 const gate = await adminForm(ctx);
16 if (gate instanceof Response) return gate;
17 const { repo, form } = gate;
18 const to = `${repoBase(repo)}/settings#file-holds`;
19 const path = field(form, "path");
20 if (field(form, "verb") === "release") {
21 const released = releaseFileAs(ctx.app, ctx.auth.user, repo, path);
22 return flashTo(
23 ctx,
24 to,
25 released.ok
26 ? { message: released.value ? `${path} is free again.` : `${path} was not held.` }
27 : { tone: "danger", message: "That path could not be released." },
28 );
29 }
30 const held = holdFileAs(ctx.app, ctx.auth.user, repo, path);
31 return flashTo(
32 ctx,
33 to,
34 held.ok
35 ? { message: `New work on ${held.value.path} is held.` }
36 : {
37 tone: "danger",
38 message:
39 held.error === "invalid_path"
40 ? "A held path is relative, without .. or empty parts, and under 300 characters."
41 : "That file could not be held.",
42 },
43 );
44 }
src/web/studio/crew.ts +2
@@ -39,6 +39,7 @@ import { icon } from "../icons.ts";
3939 import { crewReadiness } from "../pages/crew.ts";
4040 import { grantsSection } from "../pages/subscriptions.ts";
4141 import { directionMarked } from "./direction.ts";
42 import { fileHoldsCard } from "./file_holds.ts";
4243 import { markOf } from "../../core/marks.ts";
4344 import {
4445 btn,
@@ -401,6 +402,7 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html {
401402 ${aboutCard(repo, base, admin, isApp)}
402403 ${crewCard(ctx, repo, base, admin)}
403404 ${autonomyCard(ctx, repo, base, admin)}
405 ${admin ? fileHoldsCard(ctx, repo, base) : null}
404406 </div>`;
405407 }
406408
src/web/studio/file_holds.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * The hold-a-file card on a repository's settings, an admin's: each file
3 * held, who set it and when, what it keeps waiting, a form to hold another
4 * path and one to release. The forms post to `fileHoldUpdate`
5 * (`pages/file_holds.ts`), which is where the admin and same-origin checks are.
6 */
7
8 import { listFileHolds } from "../../actions/file_holds.ts";
9 import type { Repo } from "../../core/repos.ts";
10 import { heldNow } from "../../crew/held.ts";
11 import { MAX_HOLD_PATH } from "../../crew/file_holds.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { html, type Html } from "../html.ts";
14 import { icon } from "../icons.ts";
15 import { btn, card, field, row, time } from "../ui.ts";
16
17 /** Null unless the viewer is an admin of `repo`: the holds are the owner's book-keeping. */
18 export function fileHoldsCard(ctx: Ctx, repo: Repo, base: string): Html | null {
19 const listed = listFileHolds(ctx.app, ctx.auth.user, repo);
20 if (!listed.ok) return null;
21 const waiting = listed.value.length === 0 ? undefined : heldNow(ctx.app, repo).pinned;
22 const issuesOf = (path: string): string =>
23 [...(waiting ?? [])]
24 .filter(([, h]) => h.path === path)
25 .map(([n]) => `#${n}`)
26 .join(" ");
27 return card(
28 html`<p class="muted small">
29 No build starts on an issue that names a held path. Nothing already
30 running is cut.
31 </p>
32 ${listed.value.length === 0
33 ? html`<p class="muted" data-no-holds>No file is held.</p>`
34 : html`<ul class="list setting-list">
outdated

Hand-rolled list. Use row() rows in ul.list.setting-list (DESIGN §5, checklist 4 and 8): the path as the title, so a 300-character path wraps; who and when as the meta; the Release form in the trail.

35 ${listed.value.map((h) => {
36 const kept = issuesOf(h.path);
37 return row({
38 title: html`<span class="mono">${h.path}</span>`,
39 meta: html`held by <span data-by>${h.byName}</span> · ${time(h.at)}${kept === "" ? "" : html` · keeping ${kept} waiting`}`,
40 data: { "file-hold": h.path },
41 trail: html`<form method="post" action="${base}/settings/holds">
42 <input type="hidden" name="path" value="${h.path}" />
43 <input type="hidden" name="verb" value="release" />
44 ${btn("Release", { variant: "ghost" })}
45 </form>`,
46 });
47 })}
48 </ul>`}
49 <form method="post" action="${base}/settings/holds">
50 <input type="hidden" name="verb" value="hold" />
51 ${field({
52 label: "Hold new work on",
53 name: "path",
54 placeholder: "src/http/mcp.ts",
55 mono: true,
56 required: true,
57 spellcheck: false,
58 hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`,
59 })}
60 <div class="form-actions">${btn("Hold file", { variant: "primary" })}</div>
61 </form>`,
62 {
63 id: "file-holds",
64 head: html`${icon("alert")}
65 <h2>Held files</h2>`,
66 },
67 );
68 }
test/crew-file-holds.test.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * An admin's hold on one file (#6522): the picker starts no build whose issue
3 * names it, the hold says who set it, and it is reachable from the settings
4 * page, the MCP and the terminal — each refusing a non-admin.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { holdFileAs, listFileHolds, releaseFileAs } from "../src/actions/file_holds.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { heldFiles, holdFile, releaseFile } from "../src/crew/file_holds.ts";
18 import { heldNow } from "../src/crew/held.ts";
19 import { holdHot, hotSays, standingHot } from "../src/crew/hot_files.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { fileHoldLine } from "../src/tui/screens/fileholds.ts";
22 import { mcpTool } from "./helpers/mcp.ts";
23
24 describe("hold new work on a file", () => {
25 let dir: string;
26 let app: App;
27 let servers: Servers;
28 let alice: User;
29 let bob: User;
30 let repo: Repo;
31 let named: number;
32 let other: number;
33
34 const person = async (username: string, first = false): Promise<User> => {
35 const made = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username,
39 email: `${username}@forge.local`,
40 password: "a-good-passphrase",
41 adminIfFirst: first,
42 });
43 if (!made.ok) throw new Error(made.error);
44 return made.value;
45 };
46 const cookieOf = async (username: string): Promise<string> =>
47 (
48 (
49 await fetch(`${servers.url}/signin`, {
50 method: "POST",
51 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
52 redirect: "manual",
53 })
54 ).headers.get("set-cookie") ?? ""
55 ).split(";")[0]!;
56 const post = (cookie: string, body: Record<string, string>, origin = servers.url) =>
57 fetch(`${servers.url}/alice/held-file/settings/holds`, {
58 method: "POST",
59 headers: { cookie, origin },
60 body: new URLSearchParams(body),
61 redirect: "manual",
62 });
63 const waiting = () => heldNow(app, repo).by.hot_file.map((i) => i.number);
64
65 beforeAll(async () => {
66 dir = await mkdtemp(join(tmpdir(), "file-holds-"));
67 app = createApp({ dataDir: dir, port: 0 });
68 alice = await person("alice", true);
69 bob = await person("bob");
70 servers = await serve(app);
71 const made = await createRepo(app, alice, { name: "held-file", visibility: "public" });
72 if (!made.ok) throw new Error(String(made.error));
73 repo = made.value;
74 const a = await openIssue(app, alice, repo, { title: "Change src/http/mcp.ts", body: "" });
75 const b = await openIssue(app, alice, repo, { title: "Change src/other/thing.ts", body: "" });
76 if (!a.ok || !b.ok) throw new Error("issues");
77 named = a.value.number;
78 other = b.value.number;
79 });
80 afterAll(async () => {
81 servers.stop();
82 app.close();
83 await rm(dir, { recursive: true, force: true });
84 });
85
86 test("a held path holds an issue naming it, and releasing frees it", () => {
87 expect(waiting()).toEqual([]);
88 const held = holdFile(app.db, repo.id, "src/http/mcp.ts", alice.id);
89 expect(held.ok && held.value.by).toBe(alice.id);
90 expect(heldFiles(app.db, repo.id).map((h) => h.path)).toEqual(["src/http/mcp.ts"]);
91 expect(waiting()).toEqual([named]);
92 expect(heldNow(app, repo).startable.map((r) => r.issue.number)).toContain(other);
93 expect(releaseFile(app.db, repo.id, "src/http/mcp.ts")).toBe(true);
94 expect(waiting()).toEqual([]);
95 expect(releaseFile(app.db, repo.id, "src/http/mcp.ts")).toBe(false);
96 });
97
98 test("an invalid path is refused", () => {
99 for (const bad of ["", "/etc/passwd", "../x.ts", "a/../b.ts", "a//b.ts", "x".repeat(301), 5, "a\\b"])
100 expect(holdFile(app.db, "nope", bad, "u").ok).toBe(false);
101 expect(holdFileAs(app, alice, repo, "../x.ts")).toEqual({ ok: false, error: "invalid_path" });
102 });
103
104 test("the action refuses a non-admin and names the admin who holds", () => {
105 expect(holdFileAs(app, bob, repo, "src/http/mcp.ts")).toEqual({ ok: false, error: "not_found" });
106 expect(holdFileAs(app, undefined, repo, "src/http/mcp.ts")).toEqual({ ok: false, error: "unauthorized" });
107 expect(releaseFileAs(app, bob, repo, "src/http/mcp.ts").ok).toBe(false);
108 expect(listFileHolds(app, bob, repo).ok).toBe(false);
109 expect(heldFiles(app.db, repo.id)).toEqual([]);
110
111 const dry = holdFileAs(app, alice, repo, "src/http/mcp.ts", true);
112 expect(dry.ok).toBe(true);
113 expect(heldFiles(app.db, repo.id)).toEqual([]);
114
115 const held = holdFileAs(app, alice, repo, "src/http/mcp.ts");
116 expect(held.ok && held.value.byName).toBe("alice");
117 expect(waiting()).toEqual([named]);
118 const pin = heldNow(app, repo).pinned.get(named);
119 expect(pin?.path).toBe("src/http/mcp.ts");
120 expect(pin?.by).toBe(alice.id);
121 // The feed's sentence says who.
122 holdHot(repo.id, heldNow(app, repo).by.hot_file, new Date(), new Map([[named, { path: pin!.path, byName: "alice" }]]));
123 expect(hotSays(standingHot(repo.id)!.holds[0]!)).toBe(`#${named} waits: alice holds new work on src/http/mcp.ts.`);
124 holdHot(repo.id, []);
125 expect(releaseFileAs(app, alice, repo, "src/http/mcp.ts")).toEqual({ ok: true, value: true });
126 expect(waiting()).toEqual([]);
127 });
128
129 test("the settings page: only an admin sees the card and posts; the hold reaches the picker", async () => {
130 const adminCookie = await cookieOf("alice");
131 const bobCookie = await cookieOf("bob");
132 const seen = await (await fetch(`${servers.url}/alice/held-file/settings`, { headers: { cookie: adminCookie } })).text();
133 expect(seen).toContain('id="file-holds"');
134 const bobSees = await (await fetch(`${servers.url}/alice/held-file/settings`, { headers: { cookie: bobCookie } })).text();
135 expect(bobSees).not.toContain('id="file-holds"');
136
137 expect((await post(bobCookie, { verb: "hold", path: "src/http/mcp.ts" })).status).toBe(404);
138 expect((await post(adminCookie, { verb: "hold", path: "src/http/mcp.ts" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400);
139 expect(heldFiles(app.db, repo.id)).toEqual([]);
140
141 const done = await post(adminCookie, { verb: "hold", path: "src/http/mcp.ts" });
142 expect(done.status).toBe(303);
143 expect(heldFiles(app.db, repo.id)[0]?.by).toBe(alice.id);
144 expect(waiting()).toEqual([named]);
145 const after = await (await fetch(`${servers.url}/alice/held-file/settings`, { headers: { cookie: adminCookie } })).text();
146 expect(after).toContain('data-file-hold="src/http/mcp.ts"');
147 expect(after).toContain("alice");
148
149 await post(bobCookie, { verb: "release", path: "src/http/mcp.ts" });
150 expect(heldFiles(app.db, repo.id).length).toBe(1);
151 await post(adminCookie, { verb: "release", path: "src/http/mcp.ts" });
152 expect(waiting()).toEqual([]);
153 });
154
155 test("the MCP tool: dry by default, admin only", async () => {
156 const admin = createToken(app.db, alice.id, "t", "admin").token;
157 const reader = createToken(app.db, bob.id, "t", "admin").token;
158 const args = { app: "alice/held-file", path: "src/http/mcp.ts" };
159 expect((await mcpTool(servers.url, admin, "hold", args)).value.dryRun).toBe(true);
160 expect(heldFiles(app.db, repo.id)).toEqual([]);
161 expect((await mcpTool(servers.url, reader, "hold", { ...args, dryRun: false })).error).toBeDefined();
162 expect(heldFiles(app.db, repo.id)).toEqual([]);
163 const rw = createToken(app.db, alice.id, "t", "rw").token;
164 expect((await mcpTool(servers.url, rw, "hold", { ...args, dryRun: false })).error).toBeDefined();
165 expect(heldFiles(app.db, repo.id)).toEqual([]);
166 const held = await mcpTool(servers.url, admin, "hold", { ...args, dryRun: false });
167 expect(held.value.hold.by).toBe("alice");
168 expect(waiting()).toEqual([named]);
169 const listed = await mcpTool(servers.url, admin, "hold", { app: "alice/held-file" });
170 expect(listed.value.holds.map((h: { path: string }) => h.path)).toEqual(["src/http/mcp.ts"]);
171 expect((await mcpTool(servers.url, admin, "hold", { ...args, release: true, dryRun: false })).value.says).toContain("Released");
172 expect(waiting()).toEqual([]);
173 const forgot = await mcpTool(servers.url, admin, "hold", { app: "alice/held-file", release: true, dryRun: false });
174 expect(forgot.value?.holds).toBeUndefined();
175 expect(forgot.error).toBeDefined();
176 });
177
178 test("the terminal line holds, lists and releases, and refuses a non-admin", () => {
179 const as = (user: User) => ({ app, db: app.db, user }) as never;
180 expect(fileHoldLine(as(bob), "alice/held-file src/http/mcp.ts").ok).toBe(false);
181 expect(fileHoldLine(as(alice), "alice/held-file ../x").ok).toBe(false);
182 expect(fileHoldLine(as(alice), "alice/held-file src/http/mcp.ts").ok).toBe(true);
183 expect(waiting()).toEqual([named]);
184 expect(fileHoldLine(as(alice), "alice/held-file").says).toContain("src/http/mcp.ts (alice");
185 expect(fileHoldLine(as(alice), "release alice/held-file src/http/mcp.ts").ok).toBe(true);
186 expect(waiting()).toEqual([]);
187 });
188 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs