Public: anyone can read this; the crew can change it.
A new forge follows the topmost forge on its own: its forge repository takes updates by default, and a clean update merges itself #11148
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11143-a-new-forge-follows-the-topmost-forge-on → main 5 commits fork
20 files
changed +141
−76
docs/FOLLOWING.md +4 −4
| @@ -184,8 +184,8 @@ there is landing it upstream. | ||
| 184 | 184 | the API. |
| 185 | 185 | |
| 186 | 186 | A fork across forges (#1085): a spawned forge's `<org>/forge` follows its |
| 187 | parent's forge code in the mode the spawner chose, `tell-me` (default) or | |
| 188 | `take-updates`; `/:owner/forge/following` shows the chain to the root and | |
| 187 | parent's forge code in the mode the spawner chose, `take-updates` (default) or | |
| 188 | `tell-me`; `/:owner/forge/following` shows the chain to the root and | |
| 189 | 189 | the owner's **Sever**, which cuts the link and deletes nothing (the page |
| 190 | 190 | and the parent's `/family` say "follows nothing since …"). `docs/SPAWN.md` |
| 191 | 191 | has the shape. A **conflict** on **Take updates** is a tell for the crew: |
| @@ -256,8 +256,8 @@ the default branch is pushed to `child/<name>/<branch>-<tip>` at the parent, | ||
| 256 | 256 | and the pull opens there, titled by the change that landed last and naming it |
| 257 | 257 | (`org/forge#7`). Asking again for the same tip returns the pull already made. |
| 258 | 258 | |
| 259 | The repository's admin changes how the forge follows its parent (`tell-me` or | |
| 260 | `take-updates`) from the radio form on the same page, with `PATCH | |
| 259 | The repository's admin changes how the forge follows its parent (`take-updates` or | |
| 260 | `tell-me`) from the radio form on the same page, with `PATCH | |
| 261 | 261 | /api/v1/forge/following {"mode"}`, with the MCP `follow_mode` tool, or by pressing `m` on the chain screen in the terminal. The |
| 262 | 262 | record in `parent.json` is rewritten atomically and a `forge.follow_mode` event |
| 263 | 263 | is appended. A non-admin is refused (403), a value outside the two modes is |
docs/MCP.md +1 −1
| @@ -197,7 +197,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 197 | 197 | | `signup` | — | Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account | 291 | |
| 198 | 198 | | `sizes` | — | A child forge's sizes: small, medium and large, each with CPU, memory and disk | 196 | |
| 199 | 199 | | `snapshot_now` | — | Take a snapshot now, as Backups does (admin) | 133 | |
| 200 | | `spawn` | — | Spawn a child forge | 910 | | |
| 200 | | `spawn` | — | Spawn a child forge | 908 | | |
| 201 | 201 | | `spend` | — | Report what your session spent outside forge runs: who, model, tokens or dollars, issue | 470 | |
| 202 | 202 | | `ssh_keys` | `list`, `add`, `remove` | Your SSH keys; add: key, remove: id | 250 | |
| 203 | 203 | | `steer` | — | Say a word to one crew run by its id | 224 | |
docs/SPAWN.md +6 −6
| @@ -95,8 +95,8 @@ reads child → parent → … → root. | ||
| 95 | 95 | `parent.json` keeps `forgeCode`: `repo`, `upstream`, `mode`, `tip` (the |
| 96 | 96 | upstream's tip at birth) and `chain` (the ancestors' hops, copied from the |
| 97 | 97 | parent's own `parent.json`, so a grandchild reads the whole chain offline). |
| 98 | The spawner chooses `followMode` in the spawn input: `tell-me` (default) or | |
| 99 | `take-updates`; it is part of the plan. `/:owner/forge/following` on the | |
| 98 | The spawner chooses `followMode` in the spawn input: `take-updates` (default) or | |
| 99 | `tell-me`; it is part of the plan. A plain fork (not a spawned forge) defaults to `tell-me`. `/:owner/forge/following` on the | |
| 100 | 100 | child shows the chain, root first, one line per hop with its mode and how |
| 101 | 101 | many commits of its own it had when the next hop was born (the last hop is |
| 102 | 102 | counted now). What the modes do is in |
| @@ -125,7 +125,7 @@ learned (the parent's tip, how far behind the child is) is kept in | ||
| 125 | 125 | The owner's `followMode` decides what happens when the parent has new commits: |
| 126 | 126 | |
| 127 | 127 | - `tell-me` sends one inbox card per new tip, listing its commits. |
| 128 | - `take-updates` opens one pull from the branch `forge/take-updates` | |
| 128 | - `take-updates` (the default) fast-forwards main when the fork has no commits of its own; otherwise it opens one pull from the branch `forge/take-updates` | |
| 129 | 129 | (`TAKE_BRANCH`) holding the parent's code, and updates that same pull when |
| 130 | 130 | the parent moves again. The pull carries the check like any other. |
| 131 | 131 | |
| @@ -142,7 +142,7 @@ leaves. The page and the parent's tree then say it follows nothing since | ||
| 142 | 142 | the date. |
| 143 | 143 | |
| 144 | 144 | A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen |
| 145 | `followMode` `tell-me` or `take-updates`; in `take-updates` the hourly check puts what the parent gained in a pull. | |
| 145 | `followMode` `take-updates` (default) or `tell-me`; in `take-updates` the hourly check fast-forwards a fork with no commits of its own, and puts what the parent gained in a pull when the fork has its own. | |
| 146 | 146 | Once an hour, and |
| 147 | 147 | on demand, a forge that follows and has not severed fetches its parent's code into |
| 148 | 148 | `refs/upstream/*` of its own `<org>/forge` (no branch of the fork moves) and keeps |
| @@ -150,10 +150,10 @@ on demand, a forge that follows and has not severed fetches its parent's code in | ||
| 150 | 150 | the count shows on the chain page, in the terminal's **Chain** (`c` checks now), and as |
| 151 | 151 | `upstream` in the API and MCP payloads. `POST /:owner/forge/following/check` (a writer, |
| 152 | 152 | same-origin) checks on demand; a fetch is cut at 60 s and a failure is recorded, not |
| 153 | thrown. In `take-updates` the check then puts `refs/upstream/HEAD` (a fast-forward, or a clean merge commit) on the | |
| 153 | thrown. In `take-updates` the check first fast-forwards main itself when the fork has no commits of its own (compare-and-swap, so a push in between wins). Otherwise it puts `refs/upstream/HEAD` (a fast-forward, or a clean merge commit) on the | |
| 154 | 154 | fork's `forge/take-updates` branch and opens one pull, "Take updates from <parent>", into main, or updates |
| 155 | 155 | that pull when it is open (`spawn/take.ts`); the fork's check runs on it like on any pull and a person merges it. |
| 156 | Main is never moved, so nothing deploys by itself. It is recorded as `taken` in `upstream.json` (the chain line | |
| 156 | Main is moved only by that untouched-fork fast-forward; a fork with its own commits gets the pull and a person merges it. It is recorded as `taken` in `upstream.json` (the chain line | |
| 157 | 157 | reads "N commits from your parent are in pull #P") and as a `forge.updates_taken` event; a pull closed unmerged |
| 158 | 158 | is not reopened until the parent moves again. `forge/take-updates` belongs to the check: a commit a person pushes there is overwritten by the next check that finds something different to put on it (open a branch of your own to build on it). On a conflict nothing changes and one open issue, |
| 159 | 159 | "Take updates from <parent>", names the files. In `tell-me`, a new parent tip makes one inbox card for the owner (an issue on the fork assigned to them, "Your parent forge has N new commits", linking the chain page); `toldTip` in `upstream.json` keeps it to one per tip, and `take-updates` or a severed link makes none. Its chain |
docs/features/follow-mode.md +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | A fork follows its upstream in one of three modes, said in one line on its page: |
| 4 | 4 | |
| 5 | 5 | - **Follows ann/yard automatically** (`take-updates`): new code is merged as it lands; a conflict goes to the crew. |
| 6 | - **Proposes updates from ann/yard** (`tell-me`, the default): a fork with nothing of its own still moves along; one with work of its own is told, and takes updates when its owner chooses. | |
| 6 | - **Takes updates from ann/yard** (`take-updates`, the default for a new forge): a fork with no commits of its own fast-forwards by itself; one with commits of its own gets a pull to merge. A plain fork defaults to `tell-me`: its owner is told and chooses. | |
| 7 | 7 | - **Does not follow ann/yard** (`dont-follow`, #608): nothing is fast-forwarded, merged or announced, and the crew is never asked to converge. The fork can still offer its changes up. |
| 8 | 8 | |
| 9 | 9 | The line shows only on a fork, to anyone who can see the page. |
src/core/fork_follow.ts +6 −3
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** How a plain fork follows its parent: one row per fork that chose; none means the default. */ |
| 2 | 2 | |
| 3 | 3 | import type { Database } from "bun:sqlite"; |
| 4 | import { DEFAULT_MODE, FOLLOW_MODES } from "../spawn/chain.ts"; | |
| 4 | import { FOLLOW_MODES, type FollowMode } from "../spawn/chain.ts"; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * A plain fork's modes: the chain's two, and `dont-follow` (#608), which a |
| @@ -13,9 +13,12 @@ export type ForkFollowMode = (typeof FORK_FOLLOW_MODES)[number]; | ||
| 13 | 13 | export const isForkFollowMode = (v: unknown): v is ForkFollowMode => |
| 14 | 14 | typeof v === "string" && (FORK_FOLLOW_MODES as readonly string[]).includes(v); |
| 15 | 15 | |
| 16 | /** A plain fork still proposes by default; only a spawned forge takes updates. */ | |
| 17 | export const FORK_DEFAULT_MODE: FollowMode = "tell-me"; | |
| 18 | ||
| 16 | 19 | export function forkFollowMode(db: Database, repoId: string): ForkFollowMode { |
| 17 | 20 | const row = db.query<{ mode: ForkFollowMode }, [string]>("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(repoId); |
| 18 | return row?.mode ?? DEFAULT_MODE; | |
| 21 | return row?.mode ?? FORK_DEFAULT_MODE; | |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | 24 | export function setForkFollowMode(db: Database, repoId: string, mode: ForkFollowMode): void { |
| @@ -24,7 +27,7 @@ export function setForkFollowMode(db: Database, repoId: string, mode: ForkFollow | ||
| 24 | 27 | ).run(repoId, mode); |
| 25 | 28 | } |
| 26 | 29 | |
| 27 | /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `DEFAULT_MODE`). */ | |
| 30 | /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `FORK_DEFAULT_MODE`). */ | |
| 28 | 31 | export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, ForkFollowMode> { |
| 29 | 32 | if (repoIds.length === 0) return new Map(); |
| 30 | 33 | const rows = db |
src/spawn/chain.ts +3 −2
| @@ -20,7 +20,8 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 20 | 20 | |
| 21 | 21 | export const FOLLOW_MODES = ["tell-me", "take-updates"] as const; |
| 22 | 22 | export type FollowMode = (typeof FOLLOW_MODES)[number]; |
| 23 | export const DEFAULT_MODE: FollowMode = "tell-me"; | |
| 23 | /** What a new forge follows with; a link on disk with no mode is read as `tell-me`, as it always was. */ | |
| 24 | export const DEFAULT_MODE: FollowMode = "take-updates"; | |
| 24 | 25 | |
| 25 | 26 | /** One forge in the chain, as recorded when the hop below it was made. */ |
| 26 | 27 | export interface Hop { |
| @@ -98,7 +99,7 @@ export async function forgeLinkOf(app: App): Promise<ForgeLink | undefined> { | ||
| 98 | 99 | return { |
| 99 | 100 | repo: l["repo"], |
| 100 | 101 | upstream: l["upstream"], |
| 101 | mode: isMode(l["mode"]) ? l["mode"] : DEFAULT_MODE, | |
| 102 | mode: isMode(l["mode"]) ? l["mode"] : "tell-me", | |
| 102 | 103 | tip: typeof l["tip"] === "string" && TIP.test(l["tip"]) ? l["tip"] : "", |
| 103 | 104 | chain, |
| 104 | 105 | ...(severedAt === undefined ? {} : { severedAt }), |
src/spawn/follow_words.ts +6 −5
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | * Follow words (#7199): what each follow mode does, in one plain line, so |
| 3 | 3 | * `/forges/new`, the terminal's new-forge screen and MCP `spawn` say the |
| 4 | 4 | * same thing. They describe `src/spawn/upstream.ts`: `tell-me` sends the |
| 5 | * owner an inbox card when this forge's code moves; `take-updates` opens the | |
| 6 | * one `forge/take-updates` pull (`src/spawn/take.ts`), which a person merges. | |
| 5 | * owner an inbox card when this forge's code moves; `take-updates` (the | |
| 6 | * default) fast-forwards a forge the owner has not changed, and otherwise | |
| 7 | * opens the one `forge/take-updates` pull (`src/spawn/take.ts`), which a person merges. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import type { FollowMode } from "./chain.ts"; |
| @@ -15,12 +16,12 @@ export const FOLLOW_LABELS: Readonly<Record<FollowMode, string>> = { | ||
| 15 | 16 | |
| 16 | 17 | export const FOLLOW_WORDS: Readonly<Record<FollowMode, string>> = { |
| 17 | 18 | "tell-me": "tells the owner in their inbox when this forge's code moves.", |
| 18 | "take-updates": "opens a pull with the new code, and a person merges it.", | |
| 19 | "take-updates": "takes new code in by itself; opens a pull once the repository has commits of its own.", | |
| 19 | 20 | }; |
| 20 | 21 | |
| 21 | 22 | /** One line naming both modes, for the terminal's hint. */ |
| 22 | 23 | export const FOLLOW_LINE = |
| 23 | `tell-me (default) ${FOLLOW_WORDS["tell-me"]} take-updates ${FOLLOW_WORDS["take-updates"]}`; | |
| 24 | `take-updates (default) ${FOLLOW_WORDS["take-updates"]} tell-me ${FOLLOW_WORDS["tell-me"]}`; | |
| 24 | 25 | |
| 25 | 26 | /** The same, short enough for an MCP field (60 bytes); docs/MCP.md has the long words. */ |
| 26 | export const FOLLOW_SHORT = "tell-me: an inbox card; take-updates: a pull a person merges"; | |
| 27 | export const FOLLOW_SHORT = "take-updates: merges itself or a pull; tell-me: inbox card"; |
src/spawn/plan.ts +2 −2
| @@ -70,7 +70,7 @@ export interface SpawnInput { | ||
| 70 | 70 | readonly budgetUsd?: unknown; |
| 71 | 71 | /** One line describing a fresh app to start in the child, beside the forked ones; optional. */ |
| 72 | 72 | readonly fresh?: unknown; |
| 73 | /** How the child follows the forge's code: `tell-me` (default) or `take-updates`. */ | |
| 73 | /** How the child follows the forge's code: `take-updates` (default) or `tell-me`. */ | |
| 74 | 74 | readonly followMode?: unknown; |
| 75 | 75 | /** How big the child is: `small`, `medium` or `large`; optional. */ |
| 76 | 76 | readonly size?: unknown; |
| @@ -116,7 +116,7 @@ export interface SpawnPlan { | ||
| 116 | 116 | readonly forWhom?: string; |
| 117 | 117 | /** A fresh app, present only when asked for. */ |
| 118 | 118 | readonly fresh?: PlannedFresh; |
| 119 | /** How the child follows its parent's forge code; absent means `tell-me`. */ | |
| 119 | /** How the child follows its parent's forge code; absent means `take-updates`. */ | |
| 120 | 120 | readonly followMode?: FollowMode; |
| 121 | 121 | /** The child's size, present only when chosen. */ |
| 122 | 122 | readonly size?: Size; |
src/spawn/take.ts +27 −4
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Taking the parent's code, for a forge that follows in `take-updates` |
| 3 | 3 | * mode. Once the hourly fetch has put the parent's tip at |
| 4 | * `refs/upstream/HEAD`, this puts a fast-forward, or a merge commit when git | |
| 5 | * merges the two without conflict, on one branch, `forge/take-updates`, and | |
| 4 | * `refs/upstream/HEAD`, a fork the owner has not changed (its default branch | |
| 5 | * is an ancestor of the parent's) is fast-forwarded in place. Otherwise this | |
| 6 | * puts a merge commit, when git merges the two without conflict, on one branch, `forge/take-updates`, and | |
| 6 | 7 | * opens one pull from it into the default branch (or updates the one already |
| 7 | * open). The fork's check runs on it like on any pull; a person merges it. | |
| 8 | * It never moves the default branch, and production follows that branch. | |
| 8 | * open). The fork's check runs on it like on any pull. | |
| 9 | * That pull never moves the default branch; a person merges it. | |
| 9 | 10 | * A conflict changes nothing; it opens (or finds) one issue naming the files. |
| 10 | 11 | */ |
| 11 | 12 | |
| @@ -26,6 +27,7 @@ import type { ForgeLink } from "./chain.ts"; | ||
| 26 | 27 | /** What the last check did with the parent's new commits; absent when it did nothing. */ |
| 27 | 28 | export type Taken = |
| 28 | 29 | | { readonly kind: "pull"; readonly number: number; readonly commits: number } |
| 30 | | { readonly kind: "forwarded"; readonly commits: number } | |
| 29 | 31 | | { readonly kind: "conflict"; readonly files: readonly string[]; readonly issue: number | null }; |
| 30 | 32 | |
| 31 | 33 | export const takeTitle = (upstream: string): string => `Take updates from ${upstream}`; |
| @@ -120,6 +122,27 @@ export async function takeFromParent(app: App, link: ForgeLink): Promise<Taken | | ||
| 120 | 122 | const count = await runGit(["rev-list", "--count", `${head}..${up}`], { cwd: dir }); |
| 121 | 123 | const commits = count.ok ? Number(count.value.trim()) || 0 : 0; |
| 122 | 124 | |
| 125 | // A forge that takes updates and whose owner has not changed the repository: | |
| 126 | // the parent's code is a pure fast-forward, with nothing of theirs to review it against. | |
| 127 | // A forge that only tells (and is pressed to take) still gets the pull. | |
| 128 | if (base === head && link.mode === "take-updates") { | |
| 129 | const ref = `refs/heads/${branch}`; | |
| 130 | // Compare-and-swap: a push to the branch since `head` was read wins, and the next check looks again. | |
| 131 | if (!(await runGit(["update-ref", ref, up, head], { cwd: dir })).ok) return undefined; | |
| 132 | app.bus.append({ | |
| 133 | kind: "push", | |
| 134 | repoId: repo.id, | |
| 135 | actorId: owner.id, | |
| 136 | payload: { updates: [{ ref, oldOid: head, newOid: up }], followed: link.upstream }, | |
| 137 | }); | |
| 138 | app.bus.append({ | |
| 139 | kind: "forge.updates_taken", | |
| 140 | repoId: repo.id, | |
| 141 | payload: { repo: link.repo, upstream: link.upstream, from: head, to: up, commits, pull: null }, | |
| 142 | }); | |
| 143 | return { kind: "forwarded", commits }; | |
| 144 | } | |
| 145 | ||
| 123 | 146 | const had = await resolveOid(dir, `refs/heads/${TAKE_BRANCH}`); |
| 124 | 147 | let to = up; |
| 125 | 148 | if (base !== head) { |
src/spawn/upstream.ts +4
| @@ -68,6 +68,8 @@ export async function upstreamState(app: App): Promise<UpstreamState | undefined | ||
| 68 | 68 | function readTaken(v: unknown): { taken?: Taken } { |
| 69 | 69 | if (typeof v !== "object" || v === null) return {}; |
| 70 | 70 | const t = v as Record<string, unknown>; |
| 71 | if (t["kind"] === "forwarded" && typeof t["commits"] === "number") | |
| 72 | return { taken: { kind: "forwarded", commits: t["commits"] } }; | |
| 71 | 73 | if (t["kind"] === "pull" && typeof t["number"] === "number" && typeof t["commits"] === "number") |
| 72 | 74 | return { taken: { kind: "pull", number: t["number"], commits: t["commits"] } }; |
| 73 | 75 | if (t["kind"] === "conflict" && Array.isArray(t["files"])) |
| @@ -221,6 +223,8 @@ export function upstreamWords(s: UpstreamState | undefined, now = Date.now()): s | ||
| 221 | 223 | const mins = Math.max(0, Math.floor((now - s.checkedAt) / 60_000)); |
| 222 | 224 | const when = mins < 1 ? "just now" : mins < 60 ? `${mins} min ago` : `${Math.floor(mins / 60)} h ago`; |
| 223 | 225 | if (s.error !== "") return `could not check your parent: ${s.error} (${when})`; |
| 226 | if (s.taken?.kind === "forwarded") | |
| 227 | return `${s.taken.commits} commit${s.taken.commits === 1 ? "" : "s"} from your parent ${s.taken.commits === 1 ? "was" : "were"} taken in (checked ${when})`; | |
| 224 | 228 | if (s.taken?.kind === "pull") |
| 225 | 229 | return `${s.taken.commits} commit${s.taken.commits === 1 ? "" : "s"} from your parent ${s.taken.commits === 1 ? "is" : "are"} in pull #${s.taken.number} (checked ${when})`; |
| 226 | 230 | if (s.taken?.kind === "conflict") |
src/tui/screens/newforge.ts +2 −2
| @@ -82,7 +82,7 @@ export function newForgeScreen(f: Session, ctx: Ctx, initial: Record<string, str | ||
| 82 | 82 | name: val("name"), |
| 83 | 83 | apps, |
| 84 | 84 | ...(val("fresh") === "" ? {} : { fresh: val("fresh") }), |
| 85 | followMode: val("followMode") === "" ? "tell-me" : val("followMode"), | |
| 85 | followMode: val("followMode") === "" ? "take-updates" : val("followMode"), | |
| 86 | 86 | ...(val("for") === "" ? {} : { for: val("for") }), |
| 87 | 87 | ...(val("direction") === "" ? {} : { direction: val("direction") }), |
| 88 | 88 | ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }), |
| @@ -136,7 +136,7 @@ export function newForgeScreen(f: Session, ctx: Ctx, initial: Record<string, str | ||
| 136 | 136 | const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null; |
| 137 | 137 | const size = (body["plan"] as { size?: string } | undefined)?.size; |
| 138 | 138 | const template = (body["plan"] as { template?: string } | undefined)?.template; |
| 139 | const mode = val("followMode") === "" ? "tell-me" : val("followMode"); | |
| 139 | const mode = val("followMode") === "" ? "take-updates" : val("followMode"); | |
| 140 | 140 | plan = [ |
| 141 | 141 | `A forge at ${childUrlOf(f.app, val("name"))}.`, |
| 142 | 142 | `It follows this forge's code as ${mode}${isFollowMode(mode) ? `: it ${FOLLOW_WORDS[mode]}` : "."}`, |
src/web/pages/new-forge.ts +8 −8
| @@ -59,7 +59,7 @@ const EMPTY: Values = { | ||
| 59 | 59 | apps: [], |
| 60 | 60 | crew: [], |
| 61 | 61 | fresh: "", |
| 62 | followMode: "tell-me", | |
| 62 | followMode: "take-updates", | |
| 63 | 63 | keepAccess: false, |
| 64 | 64 | budgetUsd: "", |
| 65 | 65 | modelEndpoint: "", |
| @@ -248,22 +248,22 @@ function formView( | ||
| 248 | 248 | <input |
| 249 | 249 | type="radio" |
| 250 | 250 | name="followMode" |
| 251 | value="tell-me" | |
| 252 | ${values.followMode !== "take-updates" ? html`checked` : null} | |
| 251 | value="take-updates" | |
| 252 | ${values.followMode !== "tell-me" ? html`checked` : null} | |
| 253 | 253 | /> |
| 254 | 254 | <span |
| 255 | >${FOLLOW_LABELS["tell-me"]} <span class="muted small">${FOLLOW_WORDS["tell-me"]}</span></span | |
| 255 | >${FOLLOW_LABELS["take-updates"]} <span class="muted small">${FOLLOW_WORDS["take-updates"]}</span></span | |
| 256 | 256 | > |
| 257 | 257 | </label> |
| 258 | 258 | <label class="check"> |
| 259 | 259 | <input |
| 260 | 260 | type="radio" |
| 261 | 261 | name="followMode" |
| 262 | value="take-updates" | |
| 263 | ${values.followMode === "take-updates" ? html`checked` : null} | |
| 262 | value="tell-me" | |
| 263 | ${values.followMode === "tell-me" ? html`checked` : null} | |
| 264 | 264 | /> |
| 265 | 265 | <span |
| 266 | >${FOLLOW_LABELS["take-updates"]} <span class="muted small">${FOLLOW_WORDS["take-updates"]}</span></span | |
| 266 | >${FOLLOW_LABELS["tell-me"]} <span class="muted small">${FOLLOW_WORDS["tell-me"]}</span></span | |
| 267 | 267 | > |
| 268 | 268 | </label> |
| 269 | 269 | ${errors["followMode"] |
| @@ -485,7 +485,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 485 | 485 | apps: all(form, "apps"), |
| 486 | 486 | crew: all(form, "crew"), |
| 487 | 487 | fresh: field(form, "fresh").trim(), |
| 488 | followMode: field(form, "followMode").trim() || "tell-me", | |
| 488 | followMode: field(form, "followMode").trim() || "take-updates", | |
| 489 | 489 | keepAccess: asBool(form, "keepAccess"), |
| 490 | 490 | budgetUsd: field(form, "budgetUsd").trim(), |
| 491 | 491 | modelEndpoint: field(form, "modelEndpoint").trim(), |
test/follow-e2e.test.ts +4 −3
| @@ -87,8 +87,6 @@ describe("following across forges, end to end", () => { | ||
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | 89 | test("the whole path", async () => { |
| 90 | const start = await main(); | |
| 91 | ||
| 92 | 90 | // 1-2. Two parent commits: behind 2, one card listing both. |
| 93 | 91 | await theirs("p1.txt", "1\n", "parent one"); |
| 94 | 92 | await theirs("p2.txt", "2\n", "parent two"); |
| @@ -101,7 +99,10 @@ describe("following across forges, end to end", () => { | ||
| 101 | 99 | expect(cards[0]!.body).toContain("parent two"); |
| 102 | 100 | expect(takes()).toHaveLength(0); |
| 103 | 101 | |
| 104 | // 3. take-updates: the next check opens one pull listing both; main stays. | |
| 102 | // 3. take-updates, the fork having a commit of its own: the next check opens one pull listing both; main stays. | |
| 103 | const own = await commitToBranch(app, kid, repo, { message: "own", files: [{ path: "own.txt", content: "own\n" }] }); | |
| 104 | expect(own.ok).toBe(true); | |
| 105 | const start = await main(); | |
| 105 | 106 | expect((await setChainMode(app, "take-updates")).ok).toBe(true); |
| 106 | 107 | const took = await checkUpstream(app, { remote: parent }); |
| 107 | 108 | expect(took?.taken).toMatchObject({ kind: "pull", commits: 2 }); |
test/new-forge.test.ts +7 −7
| @@ -296,15 +296,15 @@ describe("+ New forge", () => { | ||
| 296 | 296 | expect(await again.text()).not.toMatch(/claim#[A-Za-z0-9_-]{43}/); |
| 297 | 297 | }); |
| 298 | 298 | |
| 299 | test("the form offers how the child follows the forge's code, tell-me by default, and take-updates is recorded", async () => { | |
| 299 | test("the form offers how the child follows the forge's code, take-updates by default, and tell-me is recorded", async () => { | |
| 300 | 300 | const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " "); |
| 301 | expect(html).toMatch(/name="followMode" value="tell-me" checked/); | |
| 302 | expect(html).toContain('value="take-updates"'); | |
| 301 | expect(html).toMatch(/name="followMode" value="take-updates" checked/); | |
| 302 | expect(html).toContain('value="tell-me"'); | |
| 303 | 303 | (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery"; |
| 304 | 304 | try { |
| 305 | 305 | const res = await post(admin, [ |
| 306 | 306 | ["name", "follower"], |
| 307 | ["followMode", "take-updates"], | |
| 307 | ["followMode", "tell-me"], | |
| 308 | 308 | ["do", "spawn"], |
| 309 | 309 | ]); |
| 310 | 310 | expect(res.status).toBe(201); |
| @@ -314,7 +314,7 @@ describe("+ New forge", () => { | ||
| 314 | 314 | ), |
| 315 | 315 | ); |
| 316 | 316 | const mine = docs.find((d) => d?.name === "follower"); |
| 317 | expect(mine?.forgeCode?.mode).toBe("take-updates"); | |
| 317 | expect(mine?.forgeCode?.mode).toBe("tell-me"); | |
| 318 | 318 | const bad = await post(admin, [ |
| 319 | 319 | ["name", "wrongmode"], |
| 320 | 320 | ["followMode", "yolo"], |
| @@ -330,7 +330,7 @@ describe("+ New forge", () => { | ||
| 330 | 330 | test("the page promises no update that does not happen", async () => { |
| 331 | 331 | const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " "); |
| 332 | 332 | expect(html).toContain("Tell me <span class=\"muted small\">tells the owner in their inbox when this forge's code moves.</span>"); |
| 333 | expect(html).toContain("Take updates <span class=\"muted small\">opens a pull with the new code, and a person merges it.</span>"); | |
| 333 | expect(html).toContain("Take updates <span class=\"muted small\">takes new code in by itself; opens a pull once the repository has commits of its own.</span>"); | |
| 334 | 334 | expect(html).toContain("<legend>How it follows this forge's code</legend>"); |
| 335 | 335 | expect(html).not.toMatch(/recorded for now|nothing acts on it yet/i); |
| 336 | 336 | expect(html).not.toContain("when this forge changes"); |
| @@ -339,7 +339,7 @@ describe("+ New forge", () => { | ||
| 339 | 339 | |
| 340 | 340 | test("MCP spawn says what each follow mode does, as the page does", () => { |
| 341 | 341 | const followMode = (spawnTool.inputSchema.properties as Record<string, { description?: string }>)["followMode"]; |
| 342 | expect(followMode?.description).toBe("tell-me: an inbox card; take-updates: a pull a person merges"); | |
| 342 | expect(followMode?.description).toBe("take-updates: merges itself or a pull; tell-me: inbox card"); | |
| 343 | 343 | }); |
| 344 | 344 | |
| 345 | 345 | test("a forge at its depth limit shows no door, and its page says why", async () => { |
test/spawn-anon.test.ts +1 −1
| @@ -163,7 +163,7 @@ describe("anonymous spawn", () => { | ||
| 163 | 163 | join(childDirOf(app, body.bodyId), "parent.json"), |
| 164 | 164 | ).json()) as any; |
| 165 | 165 | expect(parent.forgeCode).toEqual( |
| 166 | expect.objectContaining({ repo: "kid/forge", upstream: "alice/forge-code", mode: "tell-me" }), | |
| 166 | expect.objectContaining({ repo: "kid/forge", upstream: "alice/forge-code", mode: "take-updates" }), | |
| 167 | 167 | ); |
| 168 | 168 | } finally { |
| 169 | 169 | child.close(); |
test/spawn-chain-mode.test.ts +5 −5
| @@ -126,20 +126,20 @@ describe("choosing how a forge follows its parent", () => { | ||
| 126 | 126 | }); |
| 127 | 127 | |
| 128 | 128 | test("the owner changes it from the page, the API and MCP; the page shows it", async () => { |
| 129 | expect((await forgeLinkOf(child))?.mode).toBe("tell-me"); | |
| 129 | expect((await forgeLinkOf(child))?.mode).toBe("take-updates"); | |
| 130 | 130 | const page = async () => (await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); |
| 131 | 131 | const html = await page(); |
| 132 | 132 | expect(html).toContain('action="/kid/forge/following/mode"'); |
| 133 | expect(html).toMatch(/value="tell-me" checked/); | |
| 133 | expect(html).toMatch(/value="take-updates" checked/); | |
| 134 | 134 | expect(html).not.toContain("<script>"); |
| 135 | 135 | // Rows and headings, not boxes. |
| 136 | 136 | expect(html).not.toContain('class="card'); |
| 137 | 137 | expect(html).toContain('<ul class="list" aria-label="Forges, root first">'); |
| 138 | 138 | expect(html).toContain("<h2"); |
| 139 | 139 | |
| 140 | expect((await form(cookie, "take-updates")).status).toBe(303); | |
| 141 | expect((await forgeLinkOf(child))?.mode).toBe("take-updates"); | |
| 142 | expect(await page()).toMatch(/value="take-updates" checked/); | |
| 140 | expect((await form(cookie, "tell-me")).status).toBe(303); | |
| 141 | expect((await forgeLinkOf(child))?.mode).toBe("tell-me"); | |
| 142 | expect(await page()).toMatch(/value="tell-me" checked/); | |
| 143 | 143 | |
| 144 | 144 | const res = await patch(ownerTok, { mode: "tell-me" }); |
| 145 | 145 | expect(res.status).toBe(200); |
test/spawn-chain.test.ts +8 −6
| @@ -71,7 +71,7 @@ describe("the chain of forges", () => { | ||
| 71 | 71 | }); |
| 72 | 72 | if (!c.ok) throw new Error("commit"); |
| 73 | 73 | |
| 74 | const a = await spawn(root, alice.value, "kid", "root.test", "take-updates"); | |
| 74 | const a = await spawn(root, alice.value, "kid", "root.test"); | |
| 75 | 75 | if (!a.ok) throw new Error(JSON.stringify(a.error)); |
| 76 | 76 | child = createApp({ dataDir: childDirOf(root, a.value.bodyId), url: "https://kid.test", port: 0 }); |
| 77 | 77 | // The child's owner changes their forge all they want. |
| @@ -84,7 +84,7 @@ describe("the chain of forges", () => { | ||
| 84 | 84 | if (!own.ok) throw new Error("own commit"); |
| 85 | 85 | |
| 86 | 86 | const founder = getUserByName(child.db, "alice")!; |
| 87 | const b = await spawn(child, founder, "tot", "kid.test"); | |
| 87 | const b = await spawn(child, founder, "tot", "kid.test", "tell-me"); | |
| 88 | 88 | if (!b.ok) throw new Error(JSON.stringify(b.error)); |
| 89 | 89 | grand = createApp({ dataDir: childDirOf(child, b.value.bodyId), port: 0 }); |
| 90 | 90 | servers = await serve(grand); |
| @@ -100,13 +100,15 @@ describe("the chain of forges", () => { | ||
| 100 | 100 | await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); |
| 101 | 101 | }); |
| 102 | 102 | |
| 103 | test("followMode is validated and defaults to tell-me", () => { | |
| 103 | test("followMode is validated and defaults to take-updates", () => { | |
| 104 | 104 | const base = { name: "x", org: "x", apps: [], carryBriefs: false, carrySubscriptions: false }; |
| 105 | 105 | expect(planSpawn(base, source).ok).toBe(true); |
| 106 | 106 | const bad = planSpawn({ ...base, followMode: "yolo" }, source); |
| 107 | 107 | expect(bad.ok).toBe(false); |
| 108 | const grandLink = planSpawn({ ...base, followMode: "tell-me" }, source); | |
| 109 | expect(grandLink.ok && grandLink.value.followMode).toBeUndefined(); | |
| 108 | const taking = planSpawn({ ...base, followMode: "take-updates" }, source); | |
| 109 | expect(taking.ok && taking.value.followMode).toBeUndefined(); | |
| 110 | const telling = planSpawn({ ...base, followMode: "tell-me" }, source); | |
| 111 | expect(telling.ok && telling.value.followMode).toBe("tell-me"); | |
| 110 | 112 | }); |
| 111 | 113 | |
| 112 | 114 | test("the child carries a fork of the parent's forge code in the chosen mode", async () => { |
| @@ -118,7 +120,7 @@ describe("the chain of forges", () => { | ||
| 118 | 120 | expect(getRepo(child.db, "kid", "forge")).toBeDefined(); |
| 119 | 121 | }); |
| 120 | 122 | |
| 121 | test("the grandchild's fork names the child's forge, mode tell-me by default", async () => { | |
| 123 | test("the grandchild's fork names the child's forge, mode as chosen", async () => { | |
| 122 | 124 | const link = await forgeLinkOf(grand); |
| 123 | 125 | expect(link?.upstream).toBe("kid/forge"); |
| 124 | 126 | expect(link?.mode).toBe("tell-me"); |
test/spawn-take-body.test.ts +4 −1
| @@ -59,6 +59,9 @@ describe("the take pull's description", () => { | ||
| 59 | 59 | work = join(dir, "work"); |
| 60 | 60 | await git(dir, "clone", "-q", "--bare", repoDir(app.reposRoot, "kid", "forge"), parent); |
| 61 | 61 | await git(dir, "clone", "-q", parent, work); |
| 62 | // The fork has a commit of its own, so updates arrive as a pull. | |
| 63 | const own = await commitToBranch(app, u.value, repo, { message: "own", files: [{ path: "own.txt", content: "own\n" }] }); | |
| 64 | if (!own.ok) throw new Error(JSON.stringify(own.error)); | |
| 62 | 65 | await Bun.write( |
| 63 | 66 | join(app.config.dataDir, "parent.json"), |
| 64 | 67 | JSON.stringify({ |
| @@ -98,7 +101,7 @@ describe("the take pull's description", () => { | ||
| 98 | 101 | await theirs("c6", "subject 6"); |
| 99 | 102 | await take(); |
| 100 | 103 | expect(body()).toBe("My own words."); |
| 101 | expect(open()[0]!.headOid).toBe(await git(parent, "rev-parse", "refs/heads/main")); | |
| 104 | expect((await git(repoDir(app.reposRoot, "kid", "forge"), "rev-list", "--parents", "-n1", open()[0]!.headOid)).split(" ")).toContain(await git(parent, "rev-parse", "refs/heads/main")); | |
| 102 | 105 | }); |
| 103 | 106 | |
| 104 | 107 | test("hostile subjects stay inert in a fresh pull's body", async () => { |
test/spawn-take.test.ts +36 −9
| @@ -101,7 +101,23 @@ describe("taking the parent's code", () => { | ||
| 101 | 101 | |
| 102 | 102 | const pulls = () => listPulls(app.db, repo.id, { state: "open" }).filter((p) => p.headBranch === "forge/take-updates"); |
| 103 | 103 | |
| 104 | test("a fast-forward opens one pull and leaves main alone", async () => { | |
| 104 | test("an untouched fork fast-forwards main within one check, and opens no pull", async () => { | |
| 105 | const s = await checkUpstream(app, { remote: parent }); | |
| 106 | const tip = await git(parent, "rev-parse", "refs/heads/main"); | |
| 107 | expect(s?.taken).toEqual({ kind: "forwarded", commits: 1 }); | |
| 108 | expect(await main()).toBe(tip); | |
| 109 | expect(pulls()).toHaveLength(0); | |
| 110 | expect(s?.behind).toBe(0); | |
| 111 | expect(taken()).toHaveLength(1); | |
| 112 | expect(listEvents(app.db, { repoId: repo.id, kind: "push" }).some((e) => JSON.stringify(e.payload).includes(tip))).toBe(true); | |
| 113 | expect(upstreamWords(s, s!.checkedAt)).toContain("1 commit from your parent was taken in"); | |
| 114 | await checkUpstream(app, { remote: parent }); | |
| 115 | expect(taken()).toHaveLength(1); // nothing new: nothing said again | |
| 116 | }); | |
| 117 | ||
| 118 | test("a fork with commits of its own gets a pull, never an automatic merge", async () => { | |
| 119 | await mine("own.txt", "own\n"); | |
| 120 | await theirs("p1.txt", "1\n"); | |
| 105 | 121 | const before = await main(); |
| 106 | 122 | const s = await checkUpstream(app, { remote: parent }); |
| 107 | 123 | expect(pulls()).toHaveLength(1); |
| @@ -110,27 +126,23 @@ describe("taking the parent's code", () => { | ||
| 110 | 126 | expect(p.authorId).toBe(kid.id); |
| 111 | 127 | expect(s?.taken).toEqual({ kind: "pull", number: p.number, commits: 1 }); |
| 112 | 128 | expect(await main()).toBe(before); |
| 113 | expect(await git(child, "rev-parse", "refs/heads/forge/take-updates")).toBe(await git(parent, "rev-parse", "refs/heads/main")); | |
| 114 | expect(taken()).toHaveLength(1); | |
| 129 | expect(taken()).toHaveLength(2); | |
| 115 | 130 | expect(upstreamWords(s, s!.checkedAt)).toContain(`1 commit from your parent is in pull #${p.number}`); |
| 116 | 131 | }); |
| 117 | 132 | |
| 118 | 133 | test("a second fetch with one more commit updates the same pull", async () => { |
| 119 | 134 | const before = await main(); |
| 120 | 135 | const first = pulls()[0]!; |
| 121 | await theirs("p1.txt", "1\n"); | |
| 136 | await theirs("p1b.txt", "1b\n"); | |
| 122 | 137 | const s = await checkUpstream(app, { remote: parent }); |
| 123 | 138 | expect(pulls()).toHaveLength(1); |
| 124 | 139 | expect(pulls()[0]!.number).toBe(first.number); |
| 125 | expect(pulls()[0]!.headOid).toBe(await git(parent, "rev-parse", "refs/heads/main")); | |
| 126 | 140 | expect(s?.taken).toEqual({ kind: "pull", number: first.number, commits: 2 }); |
| 127 | 141 | expect(await main()).toBe(before); |
| 128 | expect(taken()).toHaveLength(2); | |
| 142 | expect(taken()).toHaveLength(3); | |
| 129 | 143 | const pushed = listEvents(app.db, { repoId: repo.id, kind: "pull.pushed" }); |
| 130 | 144 | expect(pushed).toHaveLength(1); |
| 131 | 145 | expect(JSON.stringify(pushed[0]!.payload)).toContain(first.number.toString()); |
| 132 | await checkUpstream(app, { remote: parent }); | |
| 133 | expect(taken()).toHaveLength(2); // nothing new: nothing said again | |
| 134 | 146 | }); |
| 135 | 147 | |
| 136 | 148 | test("a clean merge goes on the branch, not on main", async () => { |
| @@ -198,6 +210,21 @@ describe("taking the parent's code", () => { | ||
| 198 | 210 | const issues = listIssues(app.db, repo.id, { sealed: true }).filter((i) => i.title === "Take updates from root/forge"); |
| 199 | 211 | expect(issues).toHaveLength(1); |
| 200 | 212 | expect(issues[0]!.body).toContain("a.txt"); |
| 201 | expect(taken()).toHaveLength(4); | |
| 213 | expect(taken()).toHaveLength(5); | |
| 214 | }); | |
| 215 | ||
| 216 | test("a severed fork gets nothing", async () => { | |
| 217 | await Bun.write( | |
| 218 | join(app.config.dataDir, "parent.json"), | |
| 219 | JSON.stringify({ | |
| 220 | upstream: "https://parent.test", | |
| 221 | forgeCode: { repo: "kid/forge", upstream: "root/forge", mode: "take-updates", tip: "a".repeat(40), chain: [], severedAt: new Date().toISOString() }, | |
| 222 | }), | |
| 223 | ); | |
| 224 | await theirs("p9.txt", "9\n"); | |
| 225 | const before = await main(); | |
| 226 | expect(await checkUpstream(app, { remote: parent })).toBeUndefined(); | |
| 227 | expect(await main()).toBe(before); | |
| 228 | expect(taken()).toHaveLength(5); | |
| 202 | 229 | }); |
| 203 | 230 | }); |
test/tui-newforge.test.ts +6 −6
| @@ -87,11 +87,11 @@ describe("the new forge screen", () => { | ||
| 87 | 87 | test("the screen and its plan promise no update that does not happen", () => |
| 88 | 88 | withScreen(admin, async ({ tui, terminal }) => { |
| 89 | 89 | const form = tui.screen.toText(); |
| 90 | expect(form).toContain("tell-me (default) tells the owner in their inbox"); | |
| 90 | expect(form).toContain("take-updates (default) takes new code in by itself"); | |
| 91 | 91 | terminal.feed("kai\x10"); |
| 92 | 92 | await settleApp(tui); |
| 93 | 93 | const text = form + tui.screen.toText(); |
| 94 | expect(tui.screen.toText()).toContain("It follows this forge's code as tell-me: it tells the owner in their inbox"); | |
| 94 | expect(tui.screen.toText()).toContain("It follows this forge's code as take-updates: it takes new code in by itself"); | |
| 95 | 95 | expect(text).not.toMatch(/recorded for now/i); |
| 96 | 96 | expect(text).not.toMatch(/keeps? (itself )?up to date|updates itself|automatically/i); |
| 97 | 97 | })); |
| @@ -173,13 +173,13 @@ describe("the new forge screen", () => { | ||
| 173 | 173 | expect(tui.screen.toText()).toMatch(/https:\/\/zed--forge\.test\/claim#/); |
| 174 | 174 | })); |
| 175 | 175 | |
| 176 | test("the follow mode is a field: take-updates reaches parent.json", () => | |
| 176 | test("the follow mode is a field: tell-me reaches parent.json", () => | |
| 177 | 177 | withScreen(admin, async ({ tui, terminal }) => { |
| 178 | 178 | (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery"; |
| 179 | 179 | try { |
| 180 | terminal.feed("tuifollow" + "\t".repeat(8) + "take-updates\x10"); | |
| 180 | terminal.feed("tuifollow" + "\t".repeat(8) + "tell-me\x10"); | |
| 181 | 181 | await settleApp(tui); |
| 182 | expect(tui.screen.toText()).toContain("take-updates"); | |
| 182 | expect(tui.screen.toText()).toContain("tell-me"); | |
| 183 | 183 | terminal.feed("\r"); |
| 184 | 184 | await settleApp(tui); |
| 185 | 185 | const docs = await Promise.all( |
| @@ -187,7 +187,7 @@ describe("the new forge screen", () => { | ||
| 187 | 187 | Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined), |
| 188 | 188 | ), |
| 189 | 189 | ); |
| 190 | expect(docs.find((d) => d?.name === "tuifollow")?.forgeCode?.mode).toBe("take-updates"); | |
| 190 | expect(docs.find((d) => d?.name === "tuifollow")?.forgeCode?.mode).toBe("tell-me"); | |
| 191 | 191 | } finally { |
| 192 | 192 | delete (app.config as { forgeRepo?: string }).forgeRepo; |
| 193 | 193 | } |