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

A writer can run an adapter's pull now through the API and MCP, without waiting for its schedule #10091

Merged opened this

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

Merged

mason/forge:mason/10084-a-writer-can-run-an-adapter-s-pull-now-t → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/10084-a-writer-can-run-an-adapter-s-pull-now-t was merged into main as 3688fa0.

Review

Sign in to review this change.
8 files changed +412 −17
docs/ADAPTERS.md +11
@@ -71,6 +71,17 @@ An app declares itself an adapter with an optional top-level `adapter` block:
7171 Any other key, at any level, is refused. A block that breaks any of these
7272 rules is invalid, and the forge does not schedule the app.
7373
74 ## Pull now
75
76 A writer need not wait for `schedule.every` to see a pull:
77 `POST /api/v1/repos/:owner/:name/app/pull`, or the MCP tool `app_deploy` with
78 `{ repo, action: "pull" }`, runs one pull through the schedule's own path
79 (`pullAppNow` in `src/apps/pull_schedule.ts`, `src/actions/app_pull.ts`) and
80 answers `{ run }`, the recorded pull run. It is refused 404 when the default
81 branch's `forge.json` has no valid `adapter` block, and 409 when a pull of
82 the app is already in flight or its production is not running. A sleeping
83 app is refused, never woken.
84
7485 ## Source hosts and egress
7586
7687 Every host in `source.hosts` must also appear in the top-level `egress` list.
src/actions/app_pull.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * Pull now (#10084): a writer runs one adapter pull through the same path as
3 * the schedule (`apps/pull_schedule.ts`), without waiting for
4 * `schedule.every`. Shared by `POST …/app/pull` and the MCP `app_deploy`
5 * tool's `pull` action. A sleeping app is refused, never woken.
6 */
7
8 import type { App } from "../app.ts";
9 import { pullAppNow, runtimePullReach, type PullReach, type PullRefusal } from "../apps/pull_schedule.ts";
10 import { getAppByRepo, type AppRecord } from "../core/apps.ts";
11 import type { Repo } from "../core/repos.ts";
12 import type { PullRun } from "../core/runs.ts";
13 import type { User } from "../core/users.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
15 import { requireWriter, type AccessError } from "./access.ts";
16
17 /** Why a pull now was refused: access, no app, or the refusals the schedule shares (a pull now is never held as "not due"). */
18 export type PullNowError = AccessError | PullRefusal;
19
20 /** What a person is told when a pull now is refused, the same on every surface. */
21 export const PULL_NOW_REFUSALS: Readonly<Record<PullNowError, string>> = {
22 unauthorized: "Sign in to pull an adapter.",
23 forbidden: "You need write access to do that.",
24 not_found: "This repository has no app.",
25 asleep: "The app's production is not running; it is not woken for a pull. Redeploy it first.",
26 in_flight: "A pull of this app is already running.",
27 no_adapter: "This app's forge.json on the default branch has no adapter block.",
28 };
29
30 /** Run one pull of the repository's app now: the recorded run, or why not. `reachOf` is the seam a test replaces. */
31 export async function pullNow(
32 app: App,
33 actor: User | undefined,
34 repo: Repo,
35 reachOf: (record: AppRecord) => PullReach = (record) => runtimePullReach(app, record),
36 ): Promise<Result<PullRun, PullNowError>> {
37 const writer = requireWriter(app, actor, repo);
38 if (!writer.ok) return writer;
39 const record = getAppByRepo(app.db, repo.id);
40 if (!record) return err("not_found");
41 const pulled = await pullAppNow(app.db, record.id, reachOf(record));
42 return pulled.ok ? ok(pulled.run) : err(pulled.why);
43 }
src/apps/pull_schedule.ts +39 −11
@@ -17,7 +17,7 @@ import type { Database } from "bun:sqlite";
1717 import type { App } from "../app.ts";
1818 import { listApps, type AppRecord } from "../core/apps.ts";
1919 import { getRepoById } from "../core/repos.ts";
20 import { lastPullAt } from "../core/runs.ts";
20 import { lastPullAt, type PullRun } from "../core/runs.ts";
2121 import { resolveOid } from "../git/merge.ts";
2222 import { repoDir } from "../git/repos.ts";
2323 import { parseAdapter, type AdapterSpec } from "./adapter.ts";
@@ -55,29 +55,57 @@ export function answerOf(reply: PullReply): PullAnswer {
5555 return parsePullAnswer(reply.status, reply.body);
5656 }
5757
58 /** Why one pull was not made: asleep (not running, never woken), one in flight, or no `adapter` block. */
59 export type PullRefusal = "asleep" | "in_flight" | "no_adapter";
60
61 /** One pull of one app: the recorded run, or why there was none (`Held` is the caller's own refusal). */
62 export type PullOnce<Held extends string = never> =
63 | { readonly ok: true; readonly run: PullRun }
64 | { readonly ok: false; readonly why: PullRefusal | Held };
65
5866 /**
59 * Pull one app if it is a running adapter and due: whether a pull was made.
60 * The checks run cheapest first — running, then in flight, then declared,
61 * then due — so a sleeping app costs no git read and is never reached.
67 * Pull one app if it is a running adapter and `hold` lets it: the recorded
68 * run or why not. The checks run cheapest first — running, then in flight,
69 * then declared, then `hold` (the schedule's "not due") — so a sleeping app
70 * costs no git read and is never reached. The schedule and "pull now" share
71 * this one path; only the schedule passes a `hold`.
6272 */
63 export async function pullApp(db: Database, appId: string, reach: PullReach, now: () => number = Date.now): Promise<boolean> {
73 async function pullOnce<Held extends string = never>(
74 db: Database,
75 appId: string,
76 reach: PullReach,
77 now: () => number,
78 hold: (adapter: AdapterSpec) => Held | undefined = () => undefined,
79 ): Promise<PullOnce<Held>> {
6480 const port = reach.port();
65 if (port === undefined) return false;
66 if (inFlight.has(appId)) return false;
81 if (port === undefined) return { ok: false, why: "asleep" };
82 if (inFlight.has(appId)) return { ok: false, why: "in_flight" };
6783 inFlight.add(appId);
6884 try {
6985 const adapter = await reach.adapter();
70 if (adapter === undefined) return false;
71 if (!isPullDue(lastPullAt(db, appId), adapter.schedule.every, now())) return false;
86 if (adapter === undefined) return { ok: false, why: "no_adapter" };
87 const held = hold(adapter);
88 if (held !== undefined) return { ok: false, why: held };
7289 const started = now();
7390 const answer = answerOf(await reach.post(port));
74 recordPull(db, appId, { ...answer, durationMs: Math.max(0, now() - started) });
75 return true;
91 return { ok: true, run: recordPull(db, appId, { ...answer, durationMs: Math.max(0, now() - started) }) };
7692 } finally {
7793 inFlight.delete(appId);
7894 }
7995 }
8096
97 /** Pull one app if it is a running adapter and due: whether a pull was made. */
98 export async function pullApp(db: Database, appId: string, reach: PullReach, now: () => number = Date.now): Promise<boolean> {
99 const notDue = (adapter: AdapterSpec) =>
100 isPullDue(lastPullAt(db, appId), adapter.schedule.every, now()) ? undefined : ("not_due" as const);
101 return (await pullOnce(db, appId, reach, now, notDue)).ok;
102 }
103
104 /** Pull one app now, whatever its schedule (#10084): the recorded run, or why not. Never wakes it. */
105 export function pullAppNow(db: Database, appId: string, reach: PullReach, now: () => number = Date.now): Promise<PullOnce> {
106 return pullOnce(db, appId, reach, now);
107 }
108
81109 /** Who the forge says it is when it pulls: an admin of the app, named for itself. */
82110 export const PULL_CALLER: GrantWho = { id: "forge", name: "forge", role: "admin" };
83111
src/contract/openapi/routes/index.ts +1
@@ -267,6 +267,7 @@ export { route as postApiV1ReposOwnerNameAppEnvStop } from "./postApiV1ReposOwne
267267 export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposOwnerNameAppNotesIdBan.ts";
268268 export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts";
269269 export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts";
270 export { route as postApiV1ReposOwnerNameAppPull } from "./postApiV1ReposOwnerNameAppPull.ts";
270271 export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts";
271272 export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts";
272273 export { route as postApiV1ReposOwnerNameBuildsLabelCart } from "./postApiV1ReposOwnerNameBuildsLabelCart.ts";
src/contract/openapi/routes/postApiV1ReposOwnerNameAppPull.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "post", path: `${REPO}/app/pull`, summary: "Pull an adapter now, whatever its schedule (rw scope, writer); answers { run }; 404 no adapter block, 409 asleep or a pull in flight", auth: true };
src/http/api/app.ts +17 −1
@@ -1,13 +1,14 @@
11 /**
22 * A repository's app over the API: `…/app` itself (read, enable, change,
33 * disable), the people a private app names, its own domains, its
4 * environment variables, and redeploying, stopping and opening one
4 * environment variables, pulling an adapter now, and redeploying, stopping and opening one
55 * environment. The rules live in `actions/apps.ts`, `actions/app_members.ts`
66 * and `actions/domains.ts`.
77 */
88
99 import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts";
1010 import { appView, appVisitors, deleteAppEnvVar, disableApp, enableApp, listAppEnv, openEnvUrl, redeployEnv, setAppEnvVar, stopEnv, updateAppSettings, type AppSettingsInput } from "../../actions/apps.ts";
11 import { pullNow, PULL_NOW_REFUSALS } from "../../actions/app_pull.ts";
1112 import { addAppDomain, checkAppDomain, listAppDomains, removeAppDomain } from "../../actions/domains.ts";
1213 import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts";
1314 import { SETTING_WORDS } from "../../actions/setting_words.ts";
@@ -269,6 +270,21 @@ export function appRoutes(router: Router<Handler>): void {
269270 },
270271 );
271272
273 // Pull now (#10084): one adapter pull through the schedule's own path,
274 // answered with the recorded run. A sleeping app is refused, never woken.
275 router.add("POST", "/api/v1/repos/:owner/:name/app/pull", async (ctx) => {
276 const denied = requireScope(ctx, "rw");
277 if (denied) return denied;
278 const repo = loadRepo(ctx);
279 if (!repo) return notFound();
280 const pulled = await pullNow(ctx.app, ctx.auth.user, repo);
281 if (pulled.ok) return json({ run: pulled.value }, 200);
282 const error = pulled.error;
283 if (error === "no_adapter") return apiError(error, 404, PULL_NOW_REFUSALS[error]);

This no_adapter -> 404 mapping (and in_flight -> 409 below) has no route-level test. Add one that fails if this line is removed.

284 if (error === "asleep" || error === "in_flight") return apiError(error, 409, PULL_NOW_REFUSALS[error]);
285 return respondAccess(error, 422);
286 });
287
272288 router.add(
273289 "POST",
274290 "/api/v1/repos/:owner/:name/app/:env/redeploy",
src/http/mcp/tools/app_deploy.ts +10 −5
@@ -1,20 +1,21 @@
11 import { envRefusalText } from "../../../actions/env_words.ts";
2 import { pullNow, PULL_NOW_REFUSALS } from "../../../actions/app_pull.ts";
23 import { redeployEnv, stopEnv } from "../../../actions/apps.ts";
34 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "app_deploy",
7 doc: "`{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }`",
8 doc: "`{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }`. `{ repo, action: \"pull\" }` runs an adapter's pull now as `POST …/app/pull` does; answers `{ run }`",
89 description:
9 "Redeploy or stop production or a pull-N preview.",
10 "Redeploy or stop production or a pull-N preview, or pull an adapter now.",
1011 inputSchema: {
1112 type: "object",
1213 properties: {
1314 repo: REPO_ARG,
1415 env: { type: "string" },
15 action: { type: "string", enum: ["redeploy", "stop"] },
16 action: { type: "string", enum: ["redeploy", "stop", "pull"] },
1617 },
17 required: ["repo", "env", "action"],
18 required: ["repo", "action"],
1819 },
1920 writes: true,
2021 async run(ctx, user, args) {
@@ -22,8 +23,12 @@ export const tool: Tool = {
2223 if (isAnswer(repo)) return repo;
2324 const env = str(args["env"]);
2425 const action = str(args["action"]);
26 if (action === "pull") {
27 const pulled = await pullNow(ctx.app, user, repo);
28 return pulled.ok ? good({ run: pulled.value }) : bad(pulled.error, PULL_NOW_REFUSALS[pulled.error]);
29 }
2530 if (action !== "redeploy" && action !== "stop")
26 return bad("invalid_action", "action is redeploy or stop");
31 return bad("invalid_action", "action is redeploy, stop or pull");
2732 if (action === "stop") {
2833 const stopped = await stopEnv(ctx.app, user, repo, env);
2934 return stopped.ok
test/app-pull-now.test.ts added +288
@@ -0,0 +1,288 @@
1 /**
2 * Pull now (#10084): a writer runs one adapter pull without waiting for its
3 * schedule, through the schedule's own path. The action is tried with a fake
4 * reach (a writer gets the recorded run; a reader is refused; overlap, no
5 * adapter and asleep are refused, and asleep is never reached); the route and
6 * the MCP `pull` action are tried on a forge with no app runtime, where every
7 * production is asleep and so must be refused without being woken.
8 */
9 import { afterAll, beforeAll, describe, expect, mock, spyOn, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { newApp } from "../src/actions/apps.ts";
15 import { pullNow, PULL_NOW_REFUSALS } from "../src/actions/app_pull.ts";
16 import type { PullReach, PullReply } from "../src/apps/pull_schedule.ts";
17 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
18 import { repoDir } from "../src/git/repos.ts";
19 import { commitFiles } from "../src/git/write.ts";
20 import { getRepo, type Repo } from "../src/core/repos.ts";
21 import { listPullRuns } from "../src/core/runs.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { seedAdmin } from "./helpers/admin.ts";
25 import { promote } from "./helpers/members.ts";
26 import { tokenFor } from "./helpers/tokens.ts";
27
28 const good = JSON.stringify({ pulled: 3, skipped: 0, cursor: "c1", errors: 0 });
29
30 describe("pull now", () => {
31 let dataDir: string;
32 let app: App;
33 let servers: Servers;
34 let alice: User;
35 let bob: User;
36 let repo: Repo;
37 let appId: string;
38 let aliceRw: string;
39 let bobRw: string;
40 let posts = 0;
41 let id = 1;
42
43 const reach = (over: Partial<PullReach> = {}): (() => PullReach) => () => ({
44 port: () => 4321,
45 adapter: async () => ({ schedule: { every: 3600 } }),
46 post: async (): Promise<PullReply> => {
47 posts += 1;
48 return { status: 200, body: good };
49 },
50 ...over,
51 });
52
53 const mcp = async (token: string, args: unknown) => {
54 const res = await fetch(`${servers.url}/mcp`, {
55 method: "POST",
56 headers: {
57 "content-type": "application/json",
58 accept: "application/json, text/event-stream",
59 authorization: `Bearer ${token}`,
60 },
61 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "app_deploy", arguments: args } }),
62 });
63 const body: any = await res.json();
64 const text: string = body.result.content[0].text;
65 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
66 };
67
68 const post = (token: string, path = "/api/v1/repos/alice/hello/app/pull") =>
69 fetch(`${servers.url}${path}`, { method: "POST", headers: { authorization: `Bearer ${token}` } });
70
71 beforeAll(async () => {
72 dataDir = await mkdtemp(join(tmpdir(), "forge-app-pull-now-"));
73 app = createApp({ dataDir, port: 0 });
74 servers = await serve(app);
75 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
76 const b = await createUser(app.db, {
77 origin: "seed",
78 trusted: true,
79 username: "bob",
80 email: "[email protected]",
81 password: "a-good-passphrase",
82 });
83 if (!b.ok) throw new Error(b.error);
84 promote(app.db, b.value);
85 alice = getUserByName(app.db, "alice")!;
86 bob = getUserByName(app.db, "bob")!;
87 const made = await newApp(app, alice, { name: "hello", visibility: "public" } as never);
88 if (!made.ok) throw new Error(String(made.error));
89 repo = getRepo(app.db, "alice", "hello")!;
90 appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id;
91 aliceRw = tokenFor(app, "alice", "rw");
92 bobRw = tokenFor(app, "bob", "rw");
93 }, 30_000);
94
95 afterAll(async () => {
96 servers.stop();
97 app.close();
98 await rm(dataDir, { recursive: true, force: true });
99 });
100
101 test("a writer gets the recorded run, at once and again, whatever the schedule", async () => {
102 const first = await pullNow(app, alice, repo, reach());
103 expect(first.ok && first.value).toMatchObject({ appId, kind: "pull", state: "ok", written: 3, cursor: "c1" });
104 const again = await pullNow(app, alice, repo, reach());
105 expect(again.ok).toBe(true); // inside `every`: the schedule would wait, a pull now does not
106 expect(posts).toBe(2);
107 expect(listPullRuns(app.db, appId).length).toBe(2);
108 });
109
110 test("a reader is refused, and nothing is reached", async () => {
111 const before = posts;
112 expect(await pullNow(app, bob, repo, reach())).toEqual({ ok: false, error: "forbidden" });
113 expect(await pullNow(app, undefined, repo, reach())).toEqual({ ok: false, error: "unauthorized" });
114 expect(posts).toBe(before);
115 });
116
117 test("a second pull while one is in flight is refused", async () => {
118 let release!: () => void;
119 const held = new Promise<void>((resolve) => (release = resolve));
120 const slow = reach({
121 post: async () => {
122 await held;
123 return { status: 200, body: good };
124 },
125 });
126 const first = pullNow(app, alice, repo, slow);
127 await Bun.sleep(5);
128 expect(await pullNow(app, alice, repo, reach())).toEqual({ ok: false, error: "in_flight" });
129 release();
130 expect((await first).ok).toBe(true);
131 });
132
133 test("no adapter block and an asleep app are refused; asleep is never asked for its adapter", async () => {
134 expect(await pullNow(app, alice, repo, reach({ adapter: async () => undefined }))).toEqual({ ok: false, error: "no_adapter" });
135 let asked = false;
136 const asleep = reach({
137 port: () => undefined,
138 adapter: async () => {
139 asked = true;
140 return { schedule: { every: 60 } };
141 },
142 });
143 expect(await pullNow(app, alice, repo, asleep)).toEqual({ ok: false, error: "asleep" });
144 expect(asked).toBe(false);
145 });
146
147 test("the route: asleep is 409, a reader 403, no app 404, and nothing is recorded", async () => {
148 const before = listPullRuns(app.db, appId).length;
149 const asleep = await post(aliceRw);
150 expect(asleep.status).toBe(409);
151 expect(await asleep.json()).toMatchObject({ error: "asleep", message: PULL_NOW_REFUSALS.asleep });
152 expect((await post(bobRw)).status).toBe(403);
153 expect((await post(aliceRw, "/api/v1/repos/alice/nothing/app/pull")).status).toBe(404);
154 expect(listPullRuns(app.db, appId).length).toBe(before);
155 });
156
157 test("the MCP pull action is the same action", async () => {
158 const asleep = await mcp(aliceRw, { repo: "alice/hello", action: "pull" });
159 expect(asleep.error).toContain("asleep");
160 expect(asleep.error).toContain(PULL_NOW_REFUSALS.asleep);
161 const reader = await mcp(bobRw, { repo: "alice/hello", action: "pull" });
162 expect(reader.error).toContain("forbidden");
163 });
164 });
165
166 /**
167 * The route and the MCP `pull` action through the real reach
168 * (`runtimePullReach`): a registered runtime, never started, says production
169 * is running on the port of a loopback `/pull` server; the adapter block is
170 * whatever `forge.json` on the default branch says. So each mapping — 200
171 * `{ run }`, 404 `no_adapter`, 409 `in_flight` — is driven by the state that
172 * earns it, not by a stand-in.
173 */
174 describe("pull now at the route and over MCP, through the real reach", () => {
175 let dataDir: string;
176 let app: App;
177 let servers: Servers;
178 let runtime: AppsRuntime;
179 let adapterApp: ReturnType<typeof Bun.serve>;
180 let repo: Repo;
181 let appId: string;
182 let aliceRw: string;
183 let gate: Promise<void> = Promise.resolve();
184 let pulls = 0;
185 let id = 1;
186
187 const post = () =>
188 fetch(`${servers.url}/api/v1/repos/alice/hello/app/pull`, { method: "POST", headers: { authorization: `Bearer ${aliceRw}` } });
189
190 const mcp = async (args: unknown) => {
191 const res = await fetch(`${servers.url}/mcp`, {
192 method: "POST",
193 headers: {
194 "content-type": "application/json",
195 accept: "application/json, text/event-stream",
196 authorization: `Bearer ${aliceRw}`,
197 },
198 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "app_deploy", arguments: args } }),
199 });
200 const body: any = await res.json();
201 const text: string = body.result.content[0].text;
202 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
203 };
204
205 const declare = async (forgeJson: unknown) => {
206 const c = await commitFiles(repoDir(app.reposRoot, repo.ownerName, repo.name), {
207 branch: repo.defaultBranch,
208 message: "forge.json",
209 files: [{ path: "forge.json", content: JSON.stringify(forgeJson) }],
210 author: { name: "T", email: "[email protected]" },
211 });
212 if (!c.ok) throw new Error(JSON.stringify(c.error));
213 };
214
215 beforeAll(async () => {
216 dataDir = await mkdtemp(join(tmpdir(), "forge-app-pull-now-route-"));
217 app = createApp({ dataDir, port: 0 });
218 servers = await serve(app);
219 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
220 const alice = getUserByName(app.db, "alice")!;
221 const made = await newApp(app, alice, { name: "hello", visibility: "public" } as never);
222 if (!made.ok) throw new Error(String(made.error));
223 repo = getRepo(app.db, "alice", "hello")!;
224 appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id;
225 aliceRw = tokenFor(app, "alice", "rw");
226 adapterApp = Bun.serve({
227 port: 0,
228 hostname: "127.0.0.1",
229 fetch: async () => {
230 pulls += 1;
231 await gate;
232 return Response.json({ pulled: 2, skipped: 1, cursor: "r1", errors: 0 });
233 },
234 });
235 runtime = new AppsRuntime(app, { domain: "pull.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url });
236 spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
237 (): EnvStatus => ({ env: "production", state: "running", commitOid: "0".repeat(40), url: "", port: adapterApp.port }) as EnvStatus,
238 );
239 await declare({ run: "bun run src/server.ts" });
240 }, 30_000);
241
242 afterAll(async () => {
243 mock.restore();
244 await runtime.stop();
245 adapterApp.stop(true);
246 servers.stop();
247 app.close();
248 await rm(dataDir, { recursive: true, force: true });
249 });
250
251 test("no adapter block: the route is 404 no_adapter, MCP says the same, and nothing is reached", async () => {
252 const res = await post();
253 expect(res.status).toBe(404);
254 expect(await res.json()).toMatchObject({ error: "no_adapter", message: PULL_NOW_REFUSALS.no_adapter });
255 const over = await mcp({ repo: "alice/hello", action: "pull" });
256 expect(over.error).toContain("no_adapter");
257 expect(over.error).toContain(PULL_NOW_REFUSALS.no_adapter);
258 expect(pulls).toBe(0);
259 });
260
261 test("an adapter: the route is 200 { run }, and so is MCP", async () => {
262 await declare({ run: "bun run src/server.ts", adapter: { schedule: { every: 3600 } } });
263 const res = await post();
264 expect(res.status).toBe(200);
265 const body: any = await res.json();
266 expect(body.run).toMatchObject({ appId, kind: "pull", state: "ok", written: 2, cursor: "r1" });
267 const over = await mcp({ repo: "alice/hello", action: "pull" });
268 expect(over.value.run).toMatchObject({ appId, kind: "pull", state: "ok" });
269 expect(pulls).toBe(2);
270 expect(listPullRuns(app.db, appId).length).toBe(2);
271 });
272
273 test("a pull already in flight: the route is 409 in_flight, and so is MCP", async () => {
274 let release!: () => void;
275 gate = new Promise<void>((resolve) => (release = resolve));
276 const before = pulls;
277 const first = post();
278 while (pulls === before) await Bun.sleep(5);
279 const second = await post();
280 expect(second.status).toBe(409);
281 expect(await second.json()).toMatchObject({ error: "in_flight", message: PULL_NOW_REFUSALS.in_flight });
282 const over = await mcp({ repo: "alice/hello", action: "pull" });
283 expect(over.error).toContain("in_flight");
284 release();
285 gate = Promise.resolve();
286 expect((await first).status).toBe(200);
287 });
288 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs