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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10084-a-writer-can-run-an-adapter-s-pull-now-t → main 5 commits fork 1 thread · 1 unresolved
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: | ||
| 71 | 71 | Any other key, at any level, is refused. A block that breaks any of these |
| 72 | 72 | rules is invalid, and the forge does not schedule the app. |
| 73 | 73 | |
| 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 | ||
| 74 | 85 | ## Source hosts and egress |
| 75 | 86 | |
| 76 | 87 | 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"; | ||
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| 19 | 19 | import { getRepoById } from "../core/repos.ts"; |
| 20 | import { lastPullAt } from "../core/runs.ts"; | |
| 20 | import { lastPullAt, type PullRun } from "../core/runs.ts"; | |
| 21 | 21 | import { resolveOid } from "../git/merge.ts"; |
| 22 | 22 | import { repoDir } from "../git/repos.ts"; |
| 23 | 23 | import { parseAdapter, type AdapterSpec } from "./adapter.ts"; |
| @@ -55,29 +55,57 @@ export function answerOf(reply: PullReply): PullAnswer { | ||
| 55 | 55 | return parsePullAnswer(reply.status, reply.body); |
| 56 | 56 | } |
| 57 | 57 | |
| 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 | ||
| 58 | 66 | /** |
| 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`. | |
| 62 | 72 | */ |
| 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>> { | |
| 64 | 80 | 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" }; | |
| 67 | 83 | inFlight.add(appId); |
| 68 | 84 | try { |
| 69 | 85 | 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 }; | |
| 72 | 89 | const started = now(); |
| 73 | 90 | 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) }) }; | |
| 76 | 92 | } finally { |
| 77 | 93 | inFlight.delete(appId); |
| 78 | 94 | } |
| 79 | 95 | } |
| 80 | 96 | |
| 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 | ||
| 81 | 109 | /** Who the forge says it is when it pulls: an admin of the app, named for itself. */ |
| 82 | 110 | export const PULL_CALLER: GrantWho = { id: "forge", name: "forge", role: "admin" }; |
| 83 | 111 |
src/contract/openapi/routes/index.ts +1
| @@ -267,6 +267,7 @@ export { route as postApiV1ReposOwnerNameAppEnvStop } from "./postApiV1ReposOwne | ||
| 267 | 267 | export { route as postApiV1ReposOwnerNameAppNotesIdBan } from "./postApiV1ReposOwnerNameAppNotesIdBan.ts"; |
| 268 | 268 | export { route as postApiV1ReposOwnerNameAppNotesIdHide } from "./postApiV1ReposOwnerNameAppNotesIdHide.ts"; |
| 269 | 269 | export { route as postApiV1ReposOwnerNameAppNotesIdUnban } from "./postApiV1ReposOwnerNameAppNotesIdUnban.ts"; |
| 270 | export { route as postApiV1ReposOwnerNameAppPull } from "./postApiV1ReposOwnerNameAppPull.ts"; | |
| 270 | 271 | export { route as postApiV1ReposOwnerNameBranches } from "./postApiV1ReposOwnerNameBranches.ts"; |
| 271 | 272 | export { route as postApiV1ReposOwnerNameBuilds } from "./postApiV1ReposOwnerNameBuilds.ts"; |
| 272 | 273 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * A repository's app over the API: `…/app` itself (read, enable, change, |
| 3 | 3 | * 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 | |
| 5 | 5 | * environment. The rules live in `actions/apps.ts`, `actions/app_members.ts` |
| 6 | 6 | * and `actions/domains.ts`. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts"; |
| 10 | 10 | 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"; | |
| 11 | 12 | import { addAppDomain, checkAppDomain, listAppDomains, removeAppDomain } from "../../actions/domains.ts"; |
| 12 | 13 | import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts"; |
| 13 | 14 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| @@ -269,6 +270,21 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 269 | 270 | }, |
| 270 | 271 | ); |
| 271 | 272 | |
| 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]); | |
| 284 | if (error === "asleep" || error === "in_flight") return apiError(error, 409, PULL_NOW_REFUSALS[error]); | |
| 285 | return respondAccess(error, 422); | |
| 286 | }); | |
| 287 | ||
| 272 | 288 | router.add( |
| 273 | 289 | "POST", |
| 274 | 290 | "/api/v1/repos/:owner/:name/app/:env/redeploy", |
src/http/mcp/tools/app_deploy.ts +10 −5
| @@ -1,20 +1,21 @@ | ||
| 1 | 1 | import { envRefusalText } from "../../../actions/env_words.ts"; |
| 2 | import { pullNow, PULL_NOW_REFUSALS } from "../../../actions/app_pull.ts"; | |
| 2 | 3 | import { redeployEnv, stopEnv } from "../../../actions/apps.ts"; |
| 3 | 4 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | 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 }`", | |
| 8 | 9 | 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.", | |
| 10 | 11 | inputSchema: { |
| 11 | 12 | type: "object", |
| 12 | 13 | properties: { |
| 13 | 14 | repo: REPO_ARG, |
| 14 | 15 | env: { type: "string" }, |
| 15 | action: { type: "string", enum: ["redeploy", "stop"] }, | |
| 16 | action: { type: "string", enum: ["redeploy", "stop", "pull"] }, | |
| 16 | 17 | }, |
| 17 | required: ["repo", "env", "action"], | |
| 18 | required: ["repo", "action"], | |
| 18 | 19 | }, |
| 19 | 20 | writes: true, |
| 20 | 21 | async run(ctx, user, args) { |
| @@ -22,8 +23,12 @@ export const tool: Tool = { | ||
| 22 | 23 | if (isAnswer(repo)) return repo; |
| 23 | 24 | const env = str(args["env"]); |
| 24 | 25 | 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 | } | |
| 25 | 30 | 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"); | |
| 27 | 32 | if (action === "stop") { |
| 28 | 33 | const stopped = await stopEnv(ctx.app, user, repo, env); |
| 29 | 34 | 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 | }); |
This no_adapter -> 404 mapping (and in_flight -> 409 below) has no route-level test. Add one that fails if this line is removed.