Public: anyone can read this; the crew can change it.

Absorb: how it fits packs, flows/radar and forge-to-forge #9759

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9545-absorb-how-it-fits-packs-flows-radar-and → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/9545-absorb-how-it-fits-packs-flows-radar-and was merged into main as 524ce88.

Review

Sign in to review this change.
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.
6868 - 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`.
6969 - 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.
7070
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
7177 ## Not done yet
7278
7379 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 = {}
127127 if (r.ok) return { ok: true };
128128 return { ok: false, why: `the forge answered ${r.status}` };
129129 },
130 async absorb(owner, name) {

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.

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 },
130143 async makeAgent(a) {
131144 const r = await fetch(`${url}/api/v1/agents`, {
132145 method: "POST",
src/packs/deploy.ts +10
@@ -20,6 +20,8 @@ export interface Hands {
2020 carryHistory(source: string, owner: string, name: string, branch: string | undefined): Promise<{ ok: true; branch?: string } | { ok: false; why: string }>;
2121 /** 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. */
2222 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 }>;
2325 makeAgent(a: Pack["crew"][number]): Promise<{ ok: true } | { ok: false; exists: boolean; why: string }>;
2426 }
2527
@@ -29,6 +31,12 @@ export interface Step {
2931 readonly note?: string;
3032 }
3133
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
3240 export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Step[]> {
3341 const steps: Step[] = [];
3442 for (const app of pack.apps) {
@@ -52,6 +60,7 @@ export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Ste
5260 ? { what: `app ${app.name}`, state: "kept", note: "already here" }
5361 : { what: `app ${app.name}`, state: "failed", note: `already here, not followed: ${followed.why}` },
5462 );
63 if (followed.ok && app.absorb === true) steps.push(await absorbStep(hands, owner, app.name));
5564 continue;
5665 }
5766 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
6574 ? { what: `app ${app.name}`, state: "done", note: `history from ${app.source}, following it` }
6675 : { what: `app ${app.name}`, state: "failed", note: `history carried, not followed: ${followed.why}` },
6776 );
77 if (followed.ok && app.absorb === true) steps.push(await absorbStep(hands, made.owner, app.name));
6878 }
6979 for (const member of pack.crew) {
7080 const made = await hands.makeAgent(member);
src/packs/pack.ts +4 −1
@@ -28,6 +28,8 @@ export interface PackApp {
2828 /** An https address of a public git repository, no credentials in it. */
2929 readonly source: string;
3030 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;
3133 }
3234
3335 export interface PackCrew {
@@ -122,7 +124,8 @@ export function parsePack(raw: string): Result<Pack, string> {
122124 const branch = r.branch === undefined ? undefined : text(r.branch, 100);
123125 if (r.branch !== undefined && (branch === undefined || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch)))
124126 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 } : {}) });
126129 }
127130
128131 const crew: PackCrew[] = [];
test/packs-hands.test.ts +54
@@ -124,3 +124,57 @@ describe("terminal screen", () => {
124124 tui.stop?.();
125125 });
126126 });
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", () => {
4747 });
4848 });
4949
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
50102 describe("shipped packs", () => {
51103 test("Games and Business ship and parse", () => {
52104 const c = catalog();
@@ -75,6 +127,9 @@ describe("deploy", () => {
75127 calls.push(`follow ${owner}/${name} ${source} ${branch}`);
76128 return { ok: true };
77129 },
130 async absorb() {
131 return { ok: true };
132 },
78133 async makeAgent(a) {
79134 calls.push(`agent ${a.username}`);
80135 return { ok: false, exists: false, why: "no" };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs