Public: anyone can read this; the crew can change it.
The owner changes how their forge follows its parent from the chain page, the API and MCP #2638
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2632-the-owner-changes-how-their-forge-follow → main 2 commits fork 4 threads · 4 unresolved
README.md +7
| @@ -187,6 +187,13 @@ the default branch is pushed to `child/<name>/<branch>-<tip>` at the parent, | ||
| 187 | 187 | and the pull opens there, titled by the change that landed last and naming it |
| 188 | 188 | (`org/forge#7`). Asking again for the same tip returns the pull already made. |
| 189 | 189 | |
| 190 | The repository's admin changes how the forge follows its parent (`tell-me` or | |
| 191 | `take-updates`) from the radio form on the same page, with `PATCH | |
| 192 | /api/v1/forge/following {"mode"}`, with the MCP `follow_mode` tool, or by pressing `m` on the chain screen in the terminal. The | |
| 193 | record in `parent.json` is rewritten atomically and a `forge.follow_mode` event | |
| 194 | is appended. A non-admin is refused (403), a value outside the two modes is | |
| 195 | 422, and a severed forge is 409; each leaves `parent.json` untouched. | |
| 196 | ||
| 190 | 197 | The page and `GET …/following/proposals` show each proposal as open, merged or |
| 191 | 198 | closed, read from the parent (the token may read back only pulls it opened) and |
| 192 | 199 | cached for a minute (only a signed-in viewer makes the parent get asked); a merged or closed one is final. A parent that does not |
src/actions/follow.ts +35 −1
| @@ -12,7 +12,7 @@ import { convergeFork, type Converge } from "../crew/converge.ts"; | ||
| 12 | 12 | import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts"; |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | import { chainOfRepo, severChain } from "../spawn/chain.ts"; | |
| 15 | import { chainOfRepo, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; | |
| 16 | 16 | import { requireUser } from "./access.ts"; |
| 17 | 17 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| 18 | 18 | |
| @@ -62,6 +62,40 @@ export async function severForge( | ||
| 62 | 62 | return ok(cut.value); |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | export type FollowModeError = SeverError | "invalid_mode" | "severed"; | |
| 66 | ||
| 67 | /** The one answer every door gives a refused mode change, with the status an HTTP door sends. */ | |
| 68 | export const FOLLOW_MODE_REFUSALS: Record<FollowModeError, { status: number; message: string }> = { | |
| 69 | unauthorized: { status: 401, message: "sign in to change how this forge follows its parent" }, | |
| 70 | forbidden: { status: 403, message: "only this forge's owner can change how it follows" }, | |
| 71 | not_following: { status: 404, message: "this repository follows nothing across forges" }, | |
| 72 | invalid_mode: { status: 422, message: `mode is one of: ${FOLLOW_MODES.join(", ")}` }, | |
| 73 | severed: { status: 409, message: "this forge is severed and follows nothing; there is no mode to change" }, | |
| 74 | }; | |
| 75 | ||
| 76 | /** Choose how a forge follows its parent: the repository's admin only; the record changes atomically. */ | |
| 77 | export async function setFollowMode( | |
| 78 | app: App, | |
| 79 | actor: User | undefined, | |
| 80 | repo: Repo, | |
| 81 | mode: unknown, | |
| 82 | ): Promise<Result<FollowMode, FollowModeError>> { | |
| 83 | const who = requireUser(actor); | |
| 84 | if (!who.ok) return err("unauthorized"); | |
| 85 | if ((await chainOfRepo(app, repo)) === undefined) return err("not_following"); | |
| 86 | if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden"); | |
| 87 | if (!isFollowMode(mode)) return err("invalid_mode"); | |
| 88 | const set = await setChainMode(app, mode); | |
| 89 | if (!set.ok) return err(set.error); | |
| 90 | app.bus.append({ | |
| 91 | kind: "forge.follow_mode", | |
| 92 | repoId: repo.id, | |
| 93 | actorId: who.value.id, | |
| 94 | payload: { repo: `${repo.ownerName}/${repo.name}`, mode }, | |
| 95 | }); | |
| 96 | return ok(mode); | |
| 97 | } | |
| 98 | ||
| 65 | 99 | /** What the crew did about a conflict, in a sentence; `danger` when nothing is in hand. */ |
| 66 | 100 | export function convergeWords( |
| 67 | 101 | outcome: Converge, |
src/http/api.ts +17 −2
| @@ -62,9 +62,9 @@ import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; | ||
| 62 | 62 | import { reissueDoor, spawnDoor } from "../spawn/door.ts"; |
| 63 | 63 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 64 | 64 | import { forgeProposals, proposeToParentForge, proposeUpstream } from "../actions/upstream.ts"; |
| 65 | import { severForge, takeOrTell } from "../actions/follow.ts"; | |
| 65 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeOrTell } from "../actions/follow.ts"; | |
| 66 | 66 | import { relationOf } from "../actions/forks.ts"; |
| 67 | import { chainOfRepo } from "../spawn/chain.ts"; | |
| 67 | import { chainOfRepo, forgeLinkOf } from "../spawn/chain.ts"; | |
| 68 | 68 | import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts"; |
| 69 | 69 | import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts"; |
| 70 | 70 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| @@ -2355,6 +2355,21 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2355 | 2355 | }, |
| 2356 | 2356 | ); |
| 2357 | 2357 | |
| 2358 | // How this forge follows the forge its code came from: its own code | |
| 2359 | // repository's admin only, the same words as the page and MCP. | |
| 2360 | router.add("PATCH", "/api/v1/forge/following", async (ctx) => { | |
| 2361 | const denied = requireScope(ctx, "rw"); | |
| 2362 | if (denied) return denied; | |
| 2363 | const link = await forgeLinkOf(ctx.app); | |
| 2364 | const [owner, name] = (link?.repo ?? "/").split("/") as [string, string]; | |
| 2365 | const repo = link === undefined ? undefined : getRepo(ctx.app.db, owner, name); | |
| 2366 | if (repo === undefined) return apiError("not_following", 404, FOLLOW_MODE_REFUSALS.not_following.message); | |
| 2367 | const set = await setFollowMode(ctx.app, ctx.auth.user, repo, (await body(ctx.request)).mode); | |
| 2368 | if (set.ok) return json({ mode: set.value }); | |
| 2369 | const r = FOLLOW_MODE_REFUSALS[set.error]; | |
| 2370 | return apiError(set.error, r.status, r.message); | |
| 2371 | }); | |
| 2372 | ||
| 2358 | 2373 | router.add( |
| 2359 | 2374 | "POST", |
| 2360 | 2375 | "/api/v1/repos/:owner/:name/following/propose", |
src/http/mcp.ts +23 −2
| @@ -32,9 +32,9 @@ import { changeSignup, readSignup } from "../actions/signup.ts"; | ||
| 32 | 32 | import { SIGNUP_MODES } from "../settings/signup.ts"; |
| 33 | 33 | import { QUOTA_KEYS } from "../settings/quotas.ts"; |
| 34 | 34 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 35 | import { convergeWords, severForge, takeOrTell } from "../actions/follow.ts"; | |
| 35 | import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeOrTell } from "../actions/follow.ts"; | |
| 36 | 36 | import { relationOf } from "../actions/forks.ts"; |
| 37 | import { chainOfRepo } from "../spawn/chain.ts"; | |
| 37 | import { chainOfRepo, FOLLOW_MODES, forgeLinkOf } from "../spawn/chain.ts"; | |
| 38 | 38 | import { watchMilestone } from "../actions/watches.ts"; |
| 39 | 39 | import { |
| 40 | 40 | addCommentThrough, |
| @@ -1594,6 +1594,27 @@ const TOOLS: readonly Tool[] = [ | ||
| 1594 | 1594 | ); |
| 1595 | 1595 | }, |
| 1596 | 1596 | }, |
| 1597 | { | |
| 1598 | name: "follow_mode", | |
| 1599 | description: | |
| 1600 | "Choose how a spawned forge follows the forge its code came from: `tell-me` or `take-updates` (its `forge` repository's admin only). Refused on a severed forge. The same words as the radio form on the following page.", | |
| 1601 | inputSchema: { | |
| 1602 | type: "object", | |
| 1603 | properties: { | |
| 1604 | repo: REPO_ARG, | |
| 1605 | mode: { type: "string", enum: [...FOLLOW_MODES], description: "How this forge follows its parent." }, | |
| 1606 | }, | |
| 1607 | required: ["repo", "mode"], | |
| 1608 | }, | |
| 1609 | writes: true, | |
| 1610 | async run(ctx, user, args) { | |
| 1611 | const repo = repoArg(ctx, user, args); | |
| 1612 | if (isAnswer(repo)) return repo; | |
| 1613 | const set = await setFollowMode(ctx.app, user, repo, args["mode"]); | |
| 1614 | if (set.ok) return good({ says: `Now following in ${set.value} mode.`, mode: set.value }); | |
| 1615 | return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message); | |
| 1616 | }, | |
| 1617 | }, | |
| 1597 | 1618 | { |
| 1598 | 1619 | name: "propose_to_parent_forge", |
| 1599 | 1620 | description: |
src/http/server.ts +2 −1
| @@ -128,7 +128,7 @@ import { | ||
| 128 | 128 | signinPasskey, |
| 129 | 129 | } from "../web/pages/passkeys.ts"; |
| 130 | 130 | import { confirmPage, searchPage } from "../web/pages/search.ts"; |
| 131 | import { chainPage, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts"; | |
| 131 | import { chainPage, modeSubmit, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts"; | |
| 132 | 132 | import { followingSubmit } from "../web/pages/following.ts"; |
| 133 | 133 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 134 | 134 | import { activityAtom } from "../web/feeds.ts"; |
| @@ -1272,6 +1272,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1272 | 1272 | router.add("POST", "/:owner/:name/following", followingSubmit); |
| 1273 | 1273 | router.add("GET", "/:owner/:name/following", chainPage); |
| 1274 | 1274 | router.add("POST", "/:owner/:name/following/sever", severSubmit); |
| 1275 | router.add("POST", "/:owner/:name/following/mode", modeSubmit); | |
| 1275 | 1276 | router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit); |
| 1276 | 1277 | // A mirror pulls on demand; a pin says "keep this on my machine". Both are |
| 1277 | 1278 | // ordinary repo-scoped writes, not account-level ones. |
src/spawn/chain.ts +20 −6
| @@ -197,15 +197,29 @@ export async function severChain(app: App, now = new Date()): Promise<Result<str | ||
| 197 | 197 | if (doc === undefined || link === undefined) return err("not_following"); |
| 198 | 198 | if (link.severedAt !== undefined) return ok(link.severedAt); |
| 199 | 199 | const at = now.toISOString(); |
| 200 | const path = join(app.config.dataDir, "parent.json"); | |
| 201 | await Bun.write( | |
| 202 | `${path}.tmp`, | |
| 203 | JSON.stringify({ ...doc, forgeCode: { ...(doc["forgeCode"] as object), severedAt: at } }), | |
| 204 | ); | |
| 205 | await rename(`${path}.tmp`, path); | |
| 200 | await writeLink(app, doc, { severedAt: at }); | |
| 206 | 201 | return ok(at); |
| 207 | 202 | } |
| 208 | 203 | |
| 204 | /** Rewrite `forgeCode` with `patch` laid over it, atomically: the file is whole before and after. */ | |
| 205 | async function writeLink(app: App, doc: Record<string, unknown>, patch: object): Promise<void> { | |
| 206 | const path = join(app.config.dataDir, "parent.json"); | |
| 207 | await Bun.write(`${path}.tmp`, JSON.stringify({ ...doc, forgeCode: { ...(doc["forgeCode"] as object), ...patch } })); | |
| 208 | await rename(`${path}.tmp`, path); | |
| 209 | } | |
| 210 | ||
| 211 | /** Record how this forge follows its parent from now on; a severed forge follows nothing, so it refuses. */ | |
| 212 | export async function setChainMode(app: App, mode: FollowMode): Promise<Result<FollowMode, "not_following" | "severed">> { | |
| 213 | const doc = await readDoc(app); | |
| 214 | const link = await forgeLinkOf(app); | |
| 215 | if (doc === undefined || link === undefined) return err("not_following"); | |
| 216 | if (link.severedAt !== undefined) return err("severed"); | |
| 217 | await writeLink(app, doc, { mode }); | |
Low: |
||
| 218 | return ok(mode); | |
| 219 | } | |
| 220 | ||
| 221 | export const isFollowMode = isMode; | |
Nit: |
||
| 222 | ||
| 209 | 223 | /** The chain, when `repo` is this forge's own code repository; undefined for any other. */ |
| 210 | 224 | export async function chainOfRepo(app: App, repo: { ownerName: string; name: string }): Promise<ChainView | undefined> { |
| 211 | 225 | const link = await forgeLinkOf(app); |
src/tui/screens/chain.ts +14 −5
| @@ -1,16 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The chain of forges this forge's code descends from, in a card. The gate is |
| 3 | 3 | * the page's: only someone who can read `<org>/forge` sees it, and only that |
| 4 | * repository's owner is offered Sever, which asks for the same typed word. | |
| 4 | * repository's owner is offered Sever, which asks for the same typed word, | |
| 5 | * and `m`, which flips the follow mode. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { App as Forge } from "../../app.ts"; |
| 8 | import { severForge } from "../../actions/follow.ts"; | |
| 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts"; | |
| 9 | 10 | import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts"; |
| 10 | 11 | import { parentCredential } from "../../spawn/parent_credential.ts"; |
| 11 | 12 | import { canAdmin, canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts"; |
| 12 | 13 | import type { User } from "../../core/users.ts"; |
| 13 | import { chainLines, chainOfRepo, forgeLinkOf, type ChainView } from "../../spawn/chain.ts"; | |
| 14 | import { chainLines, chainOfRepo, forgeLinkOf, type ChainView, type FollowMode } from "../../spawn/chain.ts"; | |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| 16 | 17 | import { card } from "../ui/widgets.ts"; |
| @@ -39,7 +40,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 39 | 40 | const writer = user !== undefined && view.severedAt === null && canWrite(app.db, repo, user); |
| 40 | 41 | const proposing = view.severedAt === null && (await parentCredential(app)) !== undefined; |
| 41 | 42 | const linesOf = async (): Promise<[string, string][]> => [ |
| 42 | ...chainLines(view), | |
| 43 | ...chainLines((await chainOfRepo(app, repo)) ?? view), | |
| 43 | 44 | ...(proposing |
| 44 | 45 | ? (await forgeProposals(app, repo, Date.now(), user !== undefined)).map( |
| 45 | 46 | (p): [string, string] => ["proposal", forgeProposalLine(p)], |
| @@ -52,7 +53,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 52 | 53 | title: "Chain", |
| 53 | 54 | keys: [ |
| 54 | 55 | ...(writer && proposing ? [binding(["p"], "propose")] : []), |
| 55 | ...(owner ? [binding(["s"], "sever")] : []), | |
| 56 | ...(owner ? [binding(["m"], "follow mode"), binding(["s"], "sever")] : []), | |
| 56 | 57 | binding(["esc"], "close"), |
| 57 | 58 | ], |
| 58 | 59 | render(area, s) { |
| @@ -83,6 +84,14 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 83 | 84 | rows = await linesOf(); |
| 84 | 85 | ctx.toast(`Proposed to the parent forge as #${made.value.number}`, "success"); |
| 85 | 86 | }); |
| 87 | else if (e.key === "m" && owner) | |
| 88 | void forgeLinkOf(app).then(async (link) => { | |
| 89 | const next: FollowMode = link?.mode === "take-updates" ? "tell-me" : "take-updates"; | |
| 90 | const set = await setFollowMode(app, user, repo, next); | |
| 91 | if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger"); | |
| 92 | rows = await linesOf(); | |
| 93 | ctx.toast(`Now following in ${set.value} mode.`, "success"); | |
| 94 | }); | |
| 86 | 95 | else if (e.key === "s" && owner) |
| 87 | 96 | ctx.push( |
| 88 | 97 | promptScreen(ctx, self, { |
src/web/pages/chain.ts +40 −3
| @@ -5,11 +5,11 @@ | ||
| 5 | 5 | * sever. A severed forge says so with the date and nothing is deleted. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { severForge } from "../../actions/follow.ts"; | |
| 8 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts"; | |
| 9 | 9 | import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts"; |
| 10 | 10 | import { canAdmin, canWrite } from "../../core/repos.ts"; |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 12 | import { chainView, forgeLinkOf, type ChainLine } from "../../spawn/chain.ts"; | |
| 12 | import { chainView, FOLLOW_MODES, forgeLinkOf, type ChainLine, type FollowMode } from "../../spawn/chain.ts"; | |
| 13 | 13 | import { parentCredential } from "../../spawn/parent_credential.ts"; |
| 14 | 14 | import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; |
| 15 | 15 | import { field, readForm } from "../form.ts"; |
| @@ -18,7 +18,30 @@ import { flashOrJson, page, redirect } from "../layout.ts"; | ||
| 18 | 18 | import { btn, card } from "../ui.ts"; |
| 19 | 19 | |
| 20 | 20 | /** The events that change what the chain page says. */ |
| 21 | export const CHAIN_LIVE = "forge.severed,forge.proposal"; | |
| 21 | export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode"; | |
| 22 | ||
| 23 | const MODE_LABELS: Record<FollowMode, string> = { | |
| 24 | "tell-me": "Tell me when the parent has something new", | |
| 25 | "take-updates": "Take the parent's updates", | |
| 26 | }; | |
| 27 | ||
| 28 | function modeCard(base: string, current: FollowMode | null): Html { | |
The page gets the mode picker here, but the terminal's chain screen ( |
||
| 29 | return card( | |
| 30 | html`<form method="post" action="${base}/following/mode"> | |
| 31 | <fieldset class="form-field"> | |
| 32 | <legend>How this forge follows its parent</legend> | |
| 33 | ${FOLLOW_MODES.map( | |
| 34 | (m) => html`<label class="row" | |
| 35 | ><input type="radio" name="mode" value="${m}" ${m === current ? "checked" : null} /> | |
| 36 | ${MODE_LABELS[m]}</label | |
| 37 | >`, | |
| 38 | )} | |
| 39 | </fieldset> | |
| 40 | <div class="form-actions">${btn("Save", { type: "submit" })}</div> | |
| 41 | </form>`, | |
| 42 | { head: html`<h2>Follow mode</h2>` }, | |
| 43 | ); | |
| 44 | } | |
| 22 | 45 | |
| 23 | 46 | const MODE_WORDS = { "tell-me": "recorded: tell me (nothing tells it yet)", "take-updates": "recorded: take updates (nothing acts on it yet)" } as const; |
| 24 | 47 | |
| @@ -111,6 +134,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 111 | 134 | </ul>`, |
| 112 | 135 | { pad: false }, |
| 113 | 136 | )} |
| 137 | ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null} | |
| 114 | 138 | ${proposing ? proposalCard(base, proposals, writer) : null} |
| 115 | 139 | ${view.severedAt !== null |
| 116 | 140 | ? html`<p class="muted" id="severed"> |
| @@ -158,6 +182,19 @@ export async function severSubmit(ctx: Ctx): Promise<Response> { | ||
| 158 | 182 | return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` }); |
| 159 | 183 | } |
| 160 | 184 | |
| 185 | /** POST /:owner/:name/following/mode {mode}; the owner only. */ | |
| 186 | export async function modeSubmit(ctx: Ctx): Promise<Response> { | |
| 187 | const repo = loadRepo(ctx); | |
| 188 | if (!repo) return notFoundPage(ctx); | |
| 189 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 190 | const form = await readForm(ctx, "rw"); | |
| 191 | if (form instanceof Response) return form; | |
| 192 | const to = `${repoBase(repo)}/following`; | |
| 193 | const set = await setFollowMode(ctx.app, ctx.auth.user, repo, field(form, "mode")); | |
| 194 | if (!set.ok) return flashOrJson(ctx, to, { tone: "danger", message: FOLLOW_MODE_REFUSALS[set.error].message }); | |
| 195 | return flashOrJson(ctx, to, { message: `Now following in ${set.value} mode.` }); | |
| 196 | } | |
| 197 | ||
| 161 | 198 | /** POST /:owner/:name/following/propose: push to the parent forge and open the pull there; a writer only. */ |
| 162 | 199 | export async function proposeParentSubmit(ctx: Ctx): Promise<Response> { |
| 163 | 200 | const repo = loadRepo(ctx); |
test/following.test.ts +1 −1
| @@ -1092,7 +1092,7 @@ describe("an open chain page hears a sever", () => { | ||
| 1092 | 1092 | test("the page carries a live region for forge.severed, scoped to its repository", async () => { |
| 1093 | 1093 | const page = await chainPage(); |
| 1094 | 1094 | expect(page).toContain('id="live-chain"'); |
| 1095 | expect(page).toContain('data-live="forge.severed,forge.proposal"'); | |
| 1095 | expect(page).toContain('data-live="forge.severed,forge.proposal,forge.follow_mode"'); | |
| 1096 | 1096 | expect(page).toContain('data-live-repo="kid/forge"'); |
| 1097 | 1097 | expect(page).not.toContain("follows nothing since"); |
| 1098 | 1098 | }); |
test/mcp.test.ts +1
| @@ -199,6 +199,7 @@ describe("the forge over MCP", () => { | ||
| 199 | 199 | "claim_link", |
| 200 | 200 | "take_updates", |
| 201 | 201 | "sever", |
| 202 | "follow_mode", | |
| 202 | 203 | "propose_to_parent_forge", |
| 203 | 204 | "forge_proposals", |
| 204 | 205 | "converge", |
test/spawn-chain-doors.test.ts +23
| @@ -11,6 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 12 | 12 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | import { listEvents } from "../src/core/events.ts"; | |
| 14 | 15 | import { getRepo, updateRepoSettings } from "../src/core/repos.ts"; |
| 15 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | 17 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| @@ -168,6 +169,28 @@ describe("the chain at the MCP and terminal doors", () => { | ||
| 168 | 169 | expect((await forgeLinkOf(child))?.severedAt).toBeUndefined(); |
| 169 | 170 | }); |
| 170 | 171 | |
| 172 | test("the terminal's owner flips the follow mode with m; a reader has no such key", async () => { | |
| 173 | const was = (await forgeLinkOf(child))!.mode; | |
| 174 | const next = was === "tell-me" ? "take-updates" : "tell-me"; | |
| 175 | const events = () => listEvents(child.db, { kind: "forge.follow_mode" }).length; | |
| 176 | const before = events(); | |
| 177 | const guest = boot(reader); | |
| 178 | await guest.openChain(); | |
| 179 | expect(guest.text()).not.toContain("follow mode"); | |
| 180 | await guest.press("m"); | |
| 181 | expect((await forgeLinkOf(child))?.mode).toBe(was); | |
| 182 | expect(events()).toBe(before); | |
| 183 | guest.app.stop(); | |
| 184 | const owner = boot(alice); | |
| 185 | await owner.openChain(); | |
| 186 | expect(owner.text()).toContain("follow mode"); | |
| 187 | await owner.press("m"); | |
| 188 | await settleUntil(owner.app, `Now following in ${next} mode`); | |
| 189 | expect((await forgeLinkOf(child))?.mode).toBe(next); | |
| 190 | expect(events()).toBe(before + 1); | |
| 191 | owner.app.stop(); | |
| 192 | }); | |
| 193 | ||
| 171 | 194 | test("the terminal's owner types the word to sever; a wrong word does nothing", async () => { |
| 172 | 195 | const owner = boot(alice); |
| 173 | 196 | await owner.openChain(); |
test/spawn-chain-mode.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * The owner changes how their forge follows its parent (#2632): one action, | |
| 3 | * three doors (page, API, MCP) wording the same answer. A refusal leaves | |
| 4 | * parent.json byte for byte as it was. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { listEvents } from "../src/core/events.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 18 | import { repoDir } from "../src/git/repos.ts"; | |
| 19 | import { commitFiles } from "../src/git/write.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { forgeLinkOf, severChain } from "../src/spawn/chain.ts"; | |
| 22 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | describe("choosing how a forge follows its parent", () => { | |
| 28 | let dir = ""; | |
| 29 | let root: App; | |
| 30 | let child: App; | |
| 31 | let servers: Servers; | |
| 32 | let cookie = ""; | |
| 33 | let ownerTok = ""; | |
| 34 | let readerTok = ""; | |
| 35 | let readerCookie = ""; | |
| 36 | let id = 1; | |
| 37 | const file = () => join(child.config.dataDir, "parent.json"); | |
| 38 | const raw = () => Bun.file(file()).text(); | |
| 39 | const patch = (tok: string, body: unknown) => | |
| 40 | fetch(`${servers.url}/api/v1/forge/following`, { | |
| 41 | method: "PATCH", | |
| 42 | headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, | |
| 43 | body: JSON.stringify(body), | |
| 44 | }); | |
| 45 | const tool = async (tok: string, args: unknown) => { | |
| 46 | const res = await fetch(`${servers.url}/mcp`, { | |
| 47 | method: "POST", | |
| 48 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok}` }, | |
| 49 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "follow_mode", arguments: args } }), | |
| 50 | }); | |
| 51 | const j = (await res.json()) as any; | |
| 52 | return { error: j.result.isError === true, text: String(j.result.content[0].text) }; | |
| 53 | }; | |
| 54 | const form = (ck: string, mode: string) => | |
| 55 | fetch(`${servers.url}/kid/forge/following/mode`, { | |
| 56 | method: "POST", | |
| 57 | headers: { cookie: ck, origin: servers.url }, | |
| 58 | body: new URLSearchParams({ mode }), | |
| 59 | redirect: "manual", | |
| 60 | }); | |
| 61 | ||
| 62 | beforeAll(async () => { | |
| 63 | dir = await mkdtemp(join(tmpdir(), "forge-chain-mode-")); | |
| 64 | root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" }); | |
| 65 | const alice = await createUser(root.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 66 | if (!alice.ok) throw new Error("user"); | |
| 67 | const made = await createRepo(root, alice.value, { name: "forge-code", visibility: "public" }); | |
| 68 | if (!made.ok) throw new Error("repo"); | |
| 69 | const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), { | |
| 70 | branch: "main", | |
| 71 | message: "forge v1", | |
| 72 | files: [{ path: "README.md", content: "# forge\n" }], | |
| 73 | author: { name: "a", email: "[email protected]" }, | |
| 74 | }); | |
| 75 | if (!c.ok) throw new Error("commit"); | |
| 76 | const a = await spawnForge( | |
| 77 | root, | |
| 78 | alice.value, | |
| 79 | { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false }, | |
| 80 | { catalog: [], briefs: [], subscriptions: [] }, | |
| 81 | { | |
| 82 | placement: hostedPlacement({ host: "root.test" }), | |
| 83 | request: new Request("https://root.test/x", { method: "POST", headers: { origin: "https://root.test" } }), | |
| 84 | url: new URL("https://root.test/x"), | |
| 85 | }, | |
| 86 | ); | |
| 87 | if (!a.ok) throw new Error(JSON.stringify(a.error)); | |
| 88 | child = createApp({ dataDir: childDirOf(root, a.value.bodyId), port: 0 }); | |
| 89 | servers = await serve(child); | |
| 90 | const owner = getUserByName(child.db, "alice")!; | |
| 91 | cookie = `forge_session=${createSession(child.db, owner.id).token}`; | |
| 92 | ownerTok = createToken(child.db, owner.id, "t", "rw").token; | |
| 93 | const reader = await createUser(child.db, { origin: "seed", trusted: true, username: "reader", email: "[email protected]" }); | |
| 94 | if (!reader.ok) throw new Error("reader"); | |
| 95 | readerTok = createToken(child.db, reader.value.id, "t", "rw").token; | |
| 96 | readerCookie = `forge_session=${createSession(child.db, reader.value.id).token}`; | |
| 97 | }, 60_000); | |
| 98 | ||
| 99 | afterAll(async () => { | |
| 100 | servers.stop(); | |
| 101 | child.close(); | |
| 102 | root.close(); | |
| 103 | await rm(dir, { recursive: true, force: true }); | |
| 104 | }); | |
| 105 | ||
| 106 | test("refusals change nothing and say why", async () => { | |
| 107 | const before = await raw(); | |
| 108 | expect((await patch(readerTok, { mode: "take-updates" })).status).toBe(403); | |
| 109 | expect((await patch(ownerTok, { mode: "yolo" })).status).toBe(422); | |
| 110 | expect((await patch(ownerTok, {})).status).toBe(422); | |
| 111 | expect((await form(readerCookie, "take-updates")).status).toBe(303); | |
| 112 | expect((await form(cookie, "yolo")).status).toBe(303); | |
| 113 | expect((await tool(readerTok, { repo: "kid/forge", mode: "take-updates" })).error).toBe(true); | |
| 114 | expect((await tool(ownerTok, { repo: "kid/forge", mode: "yolo" })).error).toBe(true); | |
| 115 | const cross = await fetch(`${servers.url}/kid/forge/following/mode`, { | |
| 116 | method: "POST", | |
| 117 | headers: { cookie, origin: "https://evil.test" }, | |
| 118 | body: new URLSearchParams({ mode: "take-updates" }), | |
| 119 | }); | |
| 120 | expect(cross.status).toBe(403); | |
| 121 | expect(await raw()).toBe(before); | |
| 122 | expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(0); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the owner changes it from the page, the API and MCP; the page shows it", async () => { | |
| 126 | expect((await forgeLinkOf(child))?.mode).toBe("tell-me"); | |
| 127 | const page = async () => (await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); | |
| 128 | const html = await page(); | |
| 129 | expect(html).toContain('action="/kid/forge/following/mode"'); | |
| 130 | expect(html).toMatch(/value="tell-me" checked/); | |
| 131 | expect(html).not.toContain("<script>"); | |
| 132 | ||
| 133 | expect((await form(cookie, "take-updates")).status).toBe(303); | |
| 134 | expect((await forgeLinkOf(child))?.mode).toBe("take-updates"); | |
| 135 | expect(await page()).toMatch(/value="take-updates" checked/); | |
| 136 | ||
| 137 | const res = await patch(ownerTok, { mode: "tell-me" }); | |
| 138 | expect(res.status).toBe(200); | |
| 139 | expect(((await res.json()) as any).mode).toBe("tell-me"); | |
| 140 | const viaTool = await tool(ownerTok, { repo: "kid/forge", mode: "take-updates" }); | |
| 141 | expect(viaTool.error).toBe(false); | |
| 142 | expect((await forgeLinkOf(child))?.mode).toBe("take-updates"); | |
| 143 | expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(3); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a severed forge refuses with 409 and keeps its record", async () => { | |
| 147 | expect((await severChain(child)).ok).toBe(true); | |
| 148 | const before = await raw(); | |
| 149 | expect((await patch(ownerTok, { mode: "tell-me" })).status).toBe(409); | |
| 150 | expect((await tool(ownerTok, { repo: "kid/forge", mode: "tell-me" })).error).toBe(true); | |
| 151 | await form(cookie, "tell-me"); | |
| 152 | expect(await raw()).toBe(before); | |
| 153 | const html = await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text(); | |
| 154 | expect(html).not.toContain("/following/mode"); | |
| 155 | }); | |
| 156 | }); |
Once the terminal binding exists, name it here too: "…or press
mon the chain screen in the terminal".