Public: anyone can read this; the crew can change it.
A spawned forge carries a fork of the forge it came from, and the chain shows both ways #1862
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1085-a-spawned-forge-carries-a-fork-of-the-fo → main 13 commits fork 13 threads · 13 unresolved
README.md +9
| @@ -212,6 +212,15 @@ taking a release is not done here. This forge publishes its own | ||
| 212 | 212 | `/releases.json` (the version names, each linking to that document), so any |
| 213 | 213 | forge can be a parent. The terminal shows the count, not the release names. |
| 214 | 214 | |
| 215 | A spawned forge's own code is a fork of its parent's (`<org>/forge`, chosen | |
| 216 | `followMode` `tell-me` or `take-updates`; recorded, not yet acted on). Its chain | |
| 217 | to the root is at `/:owner/forge/following`, in `chain` of `GET | |
| 218 | /api/v1/repos/:owner/forge/following` and the MCP `following` tool, and in the | |
| 219 | terminal's **Chain** command; the owner severs it with the page's Sever, | |
| 220 | `POST …/following/sever` or the MCP `sever` tool (nothing is deleted). Taking | |
| 221 | updates into a conflict opens the converge issue at every door. See | |
| 222 | `docs/SPAWN.md` and `docs/FOLLOWING.md`. | |
| 223 | ||
| 215 | 224 | ## Archive |
| 216 | 225 | |
| 217 | 226 | An admin puts a repository away from **Settings → Archive**, with |
docs/FOLLOWING.md +13
| @@ -180,3 +180,16 @@ the fork owner's name, `trevato/main` proposing into `main`. Landing it | ||
| 180 | 180 | there is landing it upstream. |
| 181 | 181 | `POST /api/v1/repos/:owner/:name/following/propose` is the same press over |
| 182 | 182 | the API. |
| 183 | ||
| 184 | A fork across forges (#1085): a spawned forge's `<org>/forge` follows its | |
| 185 | parent's forge code in the mode the spawner chose, `tell-me` (default) or | |
| 186 | `take-updates`; `/:owner/forge/following` shows the chain to the root and | |
| 187 | the owner's **Sever**, which cuts the link and deletes nothing (the page | |
| 188 | and the parent's `/family` say "follows nothing since …"). `docs/SPAWN.md` | |
| 189 | has the shape. A **conflict** on **Take updates** is a tell for the crew: | |
| 190 | the press opens (or finds) the converge issue naming the files in conflict, | |
| 191 | assigned to a builder when the repository has one, and says why not when it | |
| 192 | has none. The step is `takeOrTell` in `src/actions/follow.ts`, shared by the | |
| 193 | page, `POST /api/v1/repos/:owner/:name/following/take` (409, naming the | |
| 194 | converge issue), and the MCP `take_updates` tool, so the same press does the | |
| 195 | same thing at every door. |
docs/SPAWN.md +36
| @@ -69,3 +69,39 @@ Children report in; the parent never reaches in. Every ten minutes a spawned chi | ||
| 69 | 69 | ## Callers |
| 70 | 70 | |
| 71 | 71 | `POST /api/v1/forges {name, apps[], crew[], direction?, keepAccess?, modelKey?, budgetUsd?, dryRun}` (rw token, admin only) and the MCP `spawn` tool share `src/spawn/door.ts`. `dryRun` is true unless it is `false`: the answer is the plan and what is left behind, and nothing is made. `GET /api/v1/forges` lists the ledger's children. The child's org is its name; `crew` names the agents whose briefs travel. Errors: 401 unauthorized, 403 forbidden / cross_origin, 422 invalid, 409 refused / exists, 500 failed — each with one sentence. The MCP lets an `ro` token plan, never make. |
| 72 | ||
| 73 | ## The forge's own code, and the chain (#1085) | |
| 74 | ||
| 75 | Every child is born with `<org>/forge`: a fork, with full history, of the | |
| 76 | repository its parent forge is deployed from (`forgeRepo`; a spawned forge | |
| 77 | has none configured, so it uses the fork it was born with). The child's owner | |
| 78 | may change it freely. A grandchild forks the child's `forge`, so the chain | |
| 79 | reads child → parent → … → root. | |
| 80 | ||
| 81 | `parent.json` keeps `forgeCode`: `repo`, `upstream`, `mode`, `tip` (the | |
| 82 | upstream's tip at birth) and `chain` (the ancestors' hops, copied from the | |
| 83 | parent's own `parent.json`, so a grandchild reads the whole chain offline). | |
| 84 | The spawner chooses `followMode` in the spawn input: `tell-me` (default) or | |
| 85 | `take-updates`; it is part of the plan. `/:owner/forge/following` on the | |
| 86 | child shows the chain, root first, one line per hop with its mode and how | |
| 87 | many commits of its own it had when the next hop was born (the last hop is | |
| 88 | counted now). Behind-counts are not shown: they need a fetch of the parent, | |
| 89 | which spawn does not do; that waits on cross-forge following (#1052), which | |
| 90 | is also what will make `mode` act. Until a follower acts on it, `tell-me` | |
| 91 | and `take-updates` are the owner's recorded choice, shown on the chain, not | |
| 92 | behaviour. | |
| 93 | ||
| 94 | Every door reads and cuts the chain the same way: the page | |
| 95 | (`GET /:owner/forge/following`), `GET /api/v1/repos/:owner/forge/following` | |
| 96 | (`chain`, `severedAt`), `POST …/following/sever` (rw token, owner only), the | |
| 97 | MCP `following` and `sever` tools, and the terminal's **Chain** command. | |
| 98 | Sever's one implementation is `severForge` in `src/actions/follow.ts`. | |
| 99 | ||
| 100 | A child's heartbeat adds `forgeTip` and `severedAt` when it carries a fork; | |
| 101 | `/family` on the parent shows `code at <tip>` or `follows nothing since …`. | |
| 102 | ||
| 103 | **Sever** (`POST /:owner/forge/following/sever`, owner only, same-origin, | |
| 104 | type `sever`) writes `severedAt` into `forgeCode` and nothing else: the | |
| 105 | fork, its history and the record of where it came from stay, and nothing | |
| 106 | leaves. The page and the parent's tree then say it follows nothing since | |
| 107 | the date. |
src/actions/follow.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * What a press on a fork's following does, once, for every door (page, API, | |
| 3 | * MCP, terminal): take the parent's updates and, when both sides changed the | |
| 4 | * same files, tell the crew; and sever a forge's link to the forge its code | |
| 5 | * came from. The doors only word the answer. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { DIRECTION_NOTE } from "../crew/direct.ts"; | |
| 10 | import { convergeFork, type Converge } from "../crew/converge.ts"; | |
| 11 | import { canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 14 | import { chainOfRepo, severChain } from "../spawn/chain.ts"; | |
| 15 | import { requireUser } from "./access.ts"; | |
| 16 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; | |
| 17 | ||
| 18 | export type TakeOrTell = Result<ForkSync & { merged: boolean }, TakeUpdatesError | Conflict>; | |
| 19 | ||
| 20 | /** A conflict taken, with what the crew did about it. */ | |
| 21 | export interface Conflict { | |
| 22 | readonly kind: "conflict"; | |
| 23 | readonly files: readonly string[]; | |
| 24 | readonly converge: Converge; | |
| 25 | } | |
| 26 | ||
| 27 | /** Take updates; a conflict opens (or finds) the converge issue naming its files. */ | |
| 28 | export async function takeOrTell( | |
| 29 | app: App, | |
| 30 | actor: User | undefined, | |
| 31 | fork: Repo, | |
| 32 | ): Promise<TakeOrTell> { | |
| 33 | const took = await takeUpdates(app, actor, fork); | |
| 34 | if (took.ok || typeof took.error === "string" || took.error.kind !== "conflict") return took; | |
| 35 | const parent = forkParentFor(app.db, fork, actor); | |
| 36 | if (parent === undefined) return took; | |
| 37 | const converge = await convergeFork(app, fork, parent, actor); | |
| 38 | return err({ kind: "conflict", files: took.error.files, converge }); | |
| 39 | } | |
| 40 | ||
| 41 | export type SeverError = "unauthorized" | "forbidden" | "not_following"; | |
| 42 | ||
| 43 | /** Cut a forge's link to its parent's code: the repository's owner only. Nothing is deleted. */ | |
| 44 | export async function severForge( | |
| 45 | app: App, | |
| 46 | actor: User | undefined, | |
| 47 | repo: Repo, | |
| 48 | ): Promise<Result<string, SeverError>> { | |
| 49 | const who = requireUser(actor); | |
| 50 | if (!who.ok) return err("unauthorized"); | |
| 51 | if ((await chainOfRepo(app, repo)) === undefined) return err("not_following"); | |
| 52 | if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden"); | |
| 53 | const cut = await severChain(app); | |
| 54 | if (!cut.ok) return err("not_following"); | |
| 55 | app.bus.append({ | |
| 56 | kind: "forge.severed", | |
Minor: nothing listens for |
||
| 57 | actorId: who.value.id, | |
| 58 | payload: { repo: `${repo.ownerName}/${repo.name}`, at: cut.value }, | |
| 59 | }); | |
| 60 | return ok(cut.value); | |
| 61 | } | |
| 62 | ||
| 63 | /** What the crew did about a conflict, in a sentence; `danger` when nothing is in hand. */ | |
| 64 | export function convergeWords( | |
| 65 | outcome: Converge, | |
| 66 | parentName: string, | |
| 67 | ): { tone?: "danger"; message: string } { | |
| 68 | switch (outcome.kind) { | |
| 69 | case "queued": | |
| 70 | return { | |
| 71 | message: `${outcome.agent.user.username} is converging with ${parentName} on #${outcome.issue.number}; the proposal comes back as a pull.`, | |
| 72 | }; | |
| 73 | case "working": | |
| 74 | return { | |
| 75 | message: `The crew is already converging on #${outcome.issue.number}.`, | |
| 76 | }; | |
| 77 | case "proposed": | |
| 78 | return { | |
| 79 | message: `The crew's proposal is already open: pull #${outcome.pull.number}.`, | |
| 80 | }; | |
| 81 | case "parked": | |
| 82 | return { | |
| 83 | tone: "danger", | |
| 84 | message: `The crew tried its limit on #${outcome.issue.number}; say there what to do.`, | |
| 85 | }; | |
| 86 | case "nothing": | |
| 87 | return { | |
| 88 | message: | |
| 89 | outcome.relation?.state === "diverged" | |
| 90 | ? "They merge cleanly; Take updates does it without the crew." | |
| 91 | : "Nothing to converge.", | |
| 92 | }; | |
| 93 | case "no_crew": | |
| 94 | return { | |
| 95 | tone: "danger", | |
| 96 | message: | |
| 97 | "Nobody is on the crew here; add someone under Settings → Crew.", | |
| 98 | }; | |
| 99 | case "nobody": | |
| 100 | return { | |
| 101 | tone: "danger", | |
| 102 | message: "Nobody can open the converge issue here.", | |
| 103 | }; | |
| 104 | case "forbidden": | |
| 105 | return { tone: "danger", message: DIRECTION_NOTE }; | |
| 106 | case "ceiling": | |
| 107 | return { | |
| 108 | tone: "danger", | |
| 109 | message: "The crew is at today's ceiling; it converges tomorrow.", | |
| 110 | }; | |
| 111 | } | |
| 112 | } |
src/actions/spawn.ts +10 −5
| @@ -53,6 +53,7 @@ import { loadOrCreateHostKey } from "../ssh/keys.ts"; | ||
| 53 | 53 | import { claimLink, hasAdmin, mintClaim, spawnedOrg } from "../spawn/claim.ts"; |
| 54 | 54 | import { childPublicUrl, type Placement } from "../spawn/placement.ts"; |
| 55 | 55 | import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts"; |
| 56 | import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts"; | |
| 56 | 57 | import { requireUser } from "./access.ts"; |
| 57 | 58 | |
| 58 | 59 | export interface Spawned { |
| @@ -364,7 +365,7 @@ async function copyInto( | ||
| 364 | 365 | // A cross-forge fork: the history is copied by git, and the upstream |
| 365 | 366 | // is recorded in parent.json for the child to pull from. Skipped when |
| 366 | 367 | // the forge does not name its code repository (`forgeRepo`). |
| 367 | const forgeCode = await forkForgeCode(app, child, org.username); | |
| 368 | const forgeCode = await forkForgeCode(app, child, org.username, plan.followMode ?? DEFAULT_MODE); | |
| 368 | 369 | if (!forgeCode.ok) return err(forgeCode.error); |
| 369 | 370 | |
| 370 | 371 | for (const b of plan.briefs) { |
| @@ -441,7 +442,7 @@ async function copyInto( | ||
| 441 | 442 | // the child can follow its parent's updates. |
| 442 | 443 | ...(forgeCode.value === undefined |
| 443 | 444 | ? {} |
| 444 | : { forgeCode: { repo: `${plan.org}/forge`, upstream: forgeCode.value } }), | |
| 445 | : { forgeCode: { repo: `${plan.org}/forge`, ...forgeCode.value } }), | |
| 445 | 446 | // Only the hash: the parent alone holds what it answers to. |
| 446 | 447 | ...(ledger.statusToken === undefined ? {} : { statusHash: sha256(ledger.statusToken) }), |
| 447 | 448 | // The key the child signs its heartbeats with; the parent holds the same. |
| @@ -527,8 +528,11 @@ async function forkForgeCode( | ||
| 527 | 528 | app: App, |
| 528 | 529 | child: App, |
| 529 | 530 | orgName: string, |
| 530 | ): Promise<Result<string | undefined, string>> { | |
| 531 | const ref = app.config.forgeRepo; | |
| 531 | mode: FollowMode, | |
| 532 | ): Promise<Result<{ upstream: string; mode: FollowMode; tip: string; chain: readonly Hop[] } | undefined, string>> { | |
| 533 | // A spawned forge names no `forgeRepo` of its own: its code is the fork | |
| 534 | // it was born with, so a grandchild forks that. | |
| 535 | const ref = app.config.forgeRepo ?? (await forgeLinkOf(app))?.repo; | |
| 532 | 536 | if (ref === undefined) return ok(undefined); |
| 533 | 537 | const parts = ref.split("/"); |
| 534 | 538 | if (parts.length !== 2 || !parts[0] || !parts[1]) return ok(undefined); |
| @@ -559,7 +563,8 @@ async function forkForgeCode( | ||
| 559 | 563 | { cwd: repoDir(child.reposRoot, orgName, name), timeoutMs: 600_000 }, |
| 560 | 564 | ); |
| 561 | 565 | if (!fetched.ok) return err(`could not copy the forge's code: ${fetched.error.stderr}`); |
| 562 | return ok(`${o}/${n}`); | |
| 566 | const born = await chainForChild(app, `${o}/${n}`); | |
| 567 | return ok({ upstream: `${o}/${n}`, mode, tip: born?.tip ?? "", chain: born?.chain ?? [] }); | |
| 563 | 568 | } |
| 564 | 569 | |
| 565 | 570 | /** |
src/http/api.ts +30 −4
| @@ -58,7 +58,9 @@ import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; | ||
| 58 | 58 | import { reissueDoor, spawnDoor } from "../spawn/door.ts"; |
| 59 | 59 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 60 | 60 | import { proposeUpstream } from "../actions/upstream.ts"; |
| 61 | import { relationOf, takeUpdates } from "../actions/forks.ts"; | |
| 61 | import { severForge, takeOrTell } from "../actions/follow.ts"; | |
| 62 | import { relationOf } from "../actions/forks.ts"; | |
| 63 | import { chainOfRepo } from "../spawn/chain.ts"; | |
| 62 | 64 | import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts"; |
| 63 | 65 | import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts"; |
| 64 | 66 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| @@ -385,7 +387,7 @@ import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; | ||
| 385 | 387 | |
| 386 | 388 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| 387 | 389 | async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { |
| 388 | const took = await takeUpdates(ctx.app, ctx.auth.user, repo); | |
| 390 | const took = await takeOrTell(ctx.app, ctx.auth.user, repo); | |
| 389 | 391 | if (!took.ok) { |
| 390 | 392 | const e = took.error; |
| 391 | 393 | if (typeof e === "string") |
| @@ -396,8 +398,10 @@ async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 396 | 398 | return json( |
| 397 | 399 | { |
| 398 | 400 | error: "conflict", |
| 399 | message: "both sides changed these files; the crew can converge", | |
| 401 | message: "both sides changed these files; the crew was told", | |
| 400 | 402 | files: e.files, |
| 403 | converge: "converge" in e ? e.converge.kind : null, | |
| 404 | issue: "converge" in e && "issue" in e.converge ? e.converge.issue.number : null, | |
| 401 | 405 | }, |
| 402 | 406 | 409, |
| 403 | 407 | ); |
| @@ -2035,13 +2039,18 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2035 | 2039 | const repo = loadRepo(ctx); |
| 2036 | 2040 | if (!repo) return notFound(); |
| 2037 | 2041 | const relation = await relationOf(ctx.app, ctx.auth.user, repo); |
| 2042 | const chain = await chainOfRepo(ctx.app, repo); | |
| 2038 | 2043 | if (relation === undefined) |
| 2039 | return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 2044 | return chain === undefined | |
| 2045 | ? apiError("not_a_fork", 404, "this repository follows nothing") | |
| 2046 | : json({ chain: chain.lines, severedAt: chain.severedAt }); | |
| 2040 | 2047 | const converge = |
| 2041 | 2048 | relation.state === "diverged" && !relation.clean |
| 2042 | 2049 | ? convergeState(ctx.app, repo, relation.parent) |
| 2043 | 2050 | : undefined; |
| 2044 | 2051 | return json({ |
| 2052 | chain: chain?.lines ?? null, | |
| 2053 | severedAt: chain?.severedAt ?? null, | |
| 2045 | 2054 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, |
| 2046 | 2055 | parentBranch: relation.parent.defaultBranch, |
| 2047 | 2056 | // A parent whose origin was disconnected: the counts below are against |
| @@ -2089,6 +2098,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2089 | 2098 | }, |
| 2090 | 2099 | ); |
| 2091 | 2100 | |
| 2101 | router.add( | |
| 2102 | "POST", | |
| 2103 | "/api/v1/repos/:owner/:name/following/sever", | |
| 2104 | async (ctx) => { | |
| 2105 | const denied = requireScope(ctx, "rw"); | |
| 2106 | if (denied) return denied; | |
| 2107 | const repo = loadRepo(ctx); | |
| 2108 | if (!repo) return notFound(); | |
| 2109 | const cut = await severForge(ctx.app, ctx.auth.user, repo); | |
| 2110 | if (!cut.ok) | |
| 2111 | return cut.error === "not_following" | |
| 2112 | ? apiError("not_following", 404, "this repository follows nothing across forges") | |
| 2113 | : respondAccess(cut.error, 403); | |
| 2114 | return json({ severedAt: cut.value }); | |
| 2115 | }, | |
| 2116 | ); | |
| 2117 | ||
| 2092 | 2118 | router.add( |
| 2093 | 2119 | "POST", |
| 2094 | 2120 | "/api/v1/repos/:owner/:name/following/propose", |
src/http/mcp.ts +41 −5
| @@ -27,7 +27,9 @@ import { changeSignup, readSignup } from "../actions/signup.ts"; | ||
| 27 | 27 | import { SIGNUP_MODES } from "../settings/signup.ts"; |
| 28 | 28 | import { QUOTA_KEYS } from "../settings/quotas.ts"; |
| 29 | 29 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 30 | import { relationOf, takeUpdates } from "../actions/forks.ts"; | |
| 30 | import { convergeWords, severForge, takeOrTell } from "../actions/follow.ts"; | |
| 31 | import { relationOf } from "../actions/forks.ts"; | |
| 32 | import { chainOfRepo } from "../spawn/chain.ts"; | |
| 31 | 33 | import { watchMilestone } from "../actions/watches.ts"; |
| 32 | 34 | import { |
| 33 | 35 | addCommentThrough, |
| @@ -1006,13 +1008,18 @@ const TOOLS: readonly Tool[] = [ | ||
| 1006 | 1008 | const repo = repoArg(ctx, user, args); |
| 1007 | 1009 | if (isAnswer(repo)) return repo; |
| 1008 | 1010 | const relation = await relationOf(ctx.app, user, repo); |
| 1011 | const chain = await chainOfRepo(ctx.app, repo); | |
| 1009 | 1012 | if (relation === undefined) |
| 1010 | return bad("not_a_fork", "this repository follows nothing"); | |
| 1013 | return chain === undefined | |
| 1014 | ? bad("not_a_fork", "this repository follows nothing") | |
| 1015 | : good({ chain: chain.lines, severedAt: chain.severedAt }); | |
| 1011 | 1016 | const converge = |
| 1012 | 1017 | relation.state === "diverged" && !relation.clean |
| 1013 | 1018 | ? convergeState(ctx.app, repo, relation.parent) |
| 1014 | 1019 | : undefined; |
| 1015 | 1020 | return good({ |
| 1021 | chain: chain?.lines ?? null, | |
| 1022 | severedAt: chain?.severedAt ?? null, | |
| 1016 | 1023 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, |
| 1017 | 1024 | state: relation.state, |
| 1018 | 1025 | behind: relation.behind, |
| @@ -1055,6 +1062,12 @@ const TOOLS: readonly Tool[] = [ | ||
| 1055 | 1062 | description: |
| 1056 | 1063 | "One line (at most 200 characters) on a fresh app to start in the child beside the copied ones: it gets a README and a small app that runs.", |
| 1057 | 1064 | }, |
| 1065 | followMode: { | |
MCP can choose the mode, but the page and the terminal can't. Give those two spawn doors the same choice. |
||
| 1066 | type: "string", | |
| 1067 | enum: ["tell-me", "take-updates"], | |
| 1068 | description: | |
| 1069 | "How the child follows this forge's code: tell-me (default) says when the parent has moved; take-updates takes them.", | |
| 1070 | }, | |
| 1058 | 1071 | keepAccess: { |
| 1059 | 1072 | type: "boolean", |
| 1060 | 1073 | description: |
| @@ -1136,7 +1149,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1136 | 1149 | { |
| 1137 | 1150 | name: "take_updates", |
| 1138 | 1151 | description: |
| 1139 | "Take the parent's updates into a fork: a fast-forward, or a merge when git merges cleanly. A conflict is refused with its files; use `converge` then.", | |
| 1152 | "Take the parent's updates into a fork: a fast-forward, or a merge when git merges cleanly. A conflict is refused with its files, and the crew is told: the converge issue is opened (or found) and named in the answer.", | |
| 1140 | 1153 | inputSchema: { |
| 1141 | 1154 | type: "object", |
| 1142 | 1155 | properties: { repo: REPO_ARG }, |
| @@ -1146,7 +1159,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1146 | 1159 | async run(ctx, user, args) { |
| 1147 | 1160 | const repo = repoArg(ctx, user, args); |
| 1148 | 1161 | if (isAnswer(repo)) return repo; |
| 1149 | const took = await takeUpdates(ctx.app, user, repo); | |
| 1162 | const took = await takeOrTell(ctx.app, user, repo); | |
| 1150 | 1163 | if (took.ok) |
| 1151 | 1164 | return good({ |
| 1152 | 1165 | says: !took.value.updated |
| @@ -1157,6 +1170,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1157 | 1170 | ...took.value, |
| 1158 | 1171 | }); |
| 1159 | 1172 | const e = took.error; |
| 1173 | const up = forkParentFor(ctx.app.db, repo, user); | |
| 1174 | const parentName = up === undefined ? "the parent" : `${up.ownerName}/${up.name}`; | |
| 1160 | 1175 | if (typeof e === "string") |
| 1161 | 1176 | return bad( |
| 1162 | 1177 | e, |
| @@ -1167,7 +1182,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1167 | 1182 | return e.kind === "conflict" |
| 1168 | 1183 | ? bad( |
| 1169 | 1184 | "conflict", |
| 1170 | `both sides changed ${e.files.join(", ")}; converge with the crew`, | |
| 1185 | `both sides changed ${e.files.join(", ")}. ${"converge" in e ? convergeWords(e.converge, parentName).message : ""}`, | |
| 1171 | 1186 | ) |
| 1172 | 1187 | : bad( |
| 1173 | 1188 | e.kind, |
| @@ -1175,6 +1190,27 @@ const TOOLS: readonly Tool[] = [ | ||
| 1175 | 1190 | ); |
| 1176 | 1191 | }, |
| 1177 | 1192 | }, |
| 1193 | { | |
| 1194 | name: "sever", | |
| 1195 | description: | |
| 1196 | "Cut a spawned forge's link to the forge its code came from (its `forge` repository's owner only). Nothing is deleted or sent; the chain then says it follows nothing since today.", | |
| 1197 | inputSchema: { | |
| 1198 | type: "object", | |
| 1199 | properties: { repo: REPO_ARG }, | |
| 1200 | required: ["repo"], | |
| 1201 | }, | |
| 1202 | writes: true, | |
| 1203 | async run(ctx, user, args) { | |
| 1204 | const repo = repoArg(ctx, user, args); | |
| 1205 | if (isAnswer(repo)) return repo; | |
| 1206 | const cut = await severForge(ctx.app, user, repo); | |
| 1207 | if (cut.ok) return good({ says: `Severed: following nothing since ${cut.value.slice(0, 10)}.`, severedAt: cut.value }); | |
| 1208 | return bad( | |
| 1209 | cut.error, | |
| 1210 | cut.error === "not_following" ? "this repository follows nothing across forges" : "only this forge's owner can sever it", | |
| 1211 | ); | |
| 1212 | }, | |
| 1213 | }, | |
| 1178 | 1214 | { |
| 1179 | 1215 | name: "converge", |
| 1180 | 1216 | description: |
src/http/server.ts +3
| @@ -114,6 +114,7 @@ import { | ||
| 114 | 114 | signinPasskey, |
| 115 | 115 | } from "../web/pages/passkeys.ts"; |
| 116 | 116 | import { confirmPage, searchPage } from "../web/pages/search.ts"; |
| 117 | import { chainPage, severSubmit } from "../web/pages/chain.ts"; | |
| 117 | 118 | import { followingSubmit } from "../web/pages/following.ts"; |
| 118 | 119 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 119 | 120 | import { activityAtom } from "../web/feeds.ts"; |
| @@ -1198,6 +1199,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1198 | 1199 | router.add("POST", "/:owner/:name/make-it-yours", makeItYoursSubmit); |
| 1199 | 1200 | // A fork takes what it follows: git's part by itself, the rest by the crew. |
| 1200 | 1201 | router.add("POST", "/:owner/:name/following", followingSubmit); |
| 1202 | router.add("GET", "/:owner/:name/following", chainPage); | |
| 1203 | router.add("POST", "/:owner/:name/following/sever", severSubmit); | |
| 1201 | 1204 | // A mirror pulls on demand; a pin says "keep this on my machine". Both are |
| 1202 | 1205 | // ordinary repo-scoped writes, not account-level ones. |
| 1203 | 1206 | router.add("POST", "/:owner/:name/sync", repoSyncSubmit); |
src/spawn/chain.ts added +228
| @@ -0,0 +1,228 @@ | ||
| 1 | /** | |
| 2 | * The chain of forges a spawned forge's own code descends from. Each child | |
| 3 | * carries `<org>/forge`, a fork of the forge it came from; `parent.json` | |
| 4 | * keeps where, how the owner chose to follow (`tell-me` or `take-updates`), | |
| 5 | * the tip it was born at, and the hops above it, copied from the parent's | |
| 6 | * own `parent.json` at spawn. So a grandchild reads its whole chain to the | |
| 7 | * root without asking anyone, and the numbers are honest: each is as of | |
| 8 | * the day that hop was made, except the last, which is counted here. | |
| 9 | * | |
| 10 | * A forge is sovereign: severing writes `severedAt` and nothing else. The | |
| 11 | * fork, its history and the record of where it came from all stay. | |
| 12 | */ | |
| 13 | ||
| 14 | import { join } from "node:path"; | |
| 15 | import { rename } from "node:fs/promises"; | |
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { runGit } from "../git/env.ts"; | |
| 18 | import { repoDir } from "../git/repos.ts"; | |
| 19 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 20 | ||
| 21 | export const FOLLOW_MODES = ["tell-me", "take-updates"] as const; | |
| 22 | export type FollowMode = (typeof FOLLOW_MODES)[number]; | |
| 23 | export const DEFAULT_MODE: FollowMode = "tell-me"; | |
| 24 | ||
| 25 | /** One forge in the chain, as recorded when the hop below it was made. */ | |
| 26 | export interface Hop { | |
| 27 | /** `owner/name` of its forge repository on its own forge. */ | |
| 28 | readonly repo: string; | |
| 29 | /** Its forge's address; "" when it did not say. */ | |
| 30 | readonly forge: string; | |
| 31 | /** How it follows the hop above it; null for the root. */ | |
| 32 | readonly mode: FollowMode | null; | |
| 33 | /** Its default branch's tip when the next hop was born. */ | |
| 34 | readonly tip: string; | |
| 35 | /** Commits it had of its own then; null for the root or when unknown. */ | |
| 36 | readonly ahead: number | null; | |
| 37 | } | |
| 38 | ||
| 39 | /** The `forgeCode` record in `parent.json`. */ | |
| 40 | export interface ForgeLink { | |
| 41 | readonly repo: string; | |
| 42 | readonly upstream: string; | |
| 43 | readonly mode: FollowMode; | |
| 44 | /** The upstream's tip at birth. */ | |
| 45 | readonly tip: string; | |
| 46 | /** Root first, ending with the parent. */ | |
| 47 | readonly chain: readonly Hop[]; | |
| 48 | readonly severedAt?: string; | |
| 49 | } | |
| 50 | ||
| 51 | const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; | |
| 52 | const TIP = /^[0-9a-f]{40,64}$/; | |
| 53 | ||
| 54 | const isMode = (v: unknown): v is FollowMode => | |
| 55 | typeof v === "string" && (FOLLOW_MODES as readonly string[]).includes(v); | |
| 56 | ||
| 57 | /** The origin of an http(s) address, or "" when it is not one; parsed once, here, so nothing downstream can throw on it. */ | |
| 58 | export function originOf(v: unknown): string { | |
| 59 | if (typeof v !== "string" || v.length > 200) return ""; | |
| 60 | const u = URL.parse(v); | |
| 61 | return u !== null && (u.protocol === "http:" || u.protocol === "https:") ? u.origin : ""; | |
| 62 | } | |
| 63 | ||
| 64 | function readHop(v: unknown): Hop | undefined { | |
| 65 | if (typeof v !== "object" || v === null) return undefined; | |
| 66 | const h = v as Record<string, unknown>; | |
| 67 | if (typeof h["repo"] !== "string" || !REPO.test(h["repo"])) return undefined; | |
| 68 | if (typeof h["tip"] !== "string" || !TIP.test(h["tip"])) return undefined; | |
| 69 | const forge = originOf(h["forge"]); | |
| 70 | const ahead = | |
| 71 | typeof h["ahead"] === "number" && Number.isSafeInteger(h["ahead"]) && h["ahead"] >= 0 ? h["ahead"] : null; | |
| 72 | return { repo: h["repo"], forge, mode: isMode(h["mode"]) ? h["mode"] : null, tip: h["tip"], ahead }; | |
| 73 | } | |
| 74 | ||
| 75 | /** Everything in `parent.json`, untouched, so a rewrite keeps what it does not know. */ | |
| 76 | async function readDoc(app: App): Promise<Record<string, unknown> | undefined> { | |
| 77 | try { | |
| 78 | const doc = await Bun.file(join(app.config.dataDir, "parent.json")).json(); | |
| 79 | return typeof doc === "object" && doc !== null && !Array.isArray(doc) ? (doc as Record<string, unknown>) : undefined; | |
| 80 | } catch { | |
| 81 | return undefined; | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /** This forge's link to the forge its code came from, when it has one. */ | |
| 86 | export async function forgeLinkOf(app: App): Promise<ForgeLink | undefined> { | |
| 87 | const doc = await readDoc(app); | |
| 88 | const f = doc?.["forgeCode"]; | |
| 89 | if (typeof f !== "object" || f === null) return undefined; | |
| 90 | const l = f as Record<string, unknown>; | |
| 91 | if (typeof l["repo"] !== "string" || !REPO.test(l["repo"])) return undefined; | |
| 92 | if (typeof l["upstream"] !== "string" || !REPO.test(l["upstream"])) return undefined; | |
| 93 | const chain = Array.isArray(l["chain"]) | |
| 94 | ? l["chain"].map(readHop).filter((h): h is Hop => h !== undefined).slice(0, 16) | |
| 95 | : []; | |
| 96 | const severedAt = | |
| 97 | typeof l["severedAt"] === "string" && !Number.isNaN(Date.parse(l["severedAt"])) ? l["severedAt"] : undefined; | |
| 98 | return { | |
| 99 | repo: l["repo"], | |
| 100 | upstream: l["upstream"], | |
| 101 | mode: isMode(l["mode"]) ? l["mode"] : DEFAULT_MODE, | |
| 102 | tip: typeof l["tip"] === "string" && TIP.test(l["tip"]) ? l["tip"] : "", | |
| 103 | chain, | |
| 104 | ...(severedAt === undefined ? {} : { severedAt }), | |
| 105 | }; | |
| 106 | } | |
| 107 | ||
| 108 | async function tipOf(app: App, repo: string, base?: string): Promise<{ tip: string; ahead: number | null } | undefined> { | |
| 109 | const [o, n] = repo.split("/") as [string, string]; | |
| 110 | const dir = repoDir(app.reposRoot, o, n); | |
| 111 | const head = await runGit(["rev-parse", "--verify", "-q", "HEAD"], { cwd: dir }); | |
| 112 | if (!head.ok) return undefined; | |
| 113 | const tip = head.value.trim(); | |
| 114 | if (base === undefined || base === "") return { tip, ahead: null }; | |
| 115 | const n2 = await runGit(["rev-list", "--count", `${base}..${tip}`], { cwd: dir }); | |
| 116 | const count = n2.ok ? Number(n2.value.trim()) : NaN; | |
| 117 | return { tip, ahead: Number.isSafeInteger(count) ? count : null }; | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * The chain a child spawned from this forge is born with: this forge's own | |
| 122 | * ancestors, then this forge. `repo` is this forge's code repository. | |
| 123 | */ | |
| 124 | export async function chainForChild( | |
| 125 | app: App, | |
| 126 | repo: string, | |
| 127 | ): Promise<{ chain: Hop[]; tip: string } | undefined> { | |
| 128 | const link = await forgeLinkOf(app); | |
| 129 | const doc = await readDoc(app); | |
| 130 | const here = await tipOf(app, repo, link?.tip); | |
| 131 | if (here === undefined) return undefined; | |
| 132 | const url = app.config.url ?? (typeof doc?.["url"] === "string" ? doc["url"] : ""); | |
| 133 | const me: Hop = { | |
| 134 | repo, | |
| 135 | forge: originOf(url), | |
| 136 | mode: link === undefined || link.severedAt !== undefined ? null : link.mode, | |
| 137 | tip: here.tip, | |
| 138 | ahead: here.ahead, | |
| 139 | }; | |
| 140 | return { chain: [...(link?.chain ?? []), me], tip: here.tip }; | |
| 141 | } | |
| 142 | ||
| 143 | export interface ChainLine { | |
| 144 | readonly repo: string; | |
| 145 | /** Its forge's host, "" when unsaid. */ | |
| 146 | readonly host: string; | |
| 147 | /** How this hop follows the one above it; null for the root. */ | |
| 148 | readonly mode: FollowMode | null; | |
| 149 | readonly ahead: number | null; | |
| 150 | readonly here: boolean; | |
| 151 | readonly severedAt: string | null; | |
| 152 | } | |
| 153 | ||
| 154 | export interface ChainView { | |
| 155 | readonly lines: readonly ChainLine[]; | |
| 156 | readonly severedAt: string | null; | |
| 157 | } | |
| 158 | ||
| 159 | /** The chain to the root, root first, this forge last; undefined when this forge carries no fork. */ | |
| 160 | export async function chainView(app: App): Promise<ChainView | undefined> { | |
| 161 | const link = await forgeLinkOf(app); | |
| 162 | if (link === undefined) return undefined; | |
| 163 | const own = await tipOf(app, link.repo, link.tip); | |
| 164 | const lines: ChainLine[] = link.chain.map((h) => ({ | |
| 165 | repo: h.repo, | |
| 166 | host: h.forge === "" ? "" : new URL(h.forge).host, | |
| 167 | mode: h.mode, | |
| 168 | ahead: h.ahead, | |
| 169 | here: false, | |
| 170 | severedAt: null, | |
| 171 | })); | |
| 172 | lines.push({ | |
| 173 | repo: link.repo, | |
| 174 | host: originOf(app.config.url) === "" ? "" : new URL(originOf(app.config.url)).host, | |
| 175 | mode: link.severedAt === undefined ? link.mode : null, | |
| 176 | ahead: own?.ahead ?? null, | |
| 177 | here: true, | |
| 178 | severedAt: link.severedAt ?? null, | |
| 179 | }); | |
| 180 | return { lines, severedAt: link.severedAt ?? null }; | |
| 181 | } | |
| 182 | ||
| 183 | /** The tip of this forge's own code, for the heartbeat; null when there is none. */ | |
| 184 | export async function forgeTipOf(app: App): Promise<string | null> { | |
| 185 | const link = await forgeLinkOf(app); | |
| 186 | if (link === undefined) return null; | |
| 187 | return (await tipOf(app, link.repo))?.tip ?? null; | |
| 188 | } | |
| 189 | ||
| 190 | /** | |
| 191 | * Cut the upstream link. Only the record changes: the fork, its history and | |
| 192 | * the note of where it came from stay. Idempotent; the first date stands. | |
| 193 | */ | |
| 194 | export async function severChain(app: App, now = new Date()): Promise<Result<string, "not_following">> { | |
| 195 | const doc = await readDoc(app); | |
| 196 | const link = await forgeLinkOf(app); | |
| 197 | if (doc === undefined || link === undefined) return err("not_following"); | |
| 198 | if (link.severedAt !== undefined) return ok(link.severedAt); | |
| 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); | |
| 206 | return ok(at); | |
| 207 | } | |
| 208 | ||
| 209 | /** The chain, when `repo` is this forge's own code repository; undefined for any other. */ | |
| 210 | export async function chainOfRepo(app: App, repo: { ownerName: string; name: string }): Promise<ChainView | undefined> { | |
| 211 | const link = await forgeLinkOf(app); | |
| 212 | if (link === undefined || link.repo !== `${repo.ownerName}/${repo.name}`) return undefined; | |
| 213 | return chainView(app); | |
| 214 | } | |
| 215 | ||
| 216 | /** The chain as plain lines, for a terminal: one per hop, root first. */ | |
| 217 | export function chainLines(view: ChainView): [string, string][] { | |
| 218 | return view.lines.map((l) => { | |
| 219 | const how = | |
| 220 | l.severedAt !== null | |
| 221 | ? `follows nothing since ${l.severedAt.slice(0, 10)}` | |
| 222 | : l.mode === null | |
| 223 | ? "follows nothing" | |
| 224 | : l.mode; | |
| 225 | const bits = [how, l.ahead === null ? "" : `${l.ahead} of its own`, l.host].filter((b) => b !== ""); | |
| 226 | return [l.here ? `${l.repo} (here)` : l.repo, bits.join(" · ")]; | |
| 227 | }); | |
| 228 | } |
src/spawn/door.ts +1
| @@ -234,6 +234,7 @@ export async function spawnDoor( | ||
| 234 | 234 | keepAccess: anon ? false : raw["keepAccess"], |
| 235 | 235 | budgetUsd: raw["budgetUsd"], |
| 236 | 236 | fresh: raw["fresh"], |
| 237 | followMode: raw["followMode"], | |
|
||
| 237 | 238 | ...(anon ? {} : { for: raw["for"] }), |
| 238 | 239 | }; |
| 239 | 240 |
src/spawn/family.ts +1
| @@ -78,6 +78,7 @@ export function familyLines(view: FamilyView): [string, string][] { | ||
| 78 | 78 | : [ |
| 79 | 79 | c.state, |
| 80 | 80 | c.version === null ? "" : `version ${c.version}`, |
| 81 | c.severedAt !== null ? `follows nothing since ${c.severedAt.slice(0, 10)}` : c.forgeTip === null ? "" : `code at ${c.forgeTip.slice(0, 7)}`, | |
| 81 | 82 | c.openProposals === null ? "" : `${c.openProposals} open`, |
| 82 | 83 | ].filter((b) => b !== ""); |
| 83 | 84 | out.push([`child ${c.name}`, bits.join(" · ")]); |
src/spawn/heartbeat.ts +36 −3
| @@ -19,6 +19,7 @@ import { join } from "node:path"; | ||
| 19 | 19 | import type { App } from "../app.ts"; |
| 20 | 20 | import { beatKeyOf, listChildren } from "../actions/spawn.ts"; |
| 21 | 21 | import releases from "../core/releases.json" with { type: "json" }; |
| 22 | import { forgeLinkOf, forgeTipOf } from "./chain.ts"; | |
| 22 | 23 | import { running } from "../kernel/version.ts"; |
| 23 | 24 | |
| 24 | 25 | export const SIGNATURE_HEADER = "x-forge-signature"; |
| @@ -41,6 +42,10 @@ export interface Beat { | ||
| 41 | 42 | readonly version: string; |
| 42 | 43 | readonly openProposals: number; |
| 43 | 44 | readonly children: number; |
| 45 | /** The tip of the child's own forge code; absent from an older child, null when it carries none. */ | |
| 46 | readonly forgeTip?: string | null; | |
| 47 | /** When the child cut its upstream link; null while it follows. */ | |
| 48 | readonly severedAt?: string | null; | |
| 44 | 49 | } |
| 45 | 50 | |
| 46 | 51 | export function sign(key: string, body: string): string { |
| @@ -66,14 +71,31 @@ export function parseBeat(raw: string): Beat | undefined { | ||
| 66 | 71 | } |
| 67 | 72 | if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return undefined; |
| 68 | 73 | const d = doc as Record<string, unknown>; |
| 69 | const keys = Object.keys(d).sort().join(","); | |
| 74 | const keys = Object.keys(d) | |
| 75 | .filter((k) => k !== "forgeTip" && k !== "severedAt") | |
| 76 | .sort() | |
| 77 | .join(","); | |
| 70 | 78 | if (keys !== "at,children,name,openProposals,version") return undefined; |
| 71 | const { name, at, version, openProposals, children } = d; | |
| 79 | const { name, at, version, openProposals, children, forgeTip, severedAt } = d; | |
| 80 | const tipOk = forgeTip === undefined || forgeTip === null || (typeof forgeTip === "string" && /^[0-9a-f]{40,64}$/.test(forgeTip)); | |
| 81 | const sevOk = | |
| 82 | severedAt === undefined || | |
| 83 | severedAt === null || | |
| 84 | (typeof severedAt === "string" && severedAt.length <= 40 && !Number.isNaN(Date.parse(severedAt))); | |
| 85 | if (!tipOk || !sevOk) return undefined; | |
| 72 | 86 | if (typeof name !== "string" || !NAME.test(name)) return undefined; |
| 73 | 87 | if (typeof at !== "number" || !Number.isSafeInteger(at) || at <= 0) return undefined; |
| 74 | 88 | if (typeof version !== "string" || !VERSION.test(version)) return undefined; |
| 75 | 89 | if (!count(openProposals) || !count(children)) return undefined; |
| 76 | return { name, at, version, openProposals, children }; | |
| 90 | return { | |
| 91 | name, | |
| 92 | at, | |
| 93 | version, | |
| 94 | openProposals, | |
| 95 | children, | |
| 96 | ...(forgeTip === undefined ? {} : { forgeTip: forgeTip as string | null }), | |
| 97 | ...(severedAt === undefined ? {} : { severedAt: severedAt as string | null }), | |
| 98 | }; | |
| 77 | 99 | } |
| 78 | 100 | |
| 79 | 101 | const beatPath = (app: App, name: string): string => |
| @@ -189,11 +211,16 @@ export async function sendBeat( | ||
| 189 | 211 | const link = await parentLink(app); |
| 190 | 212 | if (link === undefined) return false; |
| 191 | 213 | const own = (await listChildren(app)).length; |
| 214 | const forge = await forgeLinkOf(app); | |
| 192 | 215 | // Strictly later than the last one sent, so a quick restart is not a replay. |
| 193 | 216 | lastAt = Math.max(now, lastAt + 1); |
| 194 | 217 | const body = JSON.stringify({ |
| 195 | 218 | ...standing(app, link.name, lastAt), |
| 196 | 219 | children: Math.min(own, COUNT_MAX), |
| 220 | // Only a forge that carries a fork of its parent's code has these to say. | |
| 221 | ...(forge === undefined | |
| 222 | ? {} | |
| 223 | : { forgeTip: await forgeTipOf(app), severedAt: forge.severedAt ?? null }), | |
| 197 | 224 | }); |
| 198 | 225 | try { |
| 199 | 226 | const res = await fetcher(`${link.upstream}/api/v1/heartbeat`, { |
| @@ -228,6 +255,10 @@ export interface ChildStanding { | ||
| 228 | 255 | readonly behind: number | null; |
| 229 | 256 | readonly openProposals: number | null; |
| 230 | 257 | readonly ownChildren: number | null; |
| 258 | /** The tip of the child's own forge code, as last reported; null when none. */ | |
| 259 | readonly forgeTip: string | null; | |
| 260 | /** When the child cut its link to this forge's code; null while it follows or when unheard. */ | |
| 261 | readonly severedAt: string | null; | |
| 231 | 262 | } |
| 232 | 263 | |
| 233 | 264 | /** How many releases of this forge are newer than `version`; null when it is not one of them. */ |
| @@ -254,6 +285,8 @@ export async function childStandings( | ||
| 254 | 285 | behind: heard === undefined ? null : releasesBehind(heard.beat.version), |
| 255 | 286 | openProposals: heard?.beat.openProposals ?? null, |
| 256 | 287 | ownChildren: heard?.beat.children ?? null, |
| 288 | forgeTip: heard?.beat.forgeTip ?? null, | |
| 289 | severedAt: heard?.beat.severedAt ?? null, | |
| 257 | 290 | }); |
| 258 | 291 | } |
| 259 | 292 | return { items, more: Math.max(0, all.length - MAX_CHILDREN) }; |
src/spawn/plan.ts +14
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | import { DEFAULT_MODE, type FollowMode } from "./chain.ts"; | |
| 11 | 12 | |
| 12 | 13 | export const MAX_APPS = 50; |
| 13 | 14 | export const MAX_BRIEFS = 100; |
| @@ -66,6 +67,8 @@ export interface SpawnInput { | ||
| 66 | 67 | readonly budgetUsd?: unknown; |
| 67 | 68 | /** One line describing a fresh app to start in the child, beside the forked ones; optional. */ |
| 68 | 69 | readonly fresh?: unknown; |
| 70 | /** How the child follows the forge's code: `tell-me` (default) or `take-updates`. */ | |
| 71 | readonly followMode?: unknown; | |
| 69 | 72 | } |
| 70 | 73 | |
| 71 | 74 | /** What the parent has to offer. Extra fields are ignored, never copied. */ |
| @@ -104,6 +107,8 @@ export interface SpawnPlan { | ||
| 104 | 107 | readonly forWhom?: string; |
| 105 | 108 | /** A fresh app, present only when asked for. */ |
| 106 | 109 | readonly fresh?: PlannedFresh; |
| 110 | /** How the child follows its parent's forge code; absent means `tell-me`. */ | |
| 111 | readonly followMode?: FollowMode; | |
| 107 | 112 | } |
| 108 | 113 | |
| 109 | 114 | export interface DryRun { |
| @@ -237,6 +242,14 @@ export function planSpawn( | ||
| 237 | 242 | }; |
| 238 | 243 | } |
| 239 | 244 | |
| 245 | let followMode: FollowMode | undefined; | |
| 246 | if (input.followMode !== undefined && input.followMode !== null && input.followMode !== "") { | |
| 247 | if (input.followMode !== "tell-me" && input.followMode !== "take-updates") | |
| 248 | return refuse("followMode", "must be tell-me or take-updates"); | |
| 249 | // Only a chosen mode is named, so a plan without one hashes as it always did. | |
| 250 | if (input.followMode !== DEFAULT_MODE) followMode = input.followMode; | |
| 251 | } | |
| 252 | ||
| 240 | 253 | // `forWhom` only when said, so a plan without it hashes as it always did. |
| 241 | 254 | return ok({ |
| 242 | 255 | name, |
| @@ -249,6 +262,7 @@ export function planSpawn( | ||
| 249 | 262 | budgetUsd, |
| 250 | 263 | ...(forWhom === "" ? {} : { forWhom }), |
| 251 | 264 | ...(fresh === undefined ? {} : { fresh }), |
| 265 | ...(followMode === undefined ? {} : { followMode }), | |
| 252 | 266 | }); |
| 253 | 267 | } |
| 254 | 268 |
src/tui/forge.ts +10
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | openOnWeb, |
| 30 | 30 | runAsync, |
| 31 | 31 | } from "./screens/common.ts"; |
| 32 | import { chainScreen } from "./screens/chain.ts"; | |
| 32 | 33 | import { canSeeMemory } from "../boards/memory.ts"; |
| 33 | 34 | import { boardsScreen } from "./screens/boards.ts"; |
| 34 | 35 | import { askedScreen } from "./screens/asked.ts"; |
| @@ -362,6 +363,15 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 362 | 363 | ctx.push(familyScreen(ctx, familyLines(view))); |
| 363 | 364 | }), |
| 364 | 365 | }); |
| 366 | commands.push({ | |
| 367 | id: "chain", | |
| 368 | title: "Chain", | |
| 369 | hint: "the forges this forge's code descends from, root first", | |
| 370 | run: () => | |
| 371 | runAsync(ctx, async () => { | |
| 372 | ctx.push(await chainScreen(session.app, session.user, ctx)); | |
| 373 | }), | |
| 374 | }); | |
| 365 | 375 | commands.push({ |
| 366 | 376 | id: "quit", |
| 367 | 377 | title: "Quit", |
src/tui/screens/chain.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * The chain of forges this forge's code descends from, in a card. The gate is | |
| 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. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App as Forge } from "../../app.ts"; | |
| 8 | import { severForge } from "../../actions/follow.ts"; | |
| 9 | import { canAdmin, canRead, getRepo, type Repo } from "../../core/repos.ts"; | |
| 10 | import type { User } from "../../core/users.ts"; | |
| 11 | import { chainLines, chainOfRepo, forgeLinkOf, type ChainView } from "../../spawn/chain.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { card } from "../ui/widgets.ts"; | |
| 15 | import { drawFacts, promptScreen } from "./common.ts"; | |
| 16 | import { familyScreen } from "./family.ts"; | |
| 17 | ||
| 18 | /** The forge repository and its chain, when this viewer may read that repository. */ | |
| 19 | export async function readableChain( | |
| 20 | app: Forge, | |
| 21 | user: User | undefined, | |
| 22 | ): Promise<{ repo: Repo; view: ChainView } | undefined> { | |
| 23 | const link = await forgeLinkOf(app); | |
| 24 | if (link === undefined) return undefined; | |
| 25 | const [owner, name] = link.repo.split("/") as [string, string]; | |
| 26 | const repo = getRepo(app.db, owner, name); | |
| 27 | if (!repo || !canRead(app.db, repo, user)) return undefined; | |
| 28 | const view = await chainOfRepo(app, repo); | |
| 29 | return view === undefined ? undefined : { repo, view }; | |
| 30 | } | |
| 31 | ||
| 32 | export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): Promise<Screen> { | |
| 33 | const found = await readableChain(app, user); | |
| 34 | if (found === undefined) return familyScreen(ctx, [["chain", "this forge follows nothing across forges"]], "Chain"); | |
| 35 | const { repo, view } = found; | |
| 36 | const rows = chainLines(view); | |
| 37 | const owner = user !== undefined && view.severedAt === null && canAdmin(app.db, repo, user); | |
| 38 | if (!owner) return familyScreen(ctx, rows, "Chain"); | |
| 39 | const self: Screen = { | |
| 40 | title: "Chain", | |
| 41 | keys: [binding(["s"], "sever"), binding(["esc"], "close")], | |
| 42 | render(area, s) { | |
| 43 | const c = ctx.theme.c; | |
| 44 | const w = Math.min(78, Math.max(30, area.w - 6)); | |
| 45 | const h = Math.min(area.h, rows.length + 2); | |
| 46 | const r = { x: area.x + ((area.w - w) >> 1), y: area.y + ((area.h - h) >> 1), w, h }; | |
| 47 | const inner = card(s, r, c, { title: "Chain", focused: true }); | |
| 48 | drawFacts(s, { x: inner.x + 1, y: inner.y, w: Math.max(0, inner.w - 1), h: inner.h }, c, rows, c.card); | |
| 49 | }, | |
| 50 | key(e) { | |
| 51 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 52 | else if (e.key === "s") | |
| 53 | ctx.push( | |
| 54 | promptScreen(ctx, self, { | |
| 55 | title: "Sever from the parent forge", | |
| 56 | label: "type sever to confirm", | |
| 57 | onSubmit: (word) => { | |
| 58 | if (word.trim() !== "sever") return "Type sever to confirm."; | |
| 59 | return severForge(app, user, repo).then((cut) => { | |
| 60 | if (!cut.ok) return ctx.toast(`Not severed: ${cut.error}`, "danger"); | |
| 61 | ctx.pop(); | |
| 62 | ctx.toast("Severed: this forge follows nothing now", "success"); | |
| 63 | }); | |
| 64 | }, | |
| 65 | }), | |
| 66 | ); | |
| 67 | return true; | |
| 68 | }, | |
| 69 | }; | |
| 70 | return self; | |
| 71 | } |
src/tui/screens/family.ts +3 −3
| @@ -9,16 +9,16 @@ import { binding } from "../app/keys.ts"; | ||
| 9 | 9 | import { card } from "../ui/widgets.ts"; |
| 10 | 10 | import { drawFacts } from "./common.ts"; |
| 11 | 11 | |
| 12 | export function familyScreen(ctx: Ctx, rows: readonly (readonly [string, string])[]): Screen { | |
| 12 | export function familyScreen(ctx: Ctx, rows: readonly (readonly [string, string])[], title = "Family"): Screen { | |
| 13 | 13 | return { |
| 14 | title: "Family", | |
| 14 | title, | |
| 15 | 15 | keys: [binding(["esc"], "close")], |
| 16 | 16 | render(area, s) { |
| 17 | 17 | const c = ctx.theme.c; |
| 18 | 18 | const w = Math.min(78, Math.max(30, area.w - 6)); |
| 19 | 19 | const h = Math.min(area.h, rows.length + 2); |
| 20 | 20 | const r = { x: area.x + ((area.w - w) >> 1), y: area.y + ((area.h - h) >> 1), w, h }; |
| 21 | const inner = card(s, r, c, { title: "Family", focused: true }); | |
| 21 | const inner = card(s, r, c, { title, focused: true }); | |
| 22 | 22 | drawFacts(s, { x: inner.x + 1, y: inner.y, w: Math.max(0, inner.w - 1), h: inner.h }, c, rows, c.card); |
| 23 | 23 | }, |
| 24 | 24 | key(e) { |
src/tui/screens/newforge.ts +3
| @@ -33,6 +33,7 @@ const SPECS: readonly Spec[] = [ | ||
| 33 | 33 | { key: "modelKey", label: "Model key", hint: "Sealed in the child, never shown again. Optional.", secret: true }, |
| 34 | 34 | { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." }, |
| 35 | 35 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 36 | { key: "followMode", label: "Follows this forge's code", hint: "tell-me (default) or take-updates. Recorded only; nothing acts on it yet." }, | |
| 36 | 37 | ]; |
| 37 | 38 | |
| 38 | 39 | /** The door's "field: reason" refusal, as the field it names and the reason. */ |
| @@ -58,6 +59,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 58 | 59 | name: val("name"), |
| 59 | 60 | apps, |
| 60 | 61 | ...(val("fresh") === "" ? {} : { fresh: val("fresh") }), |
| 62 | followMode: val("followMode") === "" ? "tell-me" : val("followMode"), | |
| 61 | 63 | ...(val("for") === "" ? {} : { for: val("for") }), |
| 62 | 64 | ...(val("direction") === "" ? {} : { direction: val("direction") }), |
| 63 | 65 | ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }), |
| @@ -107,6 +109,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 107 | 109 | const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null; |
| 108 | 110 | plan = [ |
| 109 | 111 | `A forge at ${childUrlOf(f.app, val("name"))}.`, |
| 112 | `Recorded for now, not acted on: it follows this forge's code as ${val("followMode") === "" ? "tell-me" : val("followMode")}.`, | |
| 110 | 113 | "Whoever opens the link becomes its admin.", |
| 111 | 114 | ...(budget === null ? [] : [`Its crew runs on your key, up to $${budget} for the key's whole life.`]), |
| 112 | 115 | "Copied into it:", |
src/web/pages/chain.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * `/:owner/forge/following`: the chain of forges this forge's own code | |
| 3 | * descends from, root first, one line per hop with its follow mode and how | |
| 4 | * far ahead it had gone (`spawn/chain.ts`), and the owner's one way out: | |
| 5 | * sever. A severed forge says so with the date and nothing is deleted. | |
| 6 | */ | |
| 7 | ||
| 8 | import { severForge } from "../../actions/follow.ts"; | |
| 9 | import { canAdmin } from "../../core/repos.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { chainView, forgeLinkOf, type ChainLine } from "../../spawn/chain.ts"; | |
| 12 | import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; | |
| 13 | import { field, readForm } from "../form.ts"; | |
| 14 | import { html, type Html } from "../html.ts"; | |
| 15 | import { flashOrJson, page, redirect } from "../layout.ts"; | |
| 16 | import { btn, card } from "../ui.ts"; | |
| 17 | ||
| 18 | const MODE_WORDS = { "tell-me": "tells when the parent moves", "take-updates": "takes updates" } as const; | |
| 19 | ||
| 20 | function lineRow(l: ChainLine): Html { | |
outdated
|
||
| 21 | const where = l.host === "" ? "" : ` on ${l.host}`; | |
| 22 | const how = | |
| 23 | l.severedAt !== null | |
| 24 | ? `follows nothing since ${l.severedAt.slice(0, 10)}` | |
| 25 | : l.mode === null | |
| 26 | ? l.here | |
| 27 | ? "follows nothing" | |
| 28 | : "the root: follows nothing" | |
| 29 | : MODE_WORDS[l.mode]; | |
| 30 | const ahead = l.ahead === null ? "" : l.ahead === 0 ? " · nothing of its own" : ` · ${l.ahead} of its own`; | |
| 31 | return html`<li> | |
| 32 | <div class="row"> | |
| 33 | <span class="row-main"> | |
| 34 | <span class="row-title" | |
| 35 | >${l.repo}${where}${l.here ? html` <span class="lineage-here">you are here</span>` : null}</span | |
| 36 | > | |
| 37 | <span class="row-meta">${how}${ahead}</span> | |
| 38 | </span> | |
| 39 | </div> | |
| 40 | </li>`; | |
| 41 | } | |
| 42 | ||
| 43 | /** Whether this repository is the forge code a spawned forge carries. */ | |
| 44 | async function carriesChain(ctx: Ctx): Promise<boolean> { | |
| 45 | const repo = loadRepo(ctx); | |
| 46 | const link = await forgeLinkOf(ctx.app); | |
| 47 | return repo !== undefined && link !== undefined && link.repo === `${repo.ownerName}/${repo.name}`; | |
| 48 | } | |
| 49 | ||
| 50 | // GET /:owner/:name/following | |
| 51 | export async function chainPage(ctx: Ctx): Promise<Response> { | |
| 52 | const repo = loadRepo(ctx); | |
| 53 | if (!repo) return notFoundPage(ctx); | |
| 54 | const view = (await carriesChain(ctx)) ? await chainView(ctx.app) : undefined; | |
| 55 | // Any other repository's following is the card on its home. | |
| 56 | if (view === undefined) return redirect(`${repoBase(repo)}#following`); | |
| 57 | const base = repoBase(repo); | |
| 58 | const owner = canAdmin(ctx.app.db, repo, ctx.auth.user); | |
| 59 | return page({ | |
| 60 | ctx, | |
| 61 | title: "Following", | |
| 62 | body: html`<div class="narrow-wide"> | |
| 63 | ${pageHead("Following", { | |
| 64 | sub: "The forges this one's code descends from, root first. Numbers are as of the day each hop was made; the last is counted now.", | |
| 65 | })} | |
| 66 | ${card( | |
| 67 | html`<ul class="list lineage" aria-label="Forges, root first"> | |
| 68 | ${view.lines.map(lineRow)} | |
| 69 | </ul>`, | |
| 70 | { pad: false }, | |
| 71 | )} | |
| 72 | ${view.severedAt !== null | |
| 73 | ? html`<p class="muted" id="severed"> | |
| 74 | This forge follows nothing since ${view.severedAt.slice(0, 10)}. Its code and history are | |
| 75 | all still here. | |
| 76 | </p>` | |
| 77 | : owner | |
| 78 | ? card( | |
| 79 | html`<p> | |
| 80 | Severing cuts this forge's link to the one above it. Nothing is deleted and | |
| 81 | nothing leaves; the fork stays yours, and the tree says it follows nothing | |
| 82 | from today. | |
| 83 | </p> | |
| 84 | <form method="post" action="${base}/following/sever" class="mt"> | |
| 85 | <label class="form-field" | |
| 86 | ><span>Type sever to continue</span> | |
| 87 | <input name="confirm" required autocomplete="off" class="mono" | |
| 88 | /></label> | |
| 89 | <div class="form-actions">${btn("Sever", { variant: "danger", type: "submit" })}</div> | |
| 90 | </form>`, | |
| 91 | { head: html`<h2>Sever</h2>` }, | |
| 92 | ) | |
| 93 | : null} | |
| 94 | </div>`, | |
| 95 | }); | |
| 96 | } | |
| 97 | ||
| 98 | /** POST /:owner/:name/following/sever {confirm: sever}; the owner only. */ | |
| 99 | export async function severSubmit(ctx: Ctx): Promise<Response> { | |
| 100 | const repo = loadRepo(ctx); | |
| 101 | if (!repo) return notFoundPage(ctx); | |
| 102 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 103 | const form = await readForm(ctx, "rw"); | |
| 104 | if (form instanceof Response) return form; | |
| 105 | const to = `${repoBase(repo)}/following`; | |
| 106 | if (field(form, "confirm") !== "sever") | |
| 107 | return flashOrJson(ctx, to, { tone: "danger", message: "Type sever to confirm." }); | |
| 108 | const cut = await severForge(ctx.app, ctx.auth.user, repo); | |
| 109 | if (!cut.ok) | |
| 110 | return flashOrJson(ctx, to, { | |
| 111 | tone: "danger", | |
| 112 | message: cut.error === "forbidden" ? "Only this forge's owner can sever it." : "This repository follows nothing.", | |
| 113 | }); | |
| 114 | return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` }); | |
| 115 | } |
src/web/pages/family.ts +2
| @@ -40,6 +40,8 @@ function standingNode(c: FamilyChild): Node { | ||
| 40 | 40 | meta.push(html`${c.behind === 0 ? "up to date" : `${plural(c.behind, "release")} behind`}`); |
| 41 | 41 | if (c.openProposals !== null) |
| 42 | 42 | meta.push(html`${plural(c.openProposals, "open proposal")}`); |
| 43 | if (c.severedAt !== null) meta.push(html`follows nothing since ${c.severedAt.slice(0, 10)}`); | |
Nothing tests this rendering on |
||
| 44 | else if (c.forgeTip !== null) meta.push(html`code at <span class="mono">${c.forgeTip.slice(0, 7)}</span>`); | |
| 43 | 45 | if (c.ownChildren !== null && c.ownChildren > 0) |
| 44 | 46 | meta.push(html`${plural(c.ownChildren, "forge")} of its own`); |
| 45 | 47 | } |
src/web/pages/following.ts +9 −56
| @@ -10,10 +10,8 @@ | ||
| 10 | 10 | * — it becomes the crew's proposal, or an honest sentence about why not. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { DIRECTION_NOTE } from "../../crew/direct.ts"; | |
| 14 | 13 | import { |
| 15 | 14 | relationOf, |
| 16 | takeUpdates, | |
| 17 | 15 | type ForkRelation, |
| 18 | 16 | } from "../../actions/forks.ts"; |
| 19 | 17 | import { listCrewRoles } from "../../core/agents.ts"; |
| @@ -39,6 +37,7 @@ import { | ||
| 39 | 37 | } from "../../core/parent_releases.ts"; |
| 40 | 38 | import { running } from "../../kernel/version.ts"; |
| 41 | 39 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 40 | import { convergeWords, takeOrTell } from "../../actions/follow.ts"; | |
| 42 | 41 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 43 | 42 | import { field, readForm } from "../form.ts"; |
| 44 | 43 | import { html, raw, when, type Html } from "../html.ts"; |
| @@ -440,7 +439,7 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 440 | 439 | ); |
| 441 | 440 | } |
| 442 | 441 | |
| 443 | const took = await takeUpdates(ctx.app, ctx.auth.user, repo); | |
| 442 | const took = await takeOrTell(ctx.app, ctx.auth.user, repo); | |
| 444 | 443 | if (!took.ok) { |
| 445 | 444 | const e = took.error; |
| 446 | 445 | if (typeof e === "string") |
| @@ -451,12 +450,16 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 451 | 450 | ? "This repository follows nothing." |
| 452 | 451 | : "You need write access to take updates here.", |
| 453 | 452 | }); |
| 453 | // A conflict is a tell for the crew (`actions/follow.ts`). | |
| 454 | if (e.kind === "conflict") { | |
| 455 | const files = `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")})`; | |
| 456 | const words = "converge" in e ? convergeWords(e.converge, parentName) : { message: "" }; | |
outdated
The conflict-to-issue tell only happens here. The API |
||
| 457 | return flashOrJson(ctx, base, { tone: "danger", message: `${files}. ${words.message}` }); | |
| 458 | } | |
| 454 | 459 | return flashOrJson(ctx, base, { |
| 455 | 460 | tone: "danger", |
| 456 | 461 | message: |
| 457 | e.kind === "conflict" | |
| 458 | ? `${e.files.length === 1 ? "One file needs" : `${e.files.length} files need`} a decision (${e.files.join(", ")}); the crew can converge it.` | |
| 459 | : e.kind === "moved" | |
| 462 | e.kind === "moved" | |
| 460 | 463 | ? "Something moved while this was read; try again." |
| 461 | 464 | : `Git could not take the updates: ${e.message}`, |
| 462 | 465 | }); |
| @@ -469,53 +472,3 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 469 | 472 | : `Took ${parentName}'s updates: ${repo.defaultBranch} is at ${took.value.to.slice(0, 7)}.`, |
| 470 | 473 | }); |
| 471 | 474 | } |
| 472 | ||
| 473 | function convergeWords( | |
| 474 | outcome: Converge, | |
| 475 | parentName: string, | |
| 476 | ): { tone?: "danger"; message: string } { | |
| 477 | switch (outcome.kind) { | |
| 478 | case "queued": | |
| 479 | return { | |
| 480 | message: `${outcome.agent.user.username} is converging with ${parentName} on #${outcome.issue.number}; the proposal comes back as a pull.`, | |
| 481 | }; | |
| 482 | case "working": | |
| 483 | return { | |
| 484 | message: `The crew is already converging on #${outcome.issue.number}.`, | |
| 485 | }; | |
| 486 | case "proposed": | |
| 487 | return { | |
| 488 | message: `The crew's proposal is already open: pull #${outcome.pull.number}.`, | |
| 489 | }; | |
| 490 | case "parked": | |
| 491 | return { | |
| 492 | tone: "danger", | |
| 493 | message: `The crew tried its limit on #${outcome.issue.number}; say there what to do.`, | |
| 494 | }; | |
| 495 | case "nothing": | |
| 496 | return { | |
| 497 | message: | |
| 498 | outcome.relation?.state === "diverged" | |
| 499 | ? "They merge cleanly; Take updates does it without the crew." | |
| 500 | : "Nothing to converge.", | |
| 501 | }; | |
| 502 | case "no_crew": | |
| 503 | return { | |
| 504 | tone: "danger", | |
| 505 | message: | |
| 506 | "Nobody is on the crew here; add someone under Settings → Crew.", | |
| 507 | }; | |
| 508 | case "nobody": | |
| 509 | return { | |
| 510 | tone: "danger", | |
| 511 | message: "Nobody can open the converge issue here.", | |
| 512 | }; | |
| 513 | case "forbidden": | |
| 514 | return { tone: "danger", message: DIRECTION_NOTE }; | |
| 515 | case "ceiling": | |
| 516 | return { | |
| 517 | tone: "danger", | |
| 518 | message: "The crew is at today's ceiling; it converges tomorrow.", | |
| 519 | }; | |
| 520 | } | |
| 521 | } |
src/web/pages/new-forge.ts +32
| @@ -34,6 +34,7 @@ interface Values { | ||
| 34 | 34 | readonly apps: readonly string[]; |
| 35 | 35 | readonly crew: readonly string[]; |
| 36 | 36 | readonly fresh: string; |
| 37 | readonly followMode: string; | |
| 37 | 38 | readonly keepAccess: boolean; |
| 38 | 39 | readonly budgetUsd: string; |
| 39 | 40 | readonly modelEndpoint: string; |
| @@ -48,6 +49,7 @@ const EMPTY: Values = { | ||
| 48 | 49 | apps: [], |
| 49 | 50 | crew: [], |
| 50 | 51 | fresh: "", |
| 52 | followMode: "tell-me", | |
| 51 | 53 | keepAccess: false, |
| 52 | 54 | budgetUsd: "", |
| 53 | 55 | modelEndpoint: "", |
| @@ -176,6 +178,34 @@ function formView( | ||
| 176 | 178 | hint: `One line on what it is; the child gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`, |
| 177 | 179 | ...(errors["fresh"] ? { error: errors["fresh"] } : {}), |
| 178 | 180 | })} |
| 181 | <fieldset class="field" ${errors["followMode"] ? html`data-invalid` : null}> | |
| 182 | <legend>How it follows this forge's code (recorded for now; nothing acts on it yet)</legend> | |
| 183 | <label class="check"> | |
| 184 | <input | |
| 185 | type="radio" | |
| 186 | name="followMode" | |
| 187 | value="tell-me" | |
| 188 | ${values.followMode !== "take-updates" ? html`checked` : null} | |
| 189 | /> | |
| 190 | <span | |
| 191 | >Tell me <span class="muted small">is recorded as your choice; nothing tells you yet.</span></span | |
outdated
Nothing tells anyone when this forge changes; copy overpromises. Say it is only recorded for now. |
||
| 192 | > | |
| 193 | </label> | |
| 194 | <label class="check"> | |
| 195 | <input | |
| 196 | type="radio" | |
| 197 | name="followMode" | |
| 198 | value="take-updates" | |
| 199 | ${values.followMode === "take-updates" ? html`checked` : null} | |
| 200 | /> | |
| 201 | <span | |
Nothing takes updates automatically yet (mode is not acted on until #1052). Reword to the truth so the choice is not a false promise. |
||
| 202 | >Take updates <span class="muted small">is recorded as your choice; nothing takes updates automatically yet.</span></span | |
| 203 | > | |
| 204 | </label> | |
| 205 | ${errors["followMode"] | |
| 206 | ? html`<span class="error" id="f-followMode-err">${errors["followMode"]}</span>` | |
| 207 | : null} | |
| 208 | </fieldset> | |
| 179 | 209 | ${crew.length === 0 |
| 180 | 210 | ? null |
| 181 | 211 | : html`<fieldset class="field"> |
| @@ -360,6 +390,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 360 | 390 | apps: all(form, "apps"), |
| 361 | 391 | crew: all(form, "crew"), |
| 362 | 392 | fresh: field(form, "fresh").trim(), |
| 393 | followMode: field(form, "followMode").trim() || "tell-me", | |
| 363 | 394 | keepAccess: asBool(form, "keepAccess"), |
| 364 | 395 | budgetUsd: field(form, "budgetUsd").trim(), |
| 365 | 396 | modelEndpoint: field(form, "modelEndpoint").trim(), |
| @@ -373,6 +404,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 373 | 404 | crew: values.crew, |
| 374 | 405 | keepAccess: values.keepAccess, |
| 375 | 406 | ...(values.fresh === "" ? {} : { fresh: values.fresh }), |
| 407 | followMode: values.followMode, | |
| 376 | 408 | ...(values.forWhom === "" ? {} : { for: values.forWhom }), |
| 377 | 409 | ...(values.direction.trim() === "" ? {} : { direction: values.direction }), |
| 378 | 410 | ...(key === "" ? {} : { modelKey: key }), |
test/family-heartbeat.test.ts +17
| @@ -309,4 +309,21 @@ describe("family heartbeats", () => { | ||
| 309 | 309 | // A forge that was not spawned has no one to tell. |
| 310 | 310 | expect(await sendBeat(parent, async () => new Response("{}"))).toBe(false); |
| 311 | 311 | }); |
| 312 | ||
| 313 | test("the parent's /family says where a child's code is, and when it stopped following", async () => { | |
| 314 | const tip = "0123456789abcdef0123456789abcdef01234567"; | |
| 315 | const send = async (o: Record<string, unknown>) => { | |
| 316 | const body = beat({ at: Date.now() + 1000, ...o }); | |
| 317 | expect((await post(body, sign(key, body))).status).toBeLessThan(300); | |
| 318 | }; | |
| 319 | await send({ forgeTip: tip }); | |
| 320 | const before = (await family(adminCookie)).replace(/\s+/g, " "); | |
| 321 | expect(before).toContain("code at"); | |
| 322 | expect(before).toContain("0123456"); | |
| 323 | expect(before).not.toContain("follows nothing since"); | |
| 324 | await send({ forgeTip: tip, severedAt: "2030-02-03T04:05:06.000Z", at: Date.now() + 2000 }); | |
| 325 | const after = (await family(adminCookie)).replace(/\s+/g, " "); | |
| 326 | expect(after).toContain("follows nothing since 2030-02-03"); | |
| 327 | expect(after).not.toContain("code at"); | |
| 328 | }); | |
| 312 | 329 | }); |
test/following.test.ts +39 −3
| @@ -236,9 +236,9 @@ describe("a fork that follows its parent", () => { | ||
| 236 | 236 | headers: as(bobToken), |
| 237 | 237 | }); |
| 238 | 238 | expect(res.status).toBe(409); |
| 239 | expect(((await res.json()) as { files: string[] }).files).toEqual([ | |
| 240 | "README.md", | |
| 241 | ]); | |
| 239 | const said = (await res.json()) as { files: string[]; converge: string | null }; | |
| 240 | expect(said.files).toEqual(["README.md"]); | |
| 241 | expect(said.converge).toBe("no_crew"); | |
| 242 | 242 | const page = await fetch(`${base}/bob/game`, { |
| 243 | 243 | headers: { authorization: `Bearer ${bobToken}`, accept: "text/html" }, |
| 244 | 244 | }).then((p) => p.text()); |
| @@ -382,6 +382,42 @@ describe("a fork that follows its parent", () => { | ||
| 382 | 382 | expect(prompt).toContain("Never drop this repository's own work"); |
| 383 | 383 | }); |
| 384 | 384 | |
| 385 | test("taking updates into a conflict tells the crew: the same issue, never a second", async () => { | |
| 386 | const issue = listIssues(app.db, fork.id, { state: "open" }).find( | |
| 387 | (i) => i.title === convergeTitle(fresh(parent)), | |
| 388 | ); | |
| 389 | expect(issue).toBeDefined(); | |
| 390 | const before = listIssues(app.db, fork.id, { state: "open" }).length; | |
| 391 | const res = await fetch(`${base}/bob/game/following`, { | |
| 392 | method: "POST", | |
| 393 | headers: { | |
| 394 | authorization: `Bearer ${bobToken}`, | |
| 395 | "content-type": "application/x-www-form-urlencoded", | |
| 396 | accept: "application/json", | |
| 397 | }, | |
| 398 | body: "do=take", | |
| 399 | }); | |
| 400 | expect(res.status).toBe(422); | |
| 401 | const said = ((await res.json()) as { error: string }).error; | |
| 402 | expect(said).toContain("README.md"); | |
| 403 | expect(said).toContain(`#${issue!.number}`); | |
| 404 | expect(listIssues(app.db, fork.id, { state: "open" }).length).toBe(before); | |
| 405 | }); | |
| 406 | ||
| 407 | test("the API's take tells the crew the same way: the converge issue is named", async () => { | |
| 408 | const issue = listIssues(app.db, fork.id, { state: "open" }).find( | |
| 409 | (i) => i.title === convergeTitle(fresh(parent)), | |
| 410 | ); | |
| 411 | const res = await fetch(`${base}/api/v1/repos/bob/game/following/take`, { | |
| 412 | method: "POST", | |
| 413 | headers: as(bobToken), | |
| 414 | }); | |
| 415 | expect(res.status).toBe(409); | |
| 416 | const said = (await res.json()) as { issue: number | null; converge: string }; | |
| 417 | expect(said.converge).toBe("working"); | |
| 418 | expect(said.issue).toBe(issue!.number); | |
| 419 | }); | |
| 420 | ||
| 385 | 421 | test("a fork with autonomy on converges by itself when its parent moves", async () => { |
| 386 | 422 | // A second follower, with a crew and autonomy on, and its own README. |
| 387 | 423 | const c = await createUser(app.db, { trusted: true, |
test/mcp.test.ts +1
| @@ -184,6 +184,7 @@ describe("the forge over MCP", () => { | ||
| 184 | 184 | "forges", |
| 185 | 185 | "claim_link", |
| 186 | 186 | "take_updates", |
| 187 | "sever", | |
| 187 | 188 | "converge", |
| 188 | 189 | "family", |
| 189 | 190 | "next_steps", |
test/new-forge.test.ts +31
| @@ -259,6 +259,37 @@ describe("+ New forge", () => { | ||
| 259 | 259 | expect(await again.text()).not.toMatch(/claim#[A-Za-z0-9_-]{43}/); |
| 260 | 260 | }); |
| 261 | 261 | |
| 262 | test("the form offers how the child follows the forge's code, tell-me by default, and take-updates is recorded", async () => { | |
| 263 | const html = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " "); | |
| 264 | expect(html).toMatch(/name="followMode" value="tell-me" checked/); | |
| 265 | expect(html).toContain('value="take-updates"'); | |
| 266 | (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery"; | |
| 267 | try { | |
| 268 | const res = await post(admin, [ | |
| 269 | ["name", "follower"], | |
| 270 | ["followMode", "take-updates"], | |
| 271 | ["do", "spawn"], | |
| 272 | ]); | |
| 273 | expect(res.status).toBe(201); | |
| 274 | const docs = await Promise.all( | |
| 275 | (await readdir(join(dir, "children"))).map((d) => | |
| 276 | Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined), | |
| 277 | ), | |
| 278 | ); | |
| 279 | const mine = docs.find((d) => d?.name === "follower"); | |
| 280 | expect(mine?.forgeCode?.mode).toBe("take-updates"); | |
| 281 | const bad = await post(admin, [ | |
| 282 | ["name", "wrongmode"], | |
| 283 | ["followMode", "yolo"], | |
| 284 | ["do", "plan"], | |
| 285 | ]); | |
| 286 | expect(bad.status).toBe(422); | |
| 287 | expect(await bad.text()).toContain("must be tell-me or take-updates"); | |
| 288 | } finally { | |
| 289 | delete (app.config as { forgeRepo?: string }).forgeRepo; | |
| 290 | } | |
| 291 | }); | |
| 292 | ||
| 262 | 293 | test("a forge at its depth limit shows no door, and its page says why", async () => { |
| 263 | 294 | usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 })); |
| 264 | 295 | expect(await (await get(admin, "/")).text()).not.toContain('href="/forges/new"'); |
test/spawn-anon.test.ts +3 −1
| @@ -140,7 +140,9 @@ describe("anonymous spawn", () => { | ||
| 140 | 140 | const parent = (await Bun.file( |
| 141 | 141 | join(childDirOf(app, body.bodyId), "parent.json"), |
| 142 | 142 | ).json()) as any; |
| 143 | expect(parent.forgeCode).toEqual({ repo: "kid/forge", upstream: "alice/forge-code" }); | |
| 143 | expect(parent.forgeCode).toEqual( | |
| 144 | expect.objectContaining({ repo: "kid/forge", upstream: "alice/forge-code", mode: "tell-me" }), | |
| 145 | ); | |
| 144 | 146 | } finally { |
| 145 | 147 | child.close(); |
| 146 | 148 | } |
test/spawn-chain-doors.test.ts added +194
| @@ -0,0 +1,194 @@ | ||
| 1 | /** | |
| 2 | * The chain's other doors (#1085): MCP `sever` and the chain in `following`, | |
| 3 | * and the terminal's Chain card, which shows it only to someone who can read | |
| 4 | * the forge repository and offers Sever to its owner alone. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, beforeEach, 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 { getRepo, updateRepoSettings } from "../src/core/repos.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 17 | import { repoDir } from "../src/git/repos.ts"; | |
| 18 | import { commitFiles } from "../src/git/write.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { forgeLinkOf } from "../src/spawn/chain.ts"; | |
| 21 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | describe("the chain at the MCP and terminal doors", () => { | |
| 31 | let dir = ""; | |
| 32 | let root: App; | |
| 33 | let child: App; | |
| 34 | let servers: Servers; | |
| 35 | let alice: User; | |
| 36 | let reader: User; | |
| 37 | let rw = ""; | |
| 38 | let ro = ""; | |
| 39 | let readerRw = ""; | |
| 40 | let id = 1; | |
| 41 | ||
| 42 | const tool = async (token: string, name: string, args: unknown) => { | |
| 43 | const res = await fetch(`${servers.url}/mcp`, { | |
| 44 | method: "POST", | |
| 45 | headers: { | |
| 46 | "content-type": "application/json", | |
| 47 | accept: "application/json, text/event-stream", | |
| 48 | authorization: `Bearer ${token}`, | |
| 49 | }, | |
| 50 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }), | |
| 51 | }); | |
| 52 | const body = (await res.json()) as any; | |
| 53 | if (body.error !== undefined) return { error: String(body.error.message) }; | |
| 54 | const text: string = body.result.content[0].text; | |
| 55 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 56 | }; | |
| 57 | ||
| 58 | const boot = (user: User | undefined) => { | |
| 59 | const identity: TuiIdentity = { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", user }; | |
| 60 | const { app } = createForgeApp(child, identity, { terminal: headlessTerminal(120, 30), exitHooks: false }); | |
| 61 | app.start(); | |
| 62 | const press = async (...keys: string[]) => { | |
| 63 | for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 64 | await settleApp(app); | |
| 65 | }; | |
| 66 | const openChain = async () => { | |
| 67 | await settleApp(app); | |
| 68 | await press(":"); | |
| 69 | for (const ch of "Chain") await press(ch); | |
| 70 | await press("enter"); | |
| 71 | await settleApp(app); | |
| 72 | }; | |
| 73 | return { app, press, openChain, text: () => app.screen.toText() }; | |
| 74 | }; | |
| 75 | ||
| 76 | beforeAll(async () => { | |
| 77 | dir = await mkdtemp(join(tmpdir(), "forge-chain-doors-")); | |
| 78 | root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" }); | |
| 79 | const a = await createUser(root.db, { trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 80 | if (!a.ok) throw new Error("user"); | |
| 81 | const made = await createRepo(root, a.value, { name: "forge-code", visibility: "public" }); | |
| 82 | if (!made.ok) throw new Error("repo"); | |
| 83 | const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), { | |
| 84 | branch: "main", | |
| 85 | message: "forge v1", | |
| 86 | files: [{ path: "README.md", content: "# forge\n" }], | |
| 87 | author: { name: "a", email: "[email protected]" }, | |
| 88 | }); | |
| 89 | if (!c.ok) throw new Error("commit"); | |
| 90 | const spawned = await spawnForge( | |
| 91 | root, | |
| 92 | a.value, | |
| 93 | { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false }, | |
| 94 | { catalog: [], briefs: [], subscriptions: [] }, | |
| 95 | { | |
| 96 | placement: hostedPlacement({ host: "root.test" }), | |
| 97 | request: new Request("https://root.test/x", { method: "POST", headers: { origin: "https://root.test" } }), | |
| 98 | url: new URL("https://root.test/x"), | |
| 99 | }, | |
| 100 | ); | |
| 101 | if (!spawned.ok) throw new Error(JSON.stringify(spawned.error)); | |
| 102 | child = createApp({ dataDir: childDirOf(root, spawned.value.bodyId), url: "https://kid.test", port: 0 }); | |
| 103 | servers = await serve(child); | |
| 104 | alice = getUserByName(child.db, "alice")!; | |
| 105 | const r = await createUser(child.db, { trusted: true, username: "reader", email: "[email protected]" }); | |
| 106 | if (!r.ok) throw new Error("reader"); | |
| 107 | reader = r.value; | |
| 108 | rw = createToken(child.db, alice.id, "rw", "rw").token; | |
| 109 | ro = createToken(child.db, alice.id, "ro", "ro").token; | |
| 110 | readerRw = createToken(child.db, reader.id, "rr", "rw").token; | |
| 111 | }, 60_000); | |
| 112 | ||
| 113 | // Every test starts from a followed forge with a public forge repository. | |
| 114 | beforeEach(async () => { | |
| 115 | const file = join(child.config.dataDir, "parent.json"); | |
| 116 | const doc = JSON.parse(await Bun.file(file).text()); | |
| 117 | delete doc.forgeCode.severedAt; | |
| 118 | await Bun.write(file, JSON.stringify(doc)); | |
| 119 | updateRepoSettings(child.db, getRepo(child.db, "kid", "forge")!, { visibility: "public" }); | |
| 120 | }); | |
| 121 | ||
| 122 | afterAll(async () => { | |
| 123 | servers.stop(); | |
| 124 | child.close(); | |
| 125 | root.close(); | |
| 126 | await rm(dir, { recursive: true, force: true }); | |
| 127 | }); | |
| 128 | ||
| 129 | test("MCP following carries the chain, root first", async () => { | |
| 130 | const got = await tool(rw, "following", { repo: "kid/forge" }); | |
| 131 | expect(got.value.chain.map((l: any) => l.repo)).toEqual(["alice/forge-code", "kid/forge"]); | |
| 132 | expect(got.value.severedAt).toBeNull(); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the terminal shows the chain to a reader, and Sever only to the owner", async () => { | |
| 136 | const guest = boot(undefined); | |
| 137 | await guest.openChain(); | |
| 138 | expect(guest.text()).toContain("root.test"); | |
| 139 | expect(guest.text()).not.toContain("sever"); | |
| 140 | guest.app.stop(); | |
| 141 | ||
| 142 | const other = boot(reader); | |
| 143 | await other.openChain(); | |
| 144 | expect(other.text()).toContain("kid.test"); | |
| 145 | await other.press("s"); | |
| 146 | expect(other.text()).not.toContain("type sever"); | |
| 147 | other.app.stop(); | |
| 148 | expect((await forgeLinkOf(child))?.severedAt).toBeUndefined(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a guest on the terminal sees nothing of a private forge repository", async () => { | |
| 152 | const repo = getRepo(child.db, "kid", "forge")!; | |
| 153 | updateRepoSettings(child.db, repo, { visibility: "private" }); | |
| 154 | const guest = boot(undefined); | |
| 155 | await guest.openChain(); | |
| 156 | expect(guest.text()).not.toContain("root.test"); | |
| 157 | expect(guest.text()).not.toContain("kid.test"); | |
| 158 | guest.app.stop(); | |
| 159 | const owner = boot(alice); | |
| 160 | await owner.openChain(); | |
| 161 | expect(owner.text()).toContain("root.test"); | |
| 162 | owner.app.stop(); | |
| 163 | }); | |
| 164 | ||
| 165 | test("MCP sever refuses a reader and a read-only token, and changes nothing", async () => { | |
| 166 | expect((await tool(readerRw, "sever", { repo: "kid/forge" })).error).toBeDefined(); | |
| 167 | expect((await tool(ro, "sever", { repo: "kid/forge" })).error).toBeDefined(); | |
| 168 | expect((await forgeLinkOf(child))?.severedAt).toBeUndefined(); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the terminal's owner types the word to sever; a wrong word does nothing", async () => { | |
| 172 | const owner = boot(alice); | |
| 173 | await owner.openChain(); | |
| 174 | await owner.press("s"); | |
| 175 | for (const ch of "nope") await owner.press(ch); | |
| 176 | await owner.press("enter"); | |
| 177 | expect(owner.text()).toContain("Type sever to confirm"); | |
| 178 | expect((await forgeLinkOf(child))?.severedAt).toBeUndefined(); | |
| 179 | for (const _ of "nope") await owner.press("backspace"); | |
| 180 | for (const ch of "sever") await owner.press(ch); | |
| 181 | await owner.press("enter"); | |
| 182 | await settleUntil(owner.app, "Severed"); | |
| 183 | expect((await forgeLinkOf(child))?.severedAt).toBeDefined(); | |
| 184 | owner.app.stop(); | |
| 185 | }); | |
| 186 | ||
| 187 | test("MCP sever by the owner is idempotent and says when", async () => { | |
| 188 | const first = await tool(rw, "sever", { repo: "kid/forge" }); | |
| 189 | const before = first.value.severedAt; | |
| 190 | const got = await tool(rw, "sever", { repo: "kid/forge" }); | |
| 191 | expect(got.value.severedAt).toBe(before); | |
| 192 | expect((await tool(rw, "following", { repo: "kid/forge" })).value.severedAt).toBe(before); | |
| 193 | }); | |
| 194 | }); |
test/spawn-chain.test.ts added +236
| @@ -0,0 +1,236 @@ | ||
| 1 | /** | |
| 2 | * The chain of forges (#1085): a child carries a fork of its parent's forge | |
| 3 | * code with the follow mode its spawner chose; a grandchild's fork names the | |
| 4 | * child's; the chain reads the same from both ends; severing cuts the link | |
| 5 | * and nothing else; the heartbeat tells the parent. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createSession } from "../src/core/sessions.ts"; | |
| 18 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 19 | import { runGit } from "../src/git/env.ts"; | |
| 20 | import { repoDir } from "../src/git/repos.ts"; | |
| 21 | import { commitFiles } from "../src/git/write.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { chainLines, chainView, forgeLinkOf, severChain } from "../src/spawn/chain.ts"; | |
| 24 | import { parseBeat, sendBeat, standing } from "../src/spawn/heartbeat.ts"; | |
| 25 | import { planSpawn } from "../src/spawn/plan.ts"; | |
| 26 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 27 | import { quietLogs } from "./helpers/globals.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | ||
| 31 | const author = { name: "a", email: "[email protected]" }; | |
| 32 | const source = { catalog: [], briefs: [], subscriptions: [] }; | |
| 33 | ||
| 34 | const spawn = (app: App, owner: any, name: string, host: string, followMode?: string) => | |
| 35 | spawnForge( | |
| 36 | app, | |
| 37 | owner, | |
| 38 | { name, org: name, apps: [], carryBriefs: false, carrySubscriptions: false, ...(followMode ? { followMode } : {}) }, | |
| 39 | source, | |
| 40 | { | |
| 41 | placement: hostedPlacement({ host }), | |
| 42 | request: new Request(`https://${host}/x`, { method: "POST", headers: { origin: `https://${host}` } }), | |
| 43 | url: new URL(`https://${host}/x`), | |
| 44 | }, | |
| 45 | ); | |
| 46 | ||
| 47 | describe("the chain of forges", () => { | |
| 48 | let dir = ""; | |
| 49 | let root: App; | |
| 50 | let child: App; | |
| 51 | let grand: App; | |
| 52 | let servers: Servers; | |
| 53 | let cookie = ""; | |
| 54 | ||
| 55 | beforeAll(async () => { | |
| 56 | dir = await mkdtemp(join(tmpdir(), "forge-chain-")); | |
| 57 | root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" }); | |
| 58 | const alice = await createUser(root.db, { trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 59 | if (!alice.ok) throw new Error("user"); | |
| 60 | const made = await createRepo(root, alice.value, { name: "forge-code", visibility: "public" }); | |
| 61 | if (!made.ok) throw new Error("repo"); | |
| 62 | const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), { | |
| 63 | branch: "main", | |
| 64 | message: "forge v1", | |
| 65 | files: [{ path: "README.md", content: "# forge\n" }], | |
| 66 | author, | |
| 67 | }); | |
| 68 | if (!c.ok) throw new Error("commit"); | |
| 69 | ||
| 70 | const a = await spawn(root, alice.value, "kid", "root.test", "take-updates"); | |
| 71 | if (!a.ok) throw new Error(JSON.stringify(a.error)); | |
| 72 | child = createApp({ dataDir: childDirOf(root, a.value.bodyId), url: "https://kid.test", port: 0 }); | |
| 73 | // The child's owner changes their forge all they want. | |
| 74 | const own = await commitFiles(repoDir(child.reposRoot, "kid", "forge"), { | |
| 75 | branch: "main", | |
| 76 | message: "mine", | |
| 77 | files: [{ path: "NOTES.md", content: "mine\n" }], | |
| 78 | author, | |
| 79 | }); | |
| 80 | if (!own.ok) throw new Error("own commit"); | |
| 81 | ||
| 82 | const founder = getUserByName(child.db, "alice")!; | |
| 83 | const b = await spawn(child, founder, "tot", "kid.test"); | |
| 84 | if (!b.ok) throw new Error(JSON.stringify(b.error)); | |
| 85 | grand = createApp({ dataDir: childDirOf(child, b.value.bodyId), port: 0 }); | |
| 86 | servers = await serve(grand); | |
| 87 | const owner = getUserByName(grand.db, "alice")!; | |
| 88 | cookie = `forge_session=${createSession(grand.db, owner.id).token}`; | |
| 89 | }, 60_000); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | servers.stop(); | |
| 93 | grand.close(); | |
| 94 | child.close(); | |
| 95 | root.close(); | |
| 96 | await rm(dir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("followMode is validated and defaults to tell-me", () => { | |
| 100 | const base = { name: "x", org: "x", apps: [], carryBriefs: false, carrySubscriptions: false }; | |
| 101 | expect(planSpawn(base, source).ok).toBe(true); | |
| 102 | const bad = planSpawn({ ...base, followMode: "yolo" }, source); | |
| 103 | expect(bad.ok).toBe(false); | |
| 104 | const grandLink = planSpawn({ ...base, followMode: "tell-me" }, source); | |
| 105 | expect(grandLink.ok && grandLink.value.followMode).toBeUndefined(); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the child carries a fork of the parent's forge code in the chosen mode", async () => { | |
| 109 | const link = await forgeLinkOf(child); | |
| 110 | expect(link?.repo).toBe("kid/forge"); | |
| 111 | expect(link?.upstream).toBe("alice/forge-code"); | |
| 112 | expect(link?.mode).toBe("take-updates"); | |
| 113 | expect(link?.chain.map((h) => h.repo)).toEqual(["alice/forge-code"]); | |
| 114 | expect(getRepo(child.db, "kid", "forge")).toBeDefined(); | |
| 115 | }); | |
| 116 | ||
| 117 | test("the grandchild's fork names the child's forge, mode tell-me by default", async () => { | |
| 118 | const link = await forgeLinkOf(grand); | |
| 119 | expect(link?.upstream).toBe("kid/forge"); | |
| 120 | expect(link?.mode).toBe("tell-me"); | |
| 121 | const log = await runGit(["log", "--oneline"], { cwd: repoDir(grand.reposRoot, "tot", "forge") }); | |
| 122 | expect(log.ok && log.value).toContain("mine"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the chain reads to the root from the grandchild, and from the child", async () => { | |
| 126 | const g = await chainView(grand); | |
| 127 | expect(g?.lines.map((l) => l.repo)).toEqual(["alice/forge-code", "kid/forge", "tot/forge"]); | |
| 128 | expect(g?.lines.map((l) => l.mode)).toEqual([null, "take-updates", "tell-me"]); | |
| 129 | expect(g?.lines[1]!.ahead).toBe(1); | |
| 130 | expect(g?.lines[2]!.here).toBe(true); | |
| 131 | const c = await chainView(child); | |
| 132 | expect(c?.lines.map((l) => l.repo)).toEqual(["alice/forge-code", "kid/forge"]); | |
| 133 | expect(await chainView(root)).toBeUndefined(); | |
| 134 | ||
| 135 | const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); | |
| 136 | expect(page).toContain("alice/forge-code"); | |
| 137 | expect(page).toContain("kid/forge"); | |
| 138 | expect(page).toContain("takes updates"); | |
| 139 | expect(page).toContain("tells when the parent moves"); | |
| 140 | expect(page).toContain("1 of its own"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the heartbeat carries the forge's tip", async () => { | |
| 144 | const beat = JSON.parse(JSON.stringify(standing(grand, "tot"))); | |
| 145 | expect(beat.forgeTip ?? null).toBeNull(); // standing() is counts; the sent body adds the tip | |
| 146 | let sent = ""; | |
| 147 | await sendBeat(grand, (async (_u: string, init?: RequestInit) => { | |
| 148 | sent = String(init?.body); | |
| 149 | return new Response("{}"); | |
| 150 | }) as any); | |
| 151 | const parsed = parseBeat(sent); | |
| 152 | expect(parsed?.forgeTip).toMatch(/^[0-9a-f]{40,64}$/); | |
| 153 | expect(parsed?.severedAt).toBeNull(); | |
| 154 | expect(parseBeat(sent.replace(/"forgeTip":"[^"]*"/, '"forgeTip":"<x>"'))).toBeUndefined(); | |
| 155 | }); | |
| 156 | ||
| 157 | test("the API reads the chain, and only the owner severs it there", async () => { | |
| 158 | const owner = getUserByName(grand.db, "alice")!; | |
| 159 | const bearer = (id: string) => ({ authorization: `Bearer ${createToken(grand.db, id, "t", "rw").token}` }); | |
| 160 | const got = (await (await fetch(`${servers.url}/api/v1/repos/tot/forge/following`, { headers: bearer(owner.id) })).json()) as any; | |
| 161 | expect(got.chain.map((l: any) => l.repo)).toEqual(["alice/forge-code", "kid/forge", "tot/forge"]); | |
| 162 | expect(got.severedAt).toBeNull(); | |
| 163 | const reader = await createUser(grand.db, { trusted: true, username: "apireader", email: "[email protected]" }); | |
| 164 | if (!reader.ok) throw new Error("reader"); | |
| 165 | const denied = await fetch(`${servers.url}/api/v1/repos/tot/forge/following/sever`, { method: "POST", headers: bearer(reader.value.id) }); | |
| 166 | expect(denied.status).toBeGreaterThanOrEqual(401); | |
| 167 | expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined(); | |
| 168 | const other = await fetch(`${servers.url}/api/v1/repos/tot/forge/following/sever`, { method: "POST" }); | |
| 169 | expect(other.status).toBeGreaterThanOrEqual(401); | |
| 170 | expect(chainLines((await chainView(grand))!).map(([k]) => k)).toEqual(["alice/forge-code", "kid/forge", "tot/forge (here)"]); | |
| 171 | }); | |
| 172 | ||
| 173 | test("a hop with an address URL rejects does not break the page", async () => { | |
| 174 | const file = join(grand.config.dataDir, "parent.json"); | |
| 175 | const doc = JSON.parse(await Bun.file(file).text()); | |
| 176 | doc.forgeCode.chain[0].forge = "http://["; | |
| 177 | await Bun.write(file, JSON.stringify(doc)); | |
| 178 | const res = await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } }); | |
| 179 | expect(res.status).toBe(200); | |
| 180 | expect(await res.text()).toContain("alice/forge-code"); | |
| 181 | }); | |
| 182 | ||
| 183 | test("sever needs the owner, a same-origin post and the word; it deletes nothing", async () => { | |
| 184 | const url = `${servers.url}/tot/forge/following/sever`; | |
| 185 | const body = (confirm: string) => new URLSearchParams({ confirm }); | |
| 186 | const anon = await fetch(url, { method: "POST", headers: { origin: servers.url }, body: body("sever"), redirect: "manual" }); | |
| 187 | expect(anon.status).toBeGreaterThanOrEqual(300); | |
| 188 | expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined(); | |
| 189 | const cross = await fetch(url, { method: "POST", headers: { cookie, origin: "https://evil.test" }, body: body("sever"), redirect: "manual" }); | |
| 190 | expect(cross.status).toBeGreaterThanOrEqual(400); | |
| 191 | const reader = await createUser(grand.db, { trusted: true, username: "reader", email: "[email protected]" }); | |
| 192 | if (!reader.ok) throw new Error("reader"); | |
| 193 | const theirs = `forge_session=${createSession(grand.db, reader.value.id).token}`; | |
| 194 | await fetch(url, { method: "POST", headers: { cookie: theirs, origin: servers.url }, body: body("sever"), redirect: "manual" }); | |
| 195 | expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined(); | |
| 196 | await fetch(url, { method: "POST", headers: { cookie, origin: servers.url }, body: body("nope"), redirect: "manual" }); | |
| 197 | expect((await forgeLinkOf(grand))?.severedAt).toBeUndefined(); | |
| 198 | ||
| 199 | const ok = await fetch(url, { method: "POST", headers: { cookie, origin: servers.url }, body: body("sever"), redirect: "manual" }); | |
| 200 | expect(ok.status).toBe(303); | |
| 201 | const link = await forgeLinkOf(grand); | |
| 202 | expect(link?.severedAt).toBeDefined(); | |
| 203 | expect(link?.upstream).toBe("kid/forge"); | |
| 204 | // Nothing deleted: the fork and its history are there. | |
| 205 | expect(getRepo(grand.db, "tot", "forge")).toBeDefined(); | |
| 206 | const log = await runGit(["log", "--oneline"], { cwd: repoDir(grand.reposRoot, "tot", "forge") }); | |
| 207 | expect(log.ok && log.value).toContain("forge v1"); | |
| 208 | const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); | |
| 209 | expect(page).toContain("follows nothing since"); | |
| 210 | // Again changes nothing and keeps the first date. | |
| 211 | const again = await severChain(grand, new Date("2030-01-01")); | |
| 212 | expect(again.ok && again.value).toBe(link!.severedAt!); | |
| 213 | }); | |
| 214 | ||
| 215 | test("the API severs for the owner, again changing nothing", async () => { | |
| 216 | const owner = getUserByName(grand.db, "alice")!; | |
| 217 | const before = (await forgeLinkOf(grand))!.severedAt!; | |
| 218 | const res = await fetch(`${servers.url}/api/v1/repos/tot/forge/following/sever`, { | |
| 219 | method: "POST", | |
| 220 | headers: { authorization: `Bearer ${createToken(grand.db, owner.id, "t2", "rw").token}` }, | |
| 221 | }); | |
| 222 | expect(res.status).toBe(200); | |
| 223 | expect(((await res.json()) as any).severedAt).toBe(before); | |
| 224 | const got = (await (await fetch(`${servers.url}/api/v1/repos/tot/forge/following`, { headers: { cookie } })).json()) as any; | |
| 225 | expect(got.severedAt).toBe(before); | |
| 226 | }); | |
| 227 | ||
| 228 | test("a severed forge says so in its beat", async () => { | |
| 229 | let sent = ""; | |
| 230 | await sendBeat(grand, (async (_u: string, init?: RequestInit) => { | |
| 231 | sent = String(init?.body); | |
| 232 | return new Response("{}"); | |
| 233 | }) as any); | |
| 234 | expect(parseBeat(sent)?.severedAt).toBe((await forgeLinkOf(grand))!.severedAt!); | |
| 235 | }); | |
| 236 | }); |
test/tui-newforge.test.ts +20
| @@ -158,4 +158,24 @@ describe("the new forge screen", () => { | ||
| 158 | 158 | expect(await children()).toBe(before + 1); |
| 159 | 159 | expect(tui.screen.toText()).toMatch(/https:\/\/zed--forge\.test\/claim#/); |
| 160 | 160 | })); |
| 161 | ||
| 162 | test("the follow mode is a field: take-updates reaches parent.json", () => | |
| 163 | withScreen(admin, async ({ tui, terminal }) => { | |
| 164 | (app.config as { forgeRepo?: string }).forgeRepo = "alice/bakery"; | |
| 165 | try { | |
| 166 | terminal.feed("tuifollow" + "\t".repeat(8) + "take-updates\x10"); | |
| 167 | await settleApp(tui); | |
| 168 | expect(tui.screen.toText()).toContain("take-updates"); | |
| 169 | terminal.feed("\r"); | |
| 170 | await settleApp(tui); | |
| 171 | const docs = await Promise.all( | |
| 172 | (await readdir(join(dir, "children"))).map((d) => | |
| 173 | Bun.file(join(dir, "children", d, "parent.json")).json().catch(() => undefined), | |
| 174 | ), | |
| 175 | ); | |
| 176 | expect(docs.find((d) => d?.name === "tuifollow")?.forgeCode?.mode).toBe("take-updates"); | |
| 177 | } finally { | |
| 178 | delete (app.config as { forgeRepo?: string }).forgeRepo; | |
| 179 | } | |
| 180 | })); | |
| 161 | 181 | }); |
This event goes onto the bus, but no page subscribes to it, so a sever made from another door does not reach an open chain page or the parent's
/familylive. Tracked in #2213; it does not block this pull.