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

The forge family's refusals live in one module, and the API and MCP read them from there #5579

Merged opened this

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

Merged

mason/forge:mason/5562-the-forge-family-s-refusals-live-in-one → main 1 commit fork

  • Merged. mason/5562-the-forge-family-s-refusals-live-in-one was merged into main as 6130899.

Review

Sign in to review this change.
6 files changed +120 −14
src/actions/family_words.ts added +13
@@ -0,0 +1,13 @@
1 /**
2 * The forge family's refusals: what a repository outside the fork tree's
3 * rules is told, typed once so the API, MCP and the contract read the same
4 * sentence from one place.
5 */
6 export const FAMILY_WORDS = {
7 /** Proposing to the parent forge with no commit ahead of it. */
8 nothing_to_propose: "nothing here is ahead of the parent forge",
9 /** Taking, severing or changing the mode of a repository with no chain across forges. */
10 not_following: "this repository follows nothing across forges",
11 /** Showing or hiding a migration trail on a repository that was never migrated. */
12 no_provenance: "this repository was not migrated here",
13 } as const;
src/actions/follow.ts +2 −1
@@ -13,6 +13,7 @@ import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../c
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts";
16 import { FAMILY_WORDS } from "./family_words.ts";
1617 import { requireUser } from "./access.ts";
1718 import { takeFromParent, type Taken } from "../spawn/take.ts";
1819 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
@@ -102,7 +103,7 @@ export type FollowModeError = SeverError | "invalid_mode" | "severed";
102103 export const FOLLOW_MODE_REFUSALS: Record<FollowModeError, { status: number; message: string }> = {
103104 unauthorized: { status: 401, message: "sign in to change how this forge follows its parent" },
104105 forbidden: { status: 403, message: "only this forge's owner can change how it follows" },
105 not_following: { status: 404, message: "this repository follows nothing across forges" },
106 not_following: { status: 404, message: FAMILY_WORDS.not_following },
106107 invalid_mode: { status: 422, message: `mode is one of: ${FOLLOW_MODES.join(", ")}` },
107108 severed: { status: 409, message: "this forge is severed and follows nothing; there is no mode to change" },
108109 };
src/contract/openapi.ts +6 −5
@@ -8,6 +8,7 @@
88 */
99
1010 import { CAPACITY_KEYS } from "../core/capacity.ts";
11 import { FAMILY_WORDS } from "../actions/family_words.ts";
1112 import { LIMIT_KEYS } from "../settings/limits.ts";
1213
1314 interface Route {
@@ -589,7 +590,7 @@ export const ROUTES: readonly Route[] = [
589590 bad_decisions_by: "422: decisionsBy is me, decider or decider_tell",
590591 bad_plans_need_nod: "422: plansNeedNod is true or false",
591592 bad_show_provenance: "422: showProvenance is true or false",
592 no_provenance: "422: this repository was not migrated here",
593 no_provenance: `422: ${FAMILY_WORDS.no_provenance}`,
593594 forbidden: "403: only an admin archives or wakes",
594595 not_found: "404: no such repository, or it is not readable by the caller",
595596 },
@@ -645,7 +646,7 @@ export const ROUTES: readonly Route[] = [
645646 auth: true,
646647 refusals: {
647648 no_parent_forge: "404: this repository has no parent forge to propose to",
648 nothing_to_propose: "409: nothing here is ahead of the parent forge",
649 nothing_to_propose: `409: ${FAMILY_WORDS.nothing_to_propose}`,
649650 parent_unreachable: "502: the parent forge did not answer",
650651 refused: "409: the parent forge refused, with the reason",
651652 },
@@ -655,7 +656,7 @@ export const ROUTES: readonly Route[] = [
655656 path: `${REPO}/following/sever`,
656657 summary: "Stop following the parent forge; answers { severedAt }",
657658 auth: true,
658 refusals: { not_following: "404: this repository follows nothing across forges" },
659 refusals: { not_following: `404: ${FAMILY_WORDS.not_following}` },
659660 },
660661 {
661662 method: "post",
@@ -665,7 +666,7 @@ export const ROUTES: readonly Route[] = [
665666 auth: true,
666667 refusals: {
667668 not_a_fork: "404: this repository follows nothing",
668 not_following: "404: this repository follows nothing across forges",
669 not_following: `404: ${FAMILY_WORDS.not_following}`,
669670 conflict: "409: both sides changed these files; the crew was told; { files, converge, issue }",
670671 moved: "409: something moved while this was read; try again",
671672 },
@@ -677,7 +678,7 @@ export const ROUTES: readonly Route[] = [
677678 auth: true,
678679 body: { mode: "string" },
679680 refusals: {
680 not_following: "404: this repository follows nothing across forges",
681 not_following: `404: ${FAMILY_WORDS.not_following}`,
681682 forbidden: "403: only this forge's owner can change how it follows",
682683 invalid_mode: "422: mode is not one the forge knows",
683684 severed: "409: this forge is severed and follows nothing",
src/http/api.ts +5 −4
@@ -15,6 +15,7 @@ import {
1515 setFollowUpstream,
1616 } from "../actions/upstream_follow.ts";
1717 import { getFollow } from "../core/upstream_follow.ts";
18 import { FAMILY_WORDS } from "../actions/family_words.ts";
1819 import { showProvenance } from "../actions/provenance.ts";
1920 import { getProvenance, provenanceFacts } from "../core/provenance.ts";
2021 import { goldenPath } from "../apps/golden_path.ts";
@@ -2519,7 +2520,7 @@ export function apiRoutes(router: Router<Handler>): void {
25192520 if (ctx.auth.user?.kind === "agent" || !canAdmin(ctx.app.db, repo, ctx.auth.user))
25202521 return respondAccess("not_found", 422);
25212522 if (getProvenance(ctx.app.db, repo.id) === undefined)
2522 return apiError("no_provenance", 422, "this repository was not migrated here");
2523 return apiError("no_provenance", 422, FAMILY_WORDS.no_provenance);
25232524 }
25242525 const updated = await updateRepoSettings(ctx.app, ctx.auth.user, repo, {
25252526 ...(input.visibility === "public" || input.visibility === "private"
@@ -2703,7 +2704,7 @@ export function apiRoutes(router: Router<Handler>): void {
27032704 const took = await takeForgeCode(ctx.app, ctx.auth.user, repo);
27042705 if (!took.ok)
27052706 return took.error === "not_following"
2706 ? apiError("not_following", 404, "this repository follows nothing across forges")
2707 ? apiError("not_following", 404, FAMILY_WORDS.not_following)
27072708 : respondAccess(took.error, 403);
27082709 return json({ ...took.value, ...takeForgeWords(took.value) });
27092710 },
@@ -2720,7 +2721,7 @@ export function apiRoutes(router: Router<Handler>): void {
27202721 const cut = await severForge(ctx.app, ctx.auth.user, repo);
27212722 if (!cut.ok)
27222723 return cut.error === "not_following"
2723 ? apiError("not_following", 404, "this repository follows nothing across forges")
2724 ? apiError("not_following", 404, FAMILY_WORDS.not_following)
27242725 : respondAccess(cut.error, 403);
27252726 return json({ severedAt: cut.value });
27262727 },
@@ -2784,7 +2785,7 @@ export function apiRoutes(router: Router<Handler>): void {
27842785 if (e === "no_parent_forge")
27852786 return apiError("no_parent_forge", 404, "this repository has no parent forge to propose to");
27862787 if (e === "nothing_to_propose")
2787 return apiError("nothing_to_propose", 409, "nothing here is ahead of the parent forge");
2788 return apiError("nothing_to_propose", 409, FAMILY_WORDS.nothing_to_propose);
27882789 if (typeof e === "string") return respondAccess(e, 403);
27892790 return e.kind === "unreachable"
27902791 ? apiError("parent_unreachable", 502, "parent unreachable")
src/http/mcp.ts +5 −4
@@ -19,6 +19,7 @@
1919
2020 import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../actions/origins.ts";
2121 import { originJson, remoteRepoJson } from "./api.ts";
22 import { FAMILY_WORDS } from "../actions/family_words.ts";
2223 import { showProvenance } from "../actions/provenance.ts";
2324 import { migrateMessage, migrateRepo, migrationJobFor } from "../actions/migrate.ts";
2425 import type { MirrorJob } from "../origins/jobs.ts";
@@ -2022,7 +2023,7 @@ const TOOLS: readonly Tool[] = [
20222023 const set = showProvenance(ctx.app, user, repo, shown, dryRun);
20232024 if (!set.ok)
20242025 return set.error === "no_provenance"
2025 ? bad("no_provenance", "this repository was not migrated here")
2026 ? bad("no_provenance", FAMILY_WORDS.no_provenance)
20262027 : bad("forbidden", "only an admin of this repository shows or hides its trail");
20272028 return good({
20282029 ...set.value,
@@ -2550,7 +2551,7 @@ const TOOLS: readonly Tool[] = [
25502551 const across = await takeForgeCode(ctx.app, user, repo);
25512552 if (across.ok) return good({ ...across.value, ...takeForgeWords(across.value) });
25522553 return across.error === "not_following"
2553 ? bad("not_following", "this repository follows nothing across forges")
2554 ? bad("not_following", FAMILY_WORDS.not_following)
25542555 : bad(across.error, "only this repository's admin can take the parent forge's code");
25552556 }
25562557 const took = await takeOrTell(ctx.app, user, repo);
@@ -2601,7 +2602,7 @@ const TOOLS: readonly Tool[] = [
26012602 if (cut.ok) return good({ says: `Severed: following nothing since ${cut.value.slice(0, 10)}.`, severedAt: cut.value });
26022603 return bad(
26032604 cut.error,
2604 cut.error === "not_following" ? "this repository follows nothing across forges" : "only this forge's owner can sever it",
2605 cut.error === "not_following" ? FAMILY_WORDS.not_following : "only this forge's owner can sever it",
26052606 );
26062607 },
26072608 },
@@ -2693,7 +2694,7 @@ const TOOLS: readonly Tool[] = [
26932694 });
26942695 const e = made.error;
26952696 if (e === "no_parent_forge") return bad(e, "this forge has no parent forge to propose to");
2696 if (e === "nothing_to_propose") return bad(e, "nothing here is ahead of the parent forge");
2697 if (e === "nothing_to_propose") return bad(e, FAMILY_WORDS.nothing_to_propose);
26972698 if (typeof e === "string") return bad(e, "you need write access to propose from here");
26982699 return e.kind === "unreachable"
26992700 ? bad("parent_unreachable", "parent unreachable: nothing was proposed")
test/family-words.test.ts added +89
@@ -0,0 +1,89 @@
1 /**
2 * The forge family's refusals (#5562): the API and MCP answer the sentence
3 * src/actions/family_words.ts holds, and no other file under src/ types it.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { FAMILY_WORDS } from "../src/actions/family_words.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/core/repos.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { mcpTool } from "./helpers/mcp.ts";
17
18 quietLogs();
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let token = "";
24
25 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
26 if (!r.ok) throw new Error(JSON.stringify(r.error));
27 return r.value;
28 };
29
30 const api = (method: string, path: string, body?: unknown) =>
31 fetch(`${servers.url}/api/v1${path}`, {
32 method,
33 headers: {
34 authorization: `Bearer ${token}`,
35 "content-type": "application/json",
36 origin: servers.url,
37 },
38 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
39 });
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-family-words-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 const ada = ok(
46 await createUser(app.db, {
47 origin: "seed",
48 trusted: true,
49 username: "ada",
50 email: "[email protected]",
51 password: "a-good-passphrase",
52 adminIfFirst: true,
53 }),
54 );
55 ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "lib", visibility: "public" }));
56 token = createToken(app.db, ada.id, "t", "admin").token;
57 }, 30_000);
58
59 afterAll(async () => {
60 servers?.stop();
61 app?.close();
62 await rm(dataDir, { recursive: true, force: true });
63 });
64
65 test("severing a repository that follows nothing answers the module's sentence at both doors", async () => {
66 const res = await api("POST", "/repos/ada/lib/following/sever");
67 expect(res.status).toBe(404);
68 expect(await res.json()).toEqual({ error: "not_following", message: FAMILY_WORDS.not_following });
69 const mcp = await mcpTool(servers.url, token, "sever", { repo: "ada/lib" });
70 expect(mcp.error).toBe(`not_following: ${FAMILY_WORDS.not_following}`);
71 });
72
73 test("showing a trail on a repository never migrated answers the module's sentence at both doors", async () => {
74 const res = await api("PATCH", "/repos/ada/lib", { showProvenance: true });
75 expect(res.status).toBe(422);
76 expect(await res.json()).toEqual({ error: "no_provenance", message: FAMILY_WORDS.no_provenance });
77 const mcp = await mcpTool(servers.url, token, "trail", { repo: "ada/lib", shown: true, dryRun: false });
78 expect(mcp.error).toBe(`no_provenance: ${FAMILY_WORDS.no_provenance}`);
79 });
80
81 test("no file under src/ but the module types a family sentence", async () => {
82 const root = join(import.meta.dir, "..", "src");
83 const files = (await readdir(root, { recursive: true })).filter((f) => f.endsWith(".ts"));
84 for (const sentence of Object.values(FAMILY_WORDS)) {
85 const typed: string[] = [];
86 for (const f of files) if ((await readFile(join(root, f), "utf8")).includes(sentence)) typed.push(f);
87 expect(typed).toEqual([join("actions", "family_words.ts")]);
88 }
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs