Public: anyone can read this; the crew can change it.
Every new forge comes with its own source as a repo, wired to trevato/forge upstream, so its crew can work on it #10992
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10989-every-new-forge-comes-with-its-own-sourc → main 3 commits fork 4 threads · 4 unresolved
6 files
changed +499
−55
docs/features/own-source.md added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | # A child forge's own source | |
| 2 | ||
| 3 | Every new child forge is born with `<admin>/forge`, a fork of the parent's code | |
| 4 | (`src/spawn/forge_code.ts`), so its crew can work on the forge itself. | |
| 5 | ||
| 6 | - Its `forgeRepo` setting names it (an admin's earlier choice stands), so the | |
| 7 | forge shows that repository as itself. | |
| 8 | - The parent is its upstream, recorded as `forgeCode` in `parent.json`: take | |
| 9 | updates, or send a change back as a pull on the parent through the origin. | |
| 10 | - Its crew is seated on it, each with the role they hold on the child's other | |
| 11 | repositories. | |
| 12 | ||
| 13 | Merging there runs on the next image; it does not redeploy the child. | |
| 14 | ||
| 15 | ## Existing children: a recorded one-shot | |
| 16 | ||
| 17 | `src/spawn/forge_code_backfill.ts` runs at the parent's boot. The first run | |
| 18 | writes `forge-code-backfill.json` under the parent's data directory: the | |
| 19 | children that exist then, and what became of each. Later boots read that file | |
| 20 | and open nothing once every child is settled; children born afterwards are | |
| 21 | made with their source and are not in it. No child names live in source. | |
| 22 | ||
| 23 | - A child whose `parent.json` already records its source is settled without | |
| 24 | opening its database. | |
| 25 | - A child that is awake (open here or in a cell) is skipped and tried at the | |
| 26 | next boot: opening its database writes, and its write lock is its own. | |
| 27 | - A failure is recorded as an error and retried at the next boot. | |
| 28 | ||
| 29 | A `forge` repository a person made is left alone. "We made it" is a record: | |
| 30 | `forgeCodeStarted` in `parent.json`, written before the repository is created. | |
| 31 | A run that died after creating the repo (fetch failed, or `parent.json` not | |
| 32 | yet written) leaves the record, and the next pass finishes the fetch; a repo | |
| 33 | without the record is a person's and stays theirs. | |
| 34 | ||
| 35 | ## Both doors, and what was exercised | |
| 36 | ||
| 37 | The terminal needs nothing of its own: `<admin>/forge` is an ordinary public | |
| 38 | repository there, listed and opened like any other. Settings (`forgeRepo`) is | |
| 39 | a web page. Exercised by `test/forge-code-own-source.test.ts`: a spawned child | |
| 40 | has the repo, the setting and the parent as origin; backfill is idempotent, | |
| 41 | finishes an unfinished run, leaves a person's repo alone, and is a recorded | |
| 42 | one-shot; adoption seats the crew with copied roles and keeps an earlier | |
| 43 | `forgeRepo`. | |
| 44 | ||
| 45 | ## Why it goes before the pipeline list | |
| 46 | ||
| 47 | A forge that cannot be worked on from inside cannot take part in the pipeline | |
| 48 | list (#6534, #6611, ...): those items are changes to the child's own code, and | |
| 49 | each needs a repository on the child to land in and an upstream to send back | |
| 50 | to. This is the ground they stand on. |
src/actions/spawn.ts +5 −55
| @@ -38,6 +38,7 @@ import { createUser, getUserByName, type User } from "../core/users.ts"; | ||
| 38 | 38 | import { runGit } from "../git/env.ts"; |
| 39 | 39 | import { repoDir } from "../git/repos.ts"; |
| 40 | 40 | import { commitFiles } from "../git/write.ts"; |
| 41 | import { adoptForgeCode, forkForgeCode } from "../spawn/forge_code.ts"; | |
| 41 | 42 | import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts"; |
| 42 | 43 | import { sha256 } from "../kernel/hash.ts"; |
| 43 | 44 | import { secret } from "../kernel/id.ts"; |
| @@ -60,7 +61,7 @@ import { quotaRefusal } from "../spawn/quota.ts"; | ||
| 60 | 61 | import { personRefusal } from "../spawn/sleep.ts"; |
| 61 | 62 | import { planSpawn, type SpawnInput, type SpawnPlan, type SpawnSource } from "../spawn/plan.ts"; |
| 62 | 63 | import { DEFAULT_SIZE, isSize, type Size } from "../spawn/sizes.ts"; |
| 63 | import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "../spawn/chain.ts"; | |
| 64 | import { DEFAULT_MODE } from "../spawn/chain.ts"; | |
| 64 | 65 | import { requireUser } from "./access.ts"; |
| 65 | 66 | |
| 66 | 67 | export interface Spawned { |
| @@ -467,6 +468,9 @@ async function copyInto( | ||
| 467 | 468 | setCrewRole(child.db, repo.id, theirs.user.id, role); |
| 468 | 469 | } |
| 469 | 470 | |
| 471 | // The fork is the child's own source: its `forgeRepo`, and the crew seated on it. | |
| 472 | adoptForgeCode(child, org.username, founder.id); | |
| 473 | ||
| 470 | 474 | // The child's own keys, made in its own directory now, so they exist |
| 471 | 475 | // before its first boot and are provably not the parent's: the key that |
| 472 | 476 | // seals its secrets, and its SSH host key. |
| @@ -587,60 +591,6 @@ async function startFresh( | ||
| 587 | 591 | return ok(true); |
| 588 | 592 | } |
| 589 | 593 | |
| 590 | /** | |
| 591 | * The parent's own code, forked into the child's organisation with its full | |
| 592 | * history. Returns the parent's `owner/name` when forked, undefined when the | |
| 593 | * forge does not name its code (`forgeRepo`) or the repo is not there — | |
| 594 | * a child is still spawned, just without the fork. | |
| 595 | * | |
| 596 | * A cross-forge fork: git carries the history, and the upstream stays | |
| 597 | * reachable through the parent's address in `parent.json`. The in-database | |
| 598 | * follow (`fork_parent_id`) is for forks on one forge; across two, the | |
| 599 | * child pulls the parent's default branch when its owner asks. | |
| 600 | */ | |
| 601 | async function forkForgeCode( | |
| 602 | app: App, | |
| 603 | child: App, | |
| 604 | orgName: string, | |
| 605 | mode: FollowMode, | |
| 606 | ): Promise<Result<{ upstream: string; mode: FollowMode; tip: string; chain: readonly Hop[] } | undefined, string>> { | |
| 607 | // A spawned forge names no `forgeRepo` of its own: its code is the fork | |
| 608 | // it was born with, so a grandchild forks that. | |
| 609 | const ref = app.config.forgeRepo ?? (await forgeLinkOf(app))?.repo; | |
| 610 | if (ref === undefined) return ok(undefined); | |
| 611 | const parts = ref.split("/"); | |
| 612 | if (parts.length !== 2 || !parts[0] || !parts[1]) return ok(undefined); | |
| 613 | const [o, n] = parts as [string, string]; | |
| 614 | const parent = getRepo(app.db, o, n); | |
| 615 | if (!parent) return ok(undefined); | |
| 616 | const name = "forge"; | |
| 617 | let repo = getRepo(child.db, orgName, name); | |
| 618 | if (!repo) { | |
| 619 | const made = await createRepo(child.db, child.reposRoot, { | |
| 620 | ownerId: getOrg(child.db, orgName)!.id, | |
| 621 | name, | |
| 622 | visibility: "public", | |
| 623 | description: `A fork of ${o}/${n}: the forge's own code, following its parent.`, | |
| 624 | defaultBranch: parent.defaultBranch, | |
| 625 | }); | |
| 626 | if (!made.ok) return err(`could not make the child's forge repo: ${made.error}`); | |
| 627 | repo = made.value; | |
| 628 | } | |
| 629 | const fetched = await runGit( | |
| 630 | [ | |
| 631 | "fetch", | |
| 632 | "--no-write-fetch-head", | |
| 633 | repoDir(app.reposRoot, o, n), | |
| 634 | "+refs/heads/*:refs/heads/*", | |
| 635 | "+refs/tags/*:refs/tags/*", | |
| 636 | ], | |
| 637 | { cwd: repoDir(child.reposRoot, orgName, name), timeoutMs: 600_000 }, | |
| 638 | ); | |
| 639 | if (!fetched.ok) return err(`could not copy the forge's code: ${fetched.error.stderr}`); | |
| 640 | const born = await chainForChild(app, `${o}/${n}`); | |
| 641 | return ok({ upstream: `${o}/${n}`, mode, tip: born?.tip ?? "", chain: born?.chain ?? [] }); | |
| 642 | } | |
| 643 | ||
| 644 | 594 | /** |
| 645 | 595 | * The model key a child already holds, opened with the child's own key; or |
| 646 | 596 | * undefined when it has none yet (or the child's database is not there). |
src/main.ts +7
| @@ -28,6 +28,7 @@ import { offerStartingBoards } from "./actions/boards.ts"; | ||
| 28 | 28 | import { syncAllPillars } from "./actions/pillars.ts"; |
| 29 | 29 | import { bootHostedChildren, childAwake } from "./spawn/host.ts"; |
| 30 | 30 | import { stopCellChildren } from "./spawn/cell.ts"; |
| 31 | import { backfillChildren } from "./spawn/forge_code_backfill.ts"; | |
| 31 | 32 | import { firstBoot } from "./spawn/first-boot.ts"; |
| 32 | 33 | import { previewIdentityKey } from "./spawn/preview-owner.ts"; |
| 33 | 34 | import { PREVIEW_ADMIN, seedPreview } from "./spawn/preview-seed.ts"; |
| @@ -440,6 +441,12 @@ if (apps && presence) warmBehind(app.log, presence, () => apps.startingCount()); | ||
| 440 | 441 | void backfillCarried(app.db, app.reposRoot).catch((error) => |
| 441 | 442 | app.log.error("could not backfill what deploys carried", { error: String(error) }), |
| 442 | 443 | ); |
| 444 | // Children born before each came with its own source get it (#10989). | |
| 445 | void backfillChildren(app) | |
| 446 | .then((done) => { | |
| 447 | if (Object.keys(done).length > 0) app.log.info("forge-code backfill", { done: JSON.stringify(done) }); | |
| 448 | }) | |
| 449 | .catch((error) => app.log.error("could not backfill children's forge code", { error: String(error) })); | |
| 443 | 450 | // A forge still draining on this database keeps writing for its gates and |
| 444 | 451 | // runs; starting ours beside it stalls the listener behind its write lock |
| 445 | 452 | // (#6957). The listener answers meanwhile; a signal now stops what is up. |
src/spawn/forge_code.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * A child forge's own source (#10989): `<org>/forge`, a fork of the | |
| 3 | * parent's code at the commit the parent holds, with the history behind it. | |
| 4 | * Three things make it the child's own to work on: | |
| 5 | * | |
| 6 | * 1. the child's `forgeRepo` setting names it, so the forge shows that | |
| 7 | * repository as itself (`core/forge_repo.ts`); | |
| 8 | * 2. `parent.json`'s `forgeCode` record names the parent's repository as | |
| 9 | * its upstream: "bring in upstream" (`spawn/take.ts`) and "send a change | |
| 10 | * back" (`actions/upstream.ts`, a pull on the parent through the | |
| 11 | * proposal token, docs/ORIGINS.md) both read that record; | |
| 12 | * 3. the crew is seated on it with the roles it holds elsewhere, so | |
| 13 | * "improve my forge" has someone to take it. | |
| 14 | * | |
| 15 | * Every step is idempotent, and a repository a person already made under | |
| 16 | * that name is left alone (`backfillForgeCode`). | |
| 17 | */ | |
| 18 | ||
| 19 | import { rename } from "node:fs/promises"; | |
| 20 | import { join } from "node:path"; | |
| 21 | import type { App } from "../app.ts"; | |
| 22 | import { addCrew, listAgents, listCrewRoles, setCrewRole, type CrewRole } from "../core/agents.ts"; | |
| 23 | import { getOrg, listTeamMembers, listTeams, OWNERS_TEAM } from "../core/orgs.ts"; | |
| 24 | import { createRepo, getRepo, listReposOwnedBy, type Repo } from "../core/repos.ts"; | |
| 25 | import { runGit } from "../git/env.ts"; | |
| 26 | import { repoDir } from "../git/repos.ts"; | |
| 27 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 28 | import { forgeSource, setForgeSource, storedForgeSource } from "../settings/forge_source.ts"; | |
| 29 | import { chainForChild, DEFAULT_MODE, forgeLinkOf, type FollowMode, type Hop } from "./chain.ts"; | |
| 30 | ||
| 31 | /** The repository's name inside the child's organisation. */ | |
| 32 | export const FORGE_CODE_NAME = "forge"; | |
| 33 | ||
| 34 | export interface ForgeCode { | |
| 35 | readonly upstream: string; | |
| 36 | readonly mode: FollowMode; | |
| 37 | readonly tip: string; | |
| 38 | readonly chain: readonly Hop[]; | |
| 39 | } | |
| 40 | ||
| 41 | /** The parent's own code repository as `owner/name`: its `forgeRepo`, else the fork it was born with. */ | |
| 42 | export async function forgeCodeRef(app: App): Promise<string | undefined> { | |
| 43 | const ref = forgeSource(app.db, app.config.forgeRepo) ?? (await forgeLinkOf(app))?.repo; | |
| 44 | const parts = ref?.split("/") ?? []; | |
| 45 | return parts.length === 2 && parts[0] && parts[1] ? ref : undefined; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Fork the parent's code into the child's organisation with its full | |
| 50 | * history; undefined when the forge names no code or it is not there (a | |
| 51 | * child is still made, just without the fork). Git carries the history; the | |
| 52 | * upstream stays reachable through `parent.json`. | |
| 53 | */ | |
| 54 | export async function forkForgeCode( | |
| 55 | app: App, | |
| 56 | child: App, | |
| 57 | orgName: string, | |
| 58 | mode: FollowMode, | |
| 59 | ): Promise<Result<ForgeCode | undefined, string>> { | |
| 60 | const ref = await forgeCodeRef(app); | |
| 61 | if (ref === undefined) return ok(undefined); | |
| 62 | const [o, n] = ref.split("/") as [string, string]; | |
| 63 | const parent = getRepo(app.db, o, n); | |
| 64 | const org = getOrg(child.db, orgName); | |
| 65 | if (!parent || !org) return ok(undefined); | |
| 66 | let repo = getRepo(child.db, orgName, FORGE_CODE_NAME); | |
| 67 | if (!repo) { | |
| 68 | const made = await createRepo(child.db, child.reposRoot, { | |
| 69 | ownerId: org.id, | |
| 70 | name: FORGE_CODE_NAME, | |
| 71 | visibility: "public", | |
| 72 | description: `A fork of ${o}/${n}: the forge's own code, following its parent.`, | |
| 73 | defaultBranch: parent.defaultBranch, | |
| 74 | }); | |
| 75 | if (!made.ok) return err(`could not make the child's forge repo: ${made.error}`); | |
| 76 | repo = made.value; | |
| 77 | } | |
| 78 | const fetched = await runGit( | |
| 79 | [ | |
| 80 | "fetch", | |
| 81 | "--no-write-fetch-head", | |
| 82 | repoDir(app.reposRoot, o, n), | |
| 83 | "+refs/heads/*:refs/heads/*", | |
| 84 | "+refs/tags/*:refs/tags/*", | |
| 85 | ], | |
| 86 | { cwd: repoDir(child.reposRoot, orgName, FORGE_CODE_NAME), timeoutMs: 600_000 }, | |
| 87 | ); | |
| 88 | if (!fetched.ok) return err(`could not copy the forge's code: ${fetched.error.stderr}`); | |
| 89 | const born = await chainForChild(app, `${o}/${n}`); | |
| 90 | return ok({ upstream: `${o}/${n}`, mode, tip: born?.tip ?? "", chain: born?.chain ?? [] }); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Make the fork the child's own source: its `forgeRepo` names it (an admin's | |
| 95 | * earlier choice stands) and every crew member is seated on it, with the | |
| 96 | * role it holds on the child's other repositories, if any. | |
| 97 | */ | |
| 98 | export function adoptForgeCode(child: App, orgName: string, actorId: string): Repo | undefined { | |
| 99 | const repo = getRepo(child.db, orgName, FORGE_CODE_NAME); | |
| 100 | if (!repo) return undefined; | |
| 101 | if (storedForgeSource(child.db) === undefined) | |
| 102 | setForgeSource(child.db, `${orgName}/${FORGE_CODE_NAME}`, actorId); | |
| 103 | const roleOf = new Map<string, CrewRole>(); | |
| 104 | for (const other of listReposOwnedBy(child.db, repo.ownerId).filter((r) => r.id !== repo.id)) | |
| 105 | for (const { agent, role } of listCrewRoles(child.db, other.id)) | |
| 106 | if (!roleOf.has(agent.user.id)) roleOf.set(agent.user.id, role); | |
| 107 | for (const agent of listAgents(child.db)) { | |
| 108 | addCrew(child.db, repo.id, agent.user.id, actorId); | |
| 109 | const role = roleOf.get(agent.user.id); | |
| 110 | if (role !== undefined) setCrewRole(child.db, repo.id, agent.user.id, role); | |
| 111 | } | |
| 112 | return repo; | |
| 113 | } | |
| 114 | ||
| 115 | export type Backfilled = "made" | "already" | "left-alone" | "no-code" | "not-a-child"; | |
| 116 | ||
| 117 | /** | |
| 118 | * Give an existing child its own source (#10989), once: forks the parent's | |
| 119 | * code into `<org>/forge`, records it in the child's `parent.json`, names it | |
| 120 | * as `forgeRepo`, and seats the crew. A child that already has it is | |
| 121 | * `already`; a repository of that name nobody here made is `left-alone`, | |
| 122 | * untouched. "We made it" is a record, `forgeCodeStarted`, written to | |
| 123 | * `parent.json` before the repository is created: a run that died after the | |
| 124 | * repo but before the fetch or the `forgeCode` record finds it on the next | |
| 125 | * pass and finishes (both steps are idempotent), where a repo without the | |
| 126 | * record is a person's. | |
| 127 | */ | |
| 128 | export async function backfillForgeCode( | |
| 129 | app: App, | |
| 130 | child: App, | |
| 131 | childDir: string, | |
| 132 | mode: FollowMode = DEFAULT_MODE, | |
| 133 | ): Promise<Result<Backfilled, string>> { | |
| 134 | const file = join(childDir, "parent.json"); | |
| 135 | let doc: Record<string, unknown>; | |
| 136 | try { | |
| 137 | const parsed = await Bun.file(file).json(); | |
| 138 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return ok("not-a-child"); | |
| 139 | doc = parsed as Record<string, unknown>; | |
| 140 | } catch { | |
| 141 | return ok("not-a-child"); | |
| 142 | } | |
| 143 | const orgName = doc["org"]; | |
| 144 | const org = typeof orgName === "string" ? getOrg(child.db, orgName) : undefined; | |
| 145 | if (org === undefined || typeof orgName !== "string") return ok("not-a-child"); | |
| 146 | const ownersTeam = listTeams(child.db, org.id).find((t) => t.name === OWNERS_TEAM); | |
| 147 | const owner = ownersTeam === undefined ? undefined : listTeamMembers(child.db, ownersTeam.id)[0]; | |
| 148 | const actorId = owner?.id ?? org.id; | |
| 149 | ||
| 150 | const have = doc["forgeCode"] !== undefined && doc["forgeCode"] !== null; | |
| 151 | const name = `${orgName}/${FORGE_CODE_NAME}`; | |
| 152 | const unfinished = doc["forgeCodeStarted"] === name; | |
| 153 | if (!have && !unfinished && getRepo(child.db, orgName, FORGE_CODE_NAME) !== undefined) return ok("left-alone"); | |
| 154 | let made = false; | |
| 155 | if (!have) { | |
| 156 | const write = async (next: Record<string, unknown>): Promise<void> => { | |
| 157 | await Bun.write(`${file}.tmp`, JSON.stringify(next)); | |
| 158 | await rename(`${file}.tmp`, file); | |
| 159 | }; | |
| 160 | // Only a parent that has code to give starts the record: otherwise a | |
| 161 | // person's later `forge` repo would read as ours. | |
| 162 | const ref = await forgeCodeRef(app); | |
| 163 | const [o, n] = ref?.split("/") ?? []; | |
| 164 | if (ref === undefined || !o || !n || getRepo(app.db, o, n) === undefined) return ok("no-code"); | |
| 165 | if (!unfinished) await write({ ...doc, forgeCodeStarted: name }); | |
| 166 | const forked = await forkForgeCode(app, child, orgName, mode); | |
| 167 | if (!forked.ok) return err(forked.error); | |
| 168 | if (forked.value === undefined) return ok("no-code"); | |
| 169 | const { forgeCodeStarted: _started, ...rest } = doc; | |
| 170 | await write({ ...rest, forgeCode: { repo: name, ...forked.value } }); | |
| 171 | made = true; | |
| 172 | } | |
| 173 | adoptForgeCode(child, orgName, actorId); | |
| 174 | return ok(made ? "made" : "already"); | |
| 175 | } | |
outdated
Hardcoded child names in source: a one-off migration that stays forever. Make it a recorded one-shot. |
src/spawn/forge_code_backfill.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * The one-shot backfill of #10989: the children that exist the first time | |
| 3 | * this runs, and were born before every child came with its own source, get | |
| 4 | * `<org>/forge` too. The run is recorded in `forge-code-backfill.json` under | |
| 5 | * the parent's data directory: the children it covers, and what became of | |
| 6 | * each. A later boot reads that one file and, once every child is settled, | |
| 7 | * opens nothing. Children born afterwards are never in it (they are made | |
| 8 | * with their source). | |
| 9 | * | |
| 10 | * Opening a child's database writes (migrations, WAL), so a child that is | |
| 11 | * awake, open here or running in a cell, is not touched: it stays unsettled | |
| 12 | * and is tried on the next boot. A child whose `parent.json` already records | |
| 13 | * its source is settled without opening its database. | |
| 14 | */ | |
| 15 | ||
| 16 | import { rename } from "node:fs/promises"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import type { App } from "../app.ts"; | |
| 19 | import { createApp } from "../app.ts"; | |
| 20 | import { childDirOf, listChildren } from "../actions/spawn.ts"; | |
| 21 | import { backfillForgeCode, type Backfilled } from "./forge_code.ts"; | |
| 22 | import { childAwake } from "./host.ts"; | |
| 23 | ||
| 24 | export type Outcome = Backfilled | { error: string }; | |
| 25 | interface Record_ { | |
| 26 | readonly considered: string[]; | |
| 27 | readonly done: Record<string, Outcome>; | |
| 28 | } | |
| 29 | ||
| 30 | const recordPath = (app: App): string => join(app.config.dataDir, "forge-code-backfill.json"); | |
| 31 | ||
| 32 | async function readRecord(app: App): Promise<Record_ | undefined> { | |
| 33 | try { | |
| 34 | const doc = (await Bun.file(recordPath(app)).json()) as Partial<Record_>; | |
| 35 | if (!Array.isArray(doc.considered) || typeof doc.done !== "object" || doc.done === null) return undefined; | |
outdated
Trailing blank line. Also this opens live child databases on every boot; justify or guard it. |
||
| 36 | return { considered: doc.considered.filter((n) => typeof n === "string"), done: doc.done }; | |
| 37 | } catch { | |
| 38 | return undefined; | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | async function writeRecord(app: App, rec: Record_): Promise<void> { | |
| 43 | const tmp = `${recordPath(app)}.tmp`; | |
| 44 | await Bun.write(tmp, JSON.stringify(rec)); | |
| 45 | await rename(tmp, recordPath(app)); | |
| 46 | } | |
| 47 | ||
| 48 | const settled = (o: Outcome | undefined): boolean => o !== undefined && typeof o === "string"; | |
| 49 | ||
| 50 | async function recordsSource(dir: string): Promise<boolean> { | |
| 51 | try { | |
| 52 | const doc = (await Bun.file(join(dir, "parent.json")).json()) as Record<string, unknown>; | |
| 53 | return doc["forgeCode"] !== undefined && doc["forgeCode"] !== null; | |
| 54 | } catch { | |
| 55 | return false; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | /** Returns what happened to the children it tried this boot. */ | |
| 60 | export async function backfillChildren(app: App): Promise<Record<string, Outcome>> { | |
| 61 | const children = (await listChildren(app)).filter((c) => c.done); | |
| 62 | let rec = await readRecord(app); | |
| 63 | if (rec === undefined) { | |
| 64 | rec = { considered: children.map((c) => c.name), done: {} }; | |
| 65 | await writeRecord(app, rec); | |
| 66 | } | |
| 67 | const tried: Record<string, Outcome> = {}; | |
| 68 | for (const c of children) { | |
| 69 | if (!rec.considered.includes(c.name) || settled(rec.done[c.name])) continue; | |
| 70 | const dir = childDirOf(app, c.bodyId); | |
| 71 | if (await recordsSource(dir)) { | |
| 72 | rec.done[c.name] = "already"; | |
| 73 | continue; | |
| 74 | } | |
| 75 | if (childAwake(app, c.name)) continue; | |
| 76 | let child: App | undefined; | |
| 77 | try { | |
| 78 | child = createApp({ dataDir: dir }); | |
| 79 | const r = await backfillForgeCode(app, child, dir); | |
| 80 | tried[c.name] = rec.done[c.name] = r.ok ? r.value : { error: r.error }; | |
| 81 | } catch (e) { | |
| 82 | tried[c.name] = rec.done[c.name] = { error: String(e) }; | |
| 83 | } finally { | |
| 84 | child?.close(); | |
| 85 | } | |
| 86 | } | |
| 87 | await writeRecord(app, rec); | |
| 88 | return tried; | |
| 89 | } |
test/forge-code-own-source.test.ts added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | /** | |
| 2 | * Every new forge comes with its own source (#10989): `<org>/forge`, named as | |
| 3 | * its `forgeRepo`, upstream wired to the parent's code; existing children get | |
| 4 | * it by an idempotent backfill that leaves a person's own repo alone. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { addCrew, createAgentRow, listCrewRoles } from "../src/core/agents.ts"; | |
| 15 | import { getOrg } from "../src/core/orgs.ts"; | |
| 16 | import { createRepo as createRepoCore, getRepo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 18 | import { runGit } from "../src/git/env.ts"; | |
| 19 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | import { commitFiles } from "../src/git/write.ts"; | |
| 21 | import { forgeLinkOf } from "../src/spawn/chain.ts"; | |
| 22 | import { backfillForgeCode } from "../src/spawn/forge_code.ts"; | |
| 23 | import { backfillChildren } from "../src/spawn/forge_code_backfill.ts"; | |
| 24 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 25 | import { setForgeSource, storedForgeSource } from "../src/settings/forge_source.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | const source = { catalog: [], briefs: [], subscriptions: [] }; | |
| 31 | const spawn = (app: App, owner: User, name: string) => | |
| 32 | spawnForge( | |
| 33 | app, | |
| 34 | owner, | |
| 35 | { name, org: name, apps: [], carryBriefs: false, carrySubscriptions: false }, | |
| 36 | source, | |
| 37 | { | |
| 38 | placement: hostedPlacement({ host: "root.test" }), | |
| 39 | request: new Request("https://root.test/x", { method: "POST", headers: { origin: "https://root.test" } }), | |
| 40 | url: new URL("https://root.test/x"), | |
| 41 | }, | |
| 42 | ); | |
| 43 | ||
| 44 | describe("a forge's own source", () => { | |
| 45 | let dir = ""; | |
| 46 | let root: App; | |
| 47 | let alice: User; | |
| 48 | const open: App[] = []; | |
| 49 | let n = 0; | |
| 50 | ||
| 51 | /** A child born of a parent that named no code, so it has no `forge` repo: how the children before this began. */ | |
| 52 | async function bornBare(name: string): Promise<{ child: App; childDir: string; parent: App }> { | |
| 53 | const parent = createApp({ dataDir: join(dir, `bare-${++n}`), url: `https://bare${n}.test`, port: 0 }); | |
| 54 | open.push(parent); | |
| 55 | const u = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 56 | if (!u.ok) throw new Error("user"); | |
| 57 | const born = await spawn(parent, u.value, name); | |
| 58 | if (!born.ok) throw new Error(JSON.stringify(born.error)); | |
| 59 | const childDir = childDirOf(parent, born.value.bodyId); | |
| 60 | const child = createApp({ dataDir: childDir, port: 0 }); | |
| 61 | open.push(child); | |
| 62 | expect(getRepo(child.db, name, "forge")).toBeUndefined(); | |
| 63 | return { child, childDir, parent }; | |
| 64 | } | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | dir = await mkdtemp(join(tmpdir(), "forge-own-")); | |
| 68 | root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" }); | |
| 69 | const u = await createUser(root.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 70 | if (!u.ok) throw new Error("user"); | |
| 71 | alice = u.value; | |
| 72 | const made = await createRepo(root, alice, { name: "forge-code", visibility: "public" }); | |
| 73 | if (!made.ok) throw new Error("repo"); | |
| 74 | const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), { | |
| 75 | branch: "main", | |
| 76 | message: "forge v1", | |
| 77 | files: [{ path: "README.md", content: "# forge\n" }], | |
| 78 | author: { name: "a", email: "[email protected]" }, | |
| 79 | }); | |
| 80 | if (!c.ok) throw new Error("commit"); | |
| 81 | }, 120_000); | |
| 82 | ||
| 83 | afterAll(async () => { | |
| 84 | for (const a of open) a.close(); | |
| 85 | root?.close(); | |
| 86 | await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a spawned child has <admin>/forge, its forgeRepo setting and the parent as origin", async () => { | |
| 90 | const r = await spawn(root, alice, "kid"); | |
| 91 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 92 | const child = createApp({ dataDir: childDirOf(root, r.value.bodyId), port: 0 }); | |
| 93 | open.push(child); | |
| 94 | expect(getRepo(child.db, "kid", "forge")).toBeDefined(); | |
| 95 | expect(storedForgeSource(child.db)).toBe("kid/forge"); | |
| 96 | const link = await forgeLinkOf(child); | |
| 97 | expect(link?.repo).toBe("kid/forge"); | |
| 98 | expect(link?.upstream).toBe("alice/forge-code"); | |
| 99 | }, 240_000); | |
| 100 | ||
| 101 | test("backfill gives an existing child its source once", async () => { | |
| 102 | const { child, childDir } = await bornBare("old"); | |
| 103 | expect((await backfillForgeCode(root, child, childDir)).ok).toBe(true); | |
| 104 | expect(getRepo(child.db, "old", "forge")).toBeDefined(); | |
| 105 | expect(storedForgeSource(child.db)).toBe("old/forge"); | |
| 106 | expect((await forgeLinkOf(child))?.upstream).toBe("alice/forge-code"); | |
| 107 | const again = await backfillForgeCode(root, child, childDir); | |
| 108 | expect(again.ok && again.value).toBe("already"); | |
| 109 | }, 240_000); | |
| 110 | ||
| 111 | test("a repo a person made under that name stays theirs", async () => { | |
| 112 | const { child, childDir } = await bornBare("mine"); | |
| 113 | const theirs = await createRepoCore(child.db, child.reposRoot, { ownerId: getOrg(child.db, "mine")!.id, name: "forge", visibility: "private" }); | |
| 114 | if (!theirs.ok) throw new Error("theirs"); | |
| 115 | const left = await backfillForgeCode(root, child, childDir); | |
| 116 | expect(left.ok && left.value).toBe("left-alone"); | |
| 117 | expect(storedForgeSource(child.db)).toBeUndefined(); | |
| 118 | expect((await Bun.file(join(childDir, "parent.json")).json()).forgeCodeStarted).toBeUndefined(); | |
| 119 | }, 240_000); | |
| 120 | ||
| 121 | test("a repo our own earlier run left unfinished is finished, not left alone", async () => { | |
| 122 | const { child, childDir } = await bornBare("half"); | |
| 123 | // The run died after creating the repo and recording that it began: no refs, no forgeCode. | |
| 124 | const empty = await createRepoCore(child.db, child.reposRoot, { ownerId: getOrg(child.db, "half")!.id, name: "forge", visibility: "public" }); | |
| 125 | if (!empty.ok) throw new Error("empty"); | |
| 126 | const file = join(childDir, "parent.json"); | |
| 127 | await Bun.write(file, JSON.stringify({ ...(await Bun.file(file).json()), forgeCodeStarted: "half/forge" })); | |
| 128 | const r = await backfillForgeCode(root, child, childDir); | |
| 129 | expect(r.ok && r.value).toBe("made"); | |
| 130 | const doc = await Bun.file(file).json(); | |
| 131 | expect(doc.forgeCode.repo).toBe("half/forge"); | |
| 132 | expect(doc.forgeCodeStarted).toBeUndefined(); | |
| 133 | const head = await runGit(["rev-parse", "--verify", "refs/heads/main"], { cwd: repoDir(child.reposRoot, "half", "forge") }); | |
| 134 | expect(head.ok).toBe(true); | |
| 135 | expect(storedForgeSource(child.db)).toBe("half/forge"); | |
| 136 | }, 240_000); | |
| 137 | ||
| 138 | test("the crew is seated on it with the role held on the child's other repos, and an earlier forgeRepo stands", async () => { | |
| 139 | const { child, childDir } = await bornBare("crewed"); | |
| 140 | const mk = async (username: string) => { | |
| 141 | const a = await createUser(child.db, { username, email: `${username}@x.test`, agent: true, origin: "agent" }); | |
| 142 | if (!a.ok) throw new Error("agent"); | |
| 143 | createAgentRow(child.db, { userId: a.value.id, roles: ["builder", "planner"] }); | |
| 144 | return a.value; | |
| 145 | }; | |
| 146 | const pia = await mk("pia"); | |
| 147 | await mk("bo"); | |
| 148 | const other = await createRepoCore(child.db, child.reposRoot, { ownerId: getOrg(child.db, "crewed")!.id, name: "other", visibility: "private" }); | |
| 149 | if (!other.ok) throw new Error("other"); | |
| 150 | addCrew(child.db, other.value.id, pia.id, undefined, "builder"); | |
| 151 | // An admin already chose a source: it stands. | |
| 152 | setForgeSource(child.db, "crewed/custom", pia.id); | |
| 153 | ||
| 154 | expect((await backfillForgeCode(root, child, childDir)).ok).toBe(true); | |
| 155 | const repo = getRepo(child.db, "crewed", "forge")!; | |
| 156 | const seats = new Map(listCrewRoles(child.db, repo.id).map((s) => [s.agent.user.username, s.role])); | |
| 157 | expect(seats.get("pia")).toBe("builder"); // copied, not the first role she declares (planner) | |
| 158 | expect(seats.get("bo")).toBe("planner"); // no seat elsewhere: the first role declared | |
| 159 | expect(storedForgeSource(child.db)).toBe("crewed/custom"); | |
| 160 | }, 240_000); | |
| 161 | ||
| 162 | test("the backfill of all children is a recorded one-shot", async () => { | |
| 163 | const { parent, child } = await bornBare("later"); | |
| 164 | expect(Object.keys(await backfillChildren(parent))).toEqual(["later"]); | |
| 165 | // Recorded as settled: the next boot opens nothing, and a child born afterwards is not swept in. | |
| 166 | expect(getRepo(child.db, "later", "forge")).toBeUndefined(); // the bare parent names no code | |
| 167 | expect(await Bun.file(join(parent.config.dataDir, "forge-code-backfill.json")).exists()).toBe(true); | |
| 168 | const u = getUserByName(parent.db, "alice")!; | |
| 169 | const born = await spawn(parent, u, "newer"); | |
| 170 | if (!born.ok) throw new Error(JSON.stringify(born.error)); | |
| 171 | expect(await backfillChildren(parent)).toEqual({}); | |
| 172 | }, 240_000); | |
| 173 | }); |
A repo left by an unfinished earlier run (created, fetch failed, parent.json unwritten) lands here as 'left-alone' for good. Distinguish it and test it.