Public: anyone can read this; the crew can change it.
Absorb: how it fits packs, flows/radar and forge-to-forge #9759
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9545-absorb-how-it-fits-packs-flows-radar-and → main 2 commits fork 1 thread · 1 unresolved
6 files
changed +142
−1
docs/features/absorb.md +6
| @@ -68,6 +68,12 @@ See `docs/features/absorb-review.md` for the findings of #9441. | ||
| 68 | 68 | - Code: `src/apps/feature.ts` (the declaration), `src/core/features.ts` (storage), `src/actions/features.ts` (the rules), `src/apps/feature_env.ts` (the environment), `src/web/pages/features.ts`, `src/http/api/features.ts`, `src/http/mcp/tools/features.ts`. |
| 69 | 69 | - Tests: `test/features.test.ts` holds the rules. `test/apps-features.test.ts` runs a real app: it absorbs the app, changes a setting and reads the variables back from the running process. |
| 70 | 70 | |
| 71 | ## Packs | |
| 72 | ||
| 73 | A pack's app may carry `"absorb": true` (a boolean, nothing else). `pack deploy` then absorbs it as the deploying admin's act, with the `app.absorbed` event, after the app is made and followed. The app must declare a `feature` in its `forge.json`: if it does not, the step is reported as failed and the app stays made. A pack still carries no setting values. | |
| 74 | ||
| 75 | Settings stay behind on a fork. A fork of another forge's app carries its `feature` block, which declares and does nothing; the new forge has its own settings (the declared defaults until an admin changes them) and its own admin decides on the absorb. | |
| 76 | ||
| 71 | 77 | ## Not done yet |
| 72 | 78 | |
| 73 | 79 | The forge does not yet fire a feature's schedule, show its feed items, serve its MCP tools inside the forge's `/mcp`, keep `secret` settings or show its health on `/system`. |
scripts/pack.ts +13
| @@ -127,6 +127,19 @@ export function realHands(url: string, token: string, options: HandsOptions = {} | ||
| 127 | 127 | if (r.ok) return { ok: true }; |
| 128 | 128 | return { ok: false, why: `the forge answered ${r.status}` }; |
| 129 | 129 | }, |
| 130 | async absorb(owner, name) { | |
| 131 | const auth = { authorization: `Bearer ${token}` }; | |
| 132 | const listed = await fetch(`${url}/api/v1/features`, { headers: auth }).catch(() => null); | |
| 133 | if (listed === null || !listed.ok) return { ok: false, why: listed === null ? `cannot reach ${url}` : `the forge answered ${listed.status}` }; | |
| 134 | const body = (await listed.json().catch(() => null)) as { items?: { slug?: string; repo?: string }[] } | null; | |
| 135 | const row = body?.items?.find((f) => f.repo === `${owner}/${name}`); | |
| 136 | if (typeof row?.slug !== "string") return { ok: false, why: "the app declares no feature in its forge.json, so there is nothing to absorb" }; | |
| 137 | const r = await fetch(`${url}/api/v1/features/${encodeURIComponent(row.slug)}`, { method: "PUT", headers: auth }).catch(() => null); | |
| 138 | if (r === null) return { ok: false, why: `cannot reach ${url}` }; | |
| 139 | if (r.ok) return { ok: true }; | |
| 140 | if (r.status === 403) return { ok: false, why: "the forge refused (403): absorbing needs a site admin's token with admin scope, and this token is not one" }; | |
| 141 | return { ok: false, why: `the forge answered ${r.status}` }; | |
| 142 | }, | |
| 130 | 143 | async makeAgent(a) { |
| 131 | 144 | const r = await fetch(`${url}/api/v1/agents`, { |
| 132 | 145 | method: "POST", |
src/packs/deploy.ts +10
| @@ -20,6 +20,8 @@ export interface Hands { | ||
| 20 | 20 | carryHistory(source: string, owner: string, name: string, branch: string | undefined): Promise<{ ok: true; branch?: string } | { ok: false; why: string }>; |
| 21 | 21 | /** Make the app follow `source` on `branch`, so Settings shows it and a new upstream commit shows as drift. No branch is a failure: a wrong one is never guessed. */ |
| 22 | 22 | follow(owner: string, name: string, source: string, branch: string | undefined): Promise<{ ok: true } | { ok: false; why: string }>; |
| 23 | /** Absorb the app as a feature, as the deploying admin. Refused when the app declares no feature: a repository cannot promote itself. */ | |
| 24 | absorb(owner: string, name: string): Promise<{ ok: true } | { ok: false; why: string }>; | |
| 23 | 25 | makeAgent(a: Pack["crew"][number]): Promise<{ ok: true } | { ok: false; exists: boolean; why: string }>; |
| 24 | 26 | } |
| 25 | 27 | |
| @@ -29,6 +31,12 @@ export interface Step { | ||
| 29 | 31 | readonly note?: string; |
| 30 | 32 | } |
| 31 | 33 | |
| 34 | /** Absorbing is a step of its own, so a refusal (no declared feature) is reported and never hides that the app is made. */ | |
| 35 | async function absorbStep(hands: Hands, owner: string, name: string): Promise<Step> { | |
| 36 | const r = await hands.absorb(owner, name); | |
| 37 | return r.ok ? { what: `absorb ${name}`, state: "done", note: "a feature in the menu" } : { what: `absorb ${name}`, state: "failed", note: r.why }; | |
| 38 | } | |
| 39 | ||
| 32 | 40 | export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Step[]> { |
| 33 | 41 | const steps: Step[] = []; |
| 34 | 42 | for (const app of pack.apps) { |
| @@ -52,6 +60,7 @@ export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Ste | ||
| 52 | 60 | ? { what: `app ${app.name}`, state: "kept", note: "already here" } |
| 53 | 61 | : { what: `app ${app.name}`, state: "failed", note: `already here, not followed: ${followed.why}` }, |
| 54 | 62 | ); |
| 63 | if (followed.ok && app.absorb === true) steps.push(await absorbStep(hands, owner, app.name)); | |
| 55 | 64 | continue; |
| 56 | 65 | } |
| 57 | 66 | const carried = await hands.carryHistory(app.source, made.owner, app.name, app.branch); |
| @@ -65,6 +74,7 @@ export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Ste | ||
| 65 | 74 | ? { what: `app ${app.name}`, state: "done", note: `history from ${app.source}, following it` } |
| 66 | 75 | : { what: `app ${app.name}`, state: "failed", note: `history carried, not followed: ${followed.why}` }, |
| 67 | 76 | ); |
| 77 | if (followed.ok && app.absorb === true) steps.push(await absorbStep(hands, made.owner, app.name)); | |
| 68 | 78 | } |
| 69 | 79 | for (const member of pack.crew) { |
| 70 | 80 | const made = await hands.makeAgent(member); |
src/packs/pack.ts +4 −1
| @@ -28,6 +28,8 @@ export interface PackApp { | ||
| 28 | 28 | /** An https address of a public git repository, no credentials in it. */ |
| 29 | 29 | readonly source: string; |
| 30 | 30 | readonly branch?: string; |
| 31 | /** Deploy absorbs the app as a feature, as the deploying admin's act (an event). Only a yes is carried; it sets no setting. */ | |
| 32 | readonly absorb?: boolean; | |
| 31 | 33 | } |
| 32 | 34 | |
| 33 | 35 | export interface PackCrew { |
| @@ -122,7 +124,8 @@ export function parsePack(raw: string): Result<Pack, string> { | ||
| 122 | 124 | const branch = r.branch === undefined ? undefined : text(r.branch, 100); |
| 123 | 125 | if (r.branch !== undefined && (branch === undefined || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch))) |
| 124 | 126 | return bad(`app "${appName}" has an unusable "branch"`); |
| 125 | apps.push({ name: appName, description: about, source, ...(branch === undefined ? {} : { branch }) }); | |
| 127 | if (r.absorb !== undefined && typeof r.absorb !== "boolean") return bad(`app "${appName}" has an unusable "absorb": it is true or false`); | |
| 128 | apps.push({ name: appName, description: about, source, ...(branch === undefined ? {} : { branch }), ...(r.absorb === true ? { absorb: true } : {}) }); | |
| 126 | 129 | } |
| 127 | 130 | |
| 128 | 131 | const crew: PackCrew[] = []; |
test/packs-hands.test.ts +54
| @@ -124,3 +124,57 @@ describe("terminal screen", () => { | ||
| 124 | 124 | tui.stop?.(); |
| 125 | 125 | }); |
| 126 | 126 | }); |
| 127 | ||
| 128 | describe("absorb hand", () => { | |
| 129 | async function withForge(put: number, items: { slug: string; repo: string }[], run: (url: string, puts: string[]) => Promise<void>) { | |
| 130 | const puts: string[] = []; | |
| 131 | const s = Bun.serve({ | |
| 132 | port: 0, | |
| 133 | fetch(req) { | |
| 134 | const u = new URL(req.url); | |
| 135 | if (u.pathname === "/api/v1/features" && req.method === "GET") return Response.json({ items }); | |
| 136 | if (u.pathname.startsWith("/api/v1/features/") && req.method === "PUT") { | |
| 137 | puts.push(u.pathname); | |
| 138 | return new Response("{}", { status: put }); | |
| 139 | } | |
| 140 | return new Response("no", { status: 404 }); | |
| 141 | }, | |
| 142 | }); | |
| 143 | try { | |
| 144 | await run(`http://127.0.0.1:${s.port}`, puts); | |
| 145 | } finally { | |
| 146 | await s.stop(true); | |
| 147 | } | |
| 148 | } | |
| 149 | test("a listed app is absorbed through its own slug", async () => { | |
| 150 | await withForge(200, [{ slug: "other", repo: "o/other" }, { slug: "radar-1", repo: "o/radar" }], async (url, puts) => { | |
| 151 | expect(await realHands(url, "t").absorb("o", "radar")).toEqual({ ok: true }); | |
| 152 | expect(puts).toEqual(["/api/v1/features/radar-1"]); | |
| 153 | }); | |
| 154 | }); | |
| 155 | test("an app that declares no feature fails the step and sends no PUT", async () => { | |
| 156 | await withForge(200, [{ slug: "other", repo: "o/other" }], async (url, puts) => { | |
| 157 | const r = await realHands(url, "t").absorb("o", "radar"); | |
| 158 | expect(r.ok).toBe(false); | |
| 159 | if (!r.ok) expect(r.why).toContain("declares no feature"); | |
| 160 | expect(puts).toEqual([]); | |
| 161 | }); | |
| 162 | }); | |
| 163 | test("a 403 names the missing admin; another status is reported as is", async () => { | |
| 164 | await withForge(403, [{ slug: "radar", repo: "o/radar" }], async (url) => { | |
| 165 | const r = await realHands(url, "t").absorb("o", "radar"); | |
| 166 | expect(r.ok).toBe(false); | |
| 167 | if (!r.ok) expect(r.why).toMatch(/site admin/); | |
| 168 | }); | |
| 169 | await withForge(500, [{ slug: "radar", repo: "o/radar" }], async (url) => { | |
| 170 | const r = await realHands(url, "t").absorb("o", "radar"); | |
| 171 | if (!r.ok) expect(r.why).toContain("500"); | |
| 172 | expect(r.ok).toBe(false); | |
| 173 | }); | |
| 174 | }); | |
| 175 | test("an unreachable forge fails the step", async () => { | |
| 176 | const r = await realHands("http://127.0.0.1:1", "t").absorb("o", "radar"); | |
| 177 | expect(r.ok).toBe(false); | |
| 178 | if (!r.ok) expect(r.why).toContain("cannot reach"); | |
| 179 | }); | |
| 180 | }); |
test/packs.test.ts +55
| @@ -47,6 +47,58 @@ describe("pack definition", () => { | ||
| 47 | 47 | }); |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | describe("pack absorb", () => { | |
| 51 | const withAbsorb = (absorb: unknown) => parse({ ...good, apps: [{ ...good.apps[0], absorb }] }); | |
| 52 | test("absorb is a boolean on an app; anything else is refused", () => { | |
| 53 | const yes = withAbsorb(true); | |
| 54 | expect(yes.ok && yes.value.apps[0]!.absorb).toBe(true); | |
| 55 | const no = withAbsorb(false); | |
| 56 | expect(no.ok && no.value.apps[0]!.absorb).toBeUndefined(); | |
| 57 | for (const v of ["yes", 1, {}, null]) expect(withAbsorb(v).ok).toBe(false); | |
| 58 | }); | |
| 59 | const hands = (calls: string[], absorbed: { ok: boolean }): Hands => ({ | |
| 60 | async makeApp(name) { | |
| 61 | return name === "kept" ? { ok: false, exists: true, why: "x", owner: "me" } : { ok: true, owner: "me" }; | |
| 62 | }, | |
| 63 | async defaultBranch() { | |
| 64 | return "main"; | |
| 65 | }, | |
| 66 | async carryHistory() { | |
| 67 | return { ok: true }; | |
| 68 | }, | |
| 69 | async follow() { | |
| 70 | return { ok: true }; | |
| 71 | }, | |
| 72 | async absorb(owner, name) { | |
| 73 | calls.push(`absorb ${owner}/${name}`); | |
| 74 | return absorbed.ok ? { ok: true } : { ok: false, why: "declares no feature" }; | |
| 75 | }, | |
| 76 | async makeAgent() { | |
| 77 | return { ok: true }; | |
| 78 | }, | |
| 79 | }); | |
| 80 | const apps = [ | |
| 81 | { name: "a", description: "d", source: "https://github.com/a/b", absorb: true }, | |
| 82 | { name: "plain", description: "d", source: "https://github.com/a/c" }, | |
| 83 | { name: "kept", description: "d", source: "https://github.com/a/d", absorb: true }, | |
| 84 | ]; | |
| 85 | test("deploy absorbs exactly the apps that ask, made or kept", async () => { | |
| 86 | const calls: string[] = []; | |
| 87 | const pack = parse({ ...good, crew: [], apps }); | |
| 88 | if (!pack.ok) throw new Error(pack.error); | |
| 89 | const steps = await deployPack(pack.value, hands(calls, { ok: true })); | |
| 90 | expect(calls).toEqual(["absorb me/a", "absorb me/kept"]); | |
| 91 | expect(steps.filter((s) => s.what.startsWith("absorb")).map((s) => s.state)).toEqual(["done", "done"]); | |
| 92 | }); | |
| 93 | test("a refused absorb is reported and leaves the app made", async () => { | |
| 94 | const pack = parse({ ...good, crew: [], apps }); | |
| 95 | if (!pack.ok) throw new Error(pack.error); | |
| 96 | const steps = await deployPack(pack.value, hands([], { ok: false })); | |
| 97 | expect(steps[0]!.state).toBe("done"); | |
| 98 | expect(steps.find((s) => s.what === "absorb a")).toMatchObject({ state: "failed", note: "declares no feature" }); | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 50 | 102 | describe("shipped packs", () => { |
| 51 | 103 | test("Games and Business ship and parse", () => { |
| 52 | 104 | const c = catalog(); |
| @@ -75,6 +127,9 @@ describe("deploy", () => { | ||
| 75 | 127 | calls.push(`follow ${owner}/${name} ${source} ${branch}`); |
| 76 | 128 | return { ok: true }; |
| 77 | 129 | }, |
| 130 | async absorb() { | |
| 131 | return { ok: true }; | |
| 132 | }, | |
| 78 | 133 | async makeAgent(a) { |
| 79 | 134 | calls.push(`agent ${a.username}`); |
| 80 | 135 | return { ok: false, exists: false, why: "no" }; |
new above
offline
No test covers this method. Add one for the listed, unlisted and refused cases. A 403 here also means the deployer is not an admin, so word that failure for a person.