Public: anyone can read this; the crew can change it.
Repository-limit and agent-not-here refusals live in one module, read by migrate, repos, apps and the fleet #6608
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/5720-repository-limit-and-agent-not-here-refu → main 1 commit fork
8 files
changed +69
−7
src/actions/crew_limit_words.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * What a refusal says at the repositories limit, or when it names an agent | |
| 3 | * that is not here (#5720): one place, read by migrate, repos, the apps page, | |
| 4 | * the fleet, the terminal and the MCP, so every door says it the same way. | |
| 5 | */ | |
| 6 | ||
| 7 | /** The fallback when an account is at its repositories limit. */ | |
| 8 | export const AT_REPOSITORY_LIMIT = "at the repositories limit"; | |
| 9 | ||
| 10 | /** An agent named that has no account on this forge. */ | |
| 11 | export const NO_SUCH_AGENT = "That agent is not on this forge."; | |
| 12 | ||
| 13 | /** An agent named that is not on the repository's crew. */ | |
| 14 | export const AGENT_NOT_ON_CREW = "that agent is not on this repository's crew"; |
src/actions/migrate.ts +3 −2
| @@ -76,6 +76,7 @@ import { | ||
| 76 | 76 | import { tell, toldWords } from "./tell.ts"; |
| 77 | 77 | import { putFollow, upstreamUrl } from "../core/upstream_follow.ts"; |
| 78 | 78 | import { pinFor, remoteGitEnv, type Pin } from "../git/remote-env.ts"; |
| 79 | import { AT_REPOSITORY_LIMIT, NO_SUCH_AGENT } from "./crew_limit_words.ts"; | |
| 79 | 80 | |
| 80 | 81 | /** The whole clone — the HEAD probe and the fetch — gets this long. */ |
| 81 | 82 | export const MIGRATE_TIMEOUT_MS = 120_000; |
| @@ -255,7 +256,7 @@ export function migrationJobFor( | ||
| 255 | 256 | } |
| 256 | 257 | |
| 257 | 258 | /** A refusal in words a person can act on. */ |
| 258 | export function migrateMessage(error: MigrateError, limit = "at the repositories limit"): string { | |
| 259 | export function migrateMessage(error: MigrateError, limit = AT_REPOSITORY_LIMIT): string { | |
| 259 | 260 | switch (error) { |
| 260 | 261 | case "invalid_url": |
| 261 | 262 | return "That is not a URL this forge will clone from. Paste an https:// (or http://) address of a public repository, with no password in it. A git:// address is not supported: use its https:// address."; |
| @@ -266,7 +267,7 @@ export function migrateMessage(error: MigrateError, limit = "at the repositories | ||
| 266 | 267 | case "unreachable": |
| 267 | 268 | return "The origin could not be reached. Try again, or reconnect it under Settings → Origins."; |
| 268 | 269 | case "no_such_agent": |
| 269 | return "That agent is not on this forge."; | |
| 270 | return NO_SUCH_AGENT; | |
| 270 | 271 | case "invalid_branch": |
| 271 | 272 | return "That is not a branch name git would take."; |
| 272 | 273 | case "name_taken": |
src/actions/repos.ts +2 −1
| @@ -13,6 +13,7 @@ import { canSeeSealed } from "../core/sealed.ts"; | ||
| 13 | 13 | import { capacityValue } from "../core/capacity.ts"; |
| 14 | 14 | import { NEW_FOR_MS } from "./accounts.ts"; |
| 15 | 15 | import { repositoryLimitSentence } from "../settings/quotas.ts"; |
| 16 | import { AT_REPOSITORY_LIMIT } from "./crew_limit_words.ts"; | |
| 16 | 17 | import { storageChanged, storageRefusal, storageSentence } from "../core/storage.ts"; |
| 17 | 18 | import { |
| 18 | 19 | createLabel as insertLabel, |
| @@ -104,7 +105,7 @@ export function repositoryLimitMessage(app: App, user: User, ownerId = user.id): | ||
| 104 | 105 | return ( |
| 105 | 106 | repositoryLimitRefusal(app, user, ownerId) ?? |
| 106 | 107 | storageSentence(app, ownerId) ?? |
| 107 | "at the repositories limit" | |
| 108 | AT_REPOSITORY_LIMIT | |
| 108 | 109 | ); |
| 109 | 110 | } |
| 110 | 111 |
src/fleet/verbs.ts +2 −1
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 14 | import { AGENT_NOT_ON_CREW } from "../actions/crew_limit_words.ts"; | |
| 14 | 15 | import type { App } from "../app.ts"; |
| 15 | 16 | import { |
| 16 | 17 | addComment, |
| @@ -117,7 +118,7 @@ const WORDS: Record<string, string> = { | ||
| 117 | 118 | mirrored: "mirrored: change it at the origin", |
| 118 | 119 | unknown_label: "no such label here", |
| 119 | 120 | assignee_cannot_read: "the assignee cannot read this repository", |
| 120 | agent_not_on_crew: "that agent is not on this repository's crew", | |
| 121 | agent_not_on_crew: AGENT_NOT_ON_CREW, | |
| 121 | 122 | already_merged: "a merged pull stays closed", |
| 122 | 123 | finished: "already finished", |
| 123 | 124 | not_agent: "only an agent run is queued again", |
src/http/mcp/tools/assign.ts +2 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { getUserByName } from "../../../core/users.ts"; |
| 2 | 2 | import { ARCHIVED_WHY } from "../../../core/repos.ts"; |
| 3 | 3 | import { NOT_A_MEMBER } from "../../../core/roles.ts"; |
| 4 | import { AGENT_NOT_ON_CREW } from "../../../actions/crew_limit_words.ts"; | |
| 4 | 5 | import { assignIssue } from "../../../actions/issues.ts"; |
| 5 | 6 | import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts"; |
| 6 | 7 | |
| @@ -40,7 +41,7 @@ export const tool: Tool = { | ||
| 40 | 41 | if (e === "archived") return bad(e, ARCHIVED_WHY); |
| 41 | 42 | if (e === "not_member") return bad(e, NOT_A_MEMBER); |
| 42 | 43 | if (e === "assignee_cannot_read") return bad(e, "the assignee cannot read this repository"); |
| 43 | if (e === "agent_not_on_crew") return bad(e, "that agent is not on this repository's crew"); | |
| 44 | if (e === "agent_not_on_crew") return bad(e, AGENT_NOT_ON_CREW); | |
| 44 | 45 | if (user.kind === "agent" && e === "forbidden") |
| 45 | 46 | return bad(e, "an agent may not assign; that is a person's call"); |
| 46 | 47 | if (e === "forbidden" || e === "unauthorized") |
src/tui/screens/common.ts +2 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts"; |
| 11 | 11 | import { refusalText } from "../../http/refusal.ts"; |
| 12 | 12 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 13 | import { AGENT_NOT_ON_CREW } from "../../actions/crew_limit_words.ts"; | |
| 13 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 15 | import { binding, type Binding } from "../app/keys.ts"; |
| 15 | 16 | import type { KeyEvent } from "../term/input.ts"; |
| @@ -906,7 +907,7 @@ const MESSAGES: Record<string, string> = { | ||
| 906 | 907 | no_such_agent: "no such agent", |
| 907 | 908 | scaffold_failed: "the repository was made, but the app could not be started", |
| 908 | 909 | assignee_cannot_read: "they cannot see this repository", |
| 909 | agent_not_on_crew: "that agent is not on this repository's crew", | |
| 910 | agent_not_on_crew: AGENT_NOT_ON_CREW, | |
| 910 | 911 | invalid_model: "that model name is not valid", |
| 911 | 912 | brief_too_long: "that brief is too long (4000 characters at most)", |
| 912 | 913 | invalid_budget: "the budget must be between 0 and 100 dollars", |
src/web/pages/apps.ts +2 −1
| @@ -16,6 +16,7 @@ import { storageLimitMessage } from "../../core/storage.ts"; | ||
| 16 | 16 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 17 | 17 | import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../../actions/repo_words.ts"; |
| 18 | 18 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 19 | import { NO_SUCH_AGENT } from "../../actions/crew_limit_words.ts"; | |
| 19 | 20 | import { getUserByName } from "../../core/users.ts"; |
| 20 | 21 | import { |
| 21 | 22 | appView, |
| @@ -442,7 +443,7 @@ const NEW_APP_ERRORS: Record<string, string> = { | ||
| 442 | 443 | "Another app already answers to that hostname. Pick another name.", |
| 443 | 444 | already_app: "That repository is already an app.", |
| 444 | 445 | scaffold_failed: "The starter app could not be committed.", |
| 445 | no_such_agent: "That agent is not on this forge.", | |
| 446 | no_such_agent: NO_SUCH_AGENT, | |
| 446 | 447 | }; |
| 447 | 448 | |
| 448 | 449 | // POST /apps/new |
test/crew-limit-words.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * The repositories-limit and agent-not-here refusals live in one module | |
| 3 | * (#5720): migrate, repos, the apps page, the fleet, the terminal and the MCP | |
| 4 | * import them, and none types its own copy. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { | |
| 11 | AGENT_NOT_ON_CREW, | |
| 12 | AT_REPOSITORY_LIMIT, | |
| 13 | NO_SUCH_AGENT, | |
| 14 | } from "../src/actions/crew_limit_words.ts"; | |
| 15 | ||
| 16 | const root = join(import.meta.dir, ".."); | |
| 17 | const read = (file: string): string => readFileSync(join(root, file), "utf8"); | |
| 18 | ||
| 19 | const readers: Record<string, Record<string, string>> = { | |
| 20 | "src/actions/migrate.ts": { AT_REPOSITORY_LIMIT, NO_SUCH_AGENT }, | |
| 21 | "src/actions/repos.ts": { AT_REPOSITORY_LIMIT }, | |
| 22 | "src/web/pages/apps.ts": { NO_SUCH_AGENT }, | |
| 23 | "src/fleet/verbs.ts": { AGENT_NOT_ON_CREW }, | |
| 24 | "src/tui/screens/common.ts": { AGENT_NOT_ON_CREW }, | |
| 25 | "src/http/mcp/tools/assign.ts": { AGENT_NOT_ON_CREW }, | |
| 26 | }; | |
| 27 | ||
| 28 | test("the sentences read as they always have", () => { | |
| 29 | expect(AT_REPOSITORY_LIMIT).toBe("at the repositories limit"); | |
| 30 | expect(NO_SUCH_AGENT).toBe("That agent is not on this forge."); | |
| 31 | expect(AGENT_NOT_ON_CREW).toBe("that agent is not on this repository's crew"); | |
| 32 | }); | |
| 33 | ||
| 34 | for (const [file, names] of Object.entries(readers)) { | |
| 35 | for (const [name, sentence] of Object.entries(names)) { | |
| 36 | test(`${file} imports ${name} and types no copy of it`, () => { | |
| 37 | const text = read(file); | |
| 38 | expect(text).not.toContain(`"${sentence}"`); | |
| 39 | expect(text).toMatch(new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from "[./]*(actions/)?crew_limit_words\\.ts"`)); | |
| 40 | }); | |
| 41 | } | |
| 42 | } |
new above
offline