Public: anyone can read this; the crew can change it.
MCP `preview` says whether the environment is serving: running, starting, failed with its error, not running or gone #8284
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8159-mcp-preview-says-whether-the-environment → main 3 commits fork
7 files
changed +134
−16
docs/MCP.md +1 −1
| @@ -130,7 +130,7 @@ for a tunnel and a forge that sleeps. | ||
| 130 | 130 | | `person` | `{ username, limit?, cursor? }`: a person's public profile and the repositories you may see, as `GET /api/v1/users/:username` and its `/repos`; a private one you cannot read is never listed | | |
| 131 | 131 | | `pins` | `{ action?, repo?, limit?, cursor? }`: `list` (the default) the caller's pinned repositories, paged by `owner/name` as `GET /api/v1/user/pins` is (`next_cursor`), leaving out a pin whose repository the caller can no longer read (the API's list still names it); `pin` or `unpin` `repo`, as `PUT`/`DELETE /api/v1/user/pins/:owner/:name` do. A repository the caller cannot see is not found | yes | |
| 132 | 132 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
| 133 | | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | | | |
| 133 | | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names], serving: { state: "running" \| "starting" \| "failed" \| "not_running" \| "gone", says, runUrl } }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going, `serving.says` is the sentence the not-serving page shows (a failure's error redacted), `runUrl` the run it links; an open pull is never `gone`; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | | | |
| 134 | 134 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 135 | 135 | | `profile` | `{ displayName?, email?, commitEmail? }`: your own profile (`publicUser` and `emailVerified`, never the address); with an argument it changes it by the rules of `PATCH /api/v1/user`, and needs admin scope: only a site admin can mint such a token, so most people sign in to `/mcp` with their password | yes | |
| 136 | 136 | | `prompts` | (admin scope) `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes | |
src/apps/preview_view.ts +24 −1
| @@ -14,6 +14,8 @@ import { mayView, mayViewId } from "../core/sealed.ts"; | ||
| 14 | 14 | import { canRead, getRepoById } from "../core/repos.ts"; |
| 15 | 15 | import { getUserById, type User } from "../core/users.ts"; |
| 16 | 16 | import { resolveAppHostname } from "./proxy.ts"; |
| 17 | import { appsRuntime } from "./runtime.ts"; | |
| 18 | import { servingWords, type ServingWords } from "./serving_words.ts"; | |
| 17 | 19 | |
| 18 | 20 | export interface PreviewView { |
| 19 | 21 | readonly app: string; |
| @@ -23,6 +25,8 @@ export interface PreviewView { | ||
| 23 | 25 | readonly firstFailure: string | null; |
| 24 | 26 | readonly logUrl: string | null; |
| 25 | 27 | readonly working: readonly string[]; |
| 28 | /** Whether anything is serving there, in the words of the proxy's page. */ | |
| 29 | readonly serving: ServingWords & { readonly runUrl: string | null }; | |
| 26 | 30 | } |
| 27 | 31 | |
| 28 | 32 | export type PreviewRefusal = "foreign" | "no_such_app" | "no_such_pull"; |
| @@ -50,11 +54,13 @@ export function previewOf( | ||
| 50 | 54 | let firstFailure: string | null = null; |
| 51 | 55 | let logUrl: string | null = null; |
| 52 | 56 | let pullIssueId: string | undefined; |
| 57 | let pullIssueState: string | undefined; | |
| 53 | 58 | if (number !== undefined) { |
| 54 | 59 | const found = getPull(db, repo.id, Number(number)); |
| 55 | 60 | if (found === undefined || !mayView(db, repo, found, user)) |
| 56 | 61 | return { ok: false, error: "no_such_pull", number: Number(number) }; |
| 57 | 62 | pullIssueId = found.id; |
| 63 | pullIssueState = found.state; | |
| 58 | 64 | pull = { number: found.number, title: found.title }; |
| 59 | 65 | const { state, run } = checkSummary(db, found.id, found.headOid); |
| 60 | 66 | check = |
| @@ -73,8 +79,25 @@ export function previewOf( | ||
| 73 | 79 | const who = run.actorId === null ? undefined : getUserById(db, run.actorId); |
| 74 | 80 | if (who !== undefined) working.add(who.username); |
| 75 | 81 | } |
| 82 | const runtime = appsRuntime(app); | |
| 83 | const status = runtime?.envStatus(target.app, target.env); | |
| 84 | // An open pull is never gone; a pull seen above is open or closed. | |
| 85 | const words = servingWords(status, number === undefined ? null : pullIssueState === "closed" ? "closed" : "open"); | |
| 86 | const runUrl = | |
| 87 | runtime === undefined || status?.runId === undefined | |
| 88 | ? null | |
| 89 | : `${runtime.options.forgeUrl}/runs/${status.runId}`; | |
| 76 | 90 | return { |
| 77 | 91 | ok: true, |
| 78 | value: { app: name, environment: target.env, pull, check, firstFailure, logUrl, working: [...working] }, | |
| 92 | value: { | |
| 93 | app: name, | |
| 94 | environment: target.env, | |
| 95 | pull, | |
| 96 | check, | |
| 97 | firstFailure, | |
| 98 | logUrl, | |
| 99 | working: [...working], | |
| 100 | serving: { ...words, runUrl }, | |
| 101 | }, | |
| 79 | 102 | }; |
| 80 | 103 | } |
src/apps/proxy.ts +3 −11
| @@ -46,6 +46,7 @@ import { feedback, mintProof } from "./feedback.ts"; | ||
| 46 | 46 | import { clientBucket } from "../http/peer.ts"; |
| 47 | 47 | import { DOOR_PULL_LIMIT, doorPage, type DoorPullLine } from "./door.ts"; |
| 48 | 48 | import { checkWord } from "./check_word.ts"; |
| 49 | import { servingWords } from "./serving_words.ts"; | |
| 49 | 50 | import { openPulls } from "./door_pulls.ts"; |
| 50 | 51 | import { notePhase, startedAt, takePhases, timePhase } from "../core/route_timing.ts"; |
| 51 | 52 | import { readServing } from "./serving.ts"; |
| @@ -1602,17 +1603,8 @@ export function unavailable( | ||
| 1602 | 1603 | // edge, since there is nothing at this address yet. A stopped or failed |
| 1603 | 1604 | // environment keeps its 502. |
| 1604 | 1605 | const absent = preview && !gone && status === undefined; |
| 1605 | const message = | |
| 1606 | reason ?? | |
| 1607 | (deploying | |
| 1608 | ? "A new version is starting." | |
| 1609 | : state === "failed" || (state === "asleep" && status?.error !== undefined) | |
| 1610 | ? (status?.error ?? "The last deploy failed.") | |
| 1611 | : gone | |
| 1612 | ? "This preview is gone: its pull was closed, and its data with it." | |
| 1613 | : absent | |
| 1614 | ? "The preview is not running. It starts again with the next push to the pull, or with app_deploy redeploy." | |
| 1615 | : "Nothing is running here yet."); | |
| 1606 | const words = servingWords(status, preview ? (gone ? "closed" : "open") : null); | |
| 1607 | const message = reason ?? words.says; | |
| 1616 | 1608 | const links = discoveryLinks(runtime, target); |
| 1617 | 1609 | const report = links.length > 0; |
| 1618 | 1610 | const runLink = |
src/apps/serving_words.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * Whether an environment is serving, in words: one place for the sentence | |
| 3 | * the proxy's not-serving page shows and the `preview` tool says. Plain | |
| 4 | * data in, plain data out. | |
| 5 | */ | |
| 6 | ||
| 7 | import { redactHeld } from "../kernel/log.ts"; | |
| 8 | import type { EnvStatus } from "./runtime.ts"; | |
| 9 | ||
| 10 | export type ServingState = "running" | "starting" | "failed" | "not_running" | "gone"; | |
| 11 | ||
| 12 | export interface ServingWords { | |
| 13 | readonly state: ServingState; | |
| 14 | readonly says: string; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * `pull` is what an open-or-not pull is for a preview: "open" or "closed" | |
| 19 | * (a missing pull is closed), and null for production. An open pull is never gone: | |
| 20 | * it deploys again on the next push. | |
| 21 | */ | |
| 22 | export function servingWords(status: EnvStatus | undefined, pull: "open" | "closed" | null): ServingWords { | |
| 23 | const state = status?.state ?? "stopped"; | |
| 24 | if (state === "running") return { state: "running", says: "It is running." }; | |
| 25 | if (state === "deploying") return { state: "starting", says: "A new version is starting." }; | |
| 26 | if (state === "failed" || (state === "asleep" && status?.error !== undefined)) | |
| 27 | return { state: "failed", says: redactHeld(status?.error ?? "The last deploy failed.") }; | |
| 28 | if (pull === "closed") | |
| 29 | return { state: "gone", says: "This preview is gone: its pull was closed, and its data with it." }; | |
| 30 | if (pull === "open" && status === undefined) | |
| 31 | return { | |
| 32 | state: "not_running", | |
| 33 | says: "The preview is not running. It starts again with the next push to the pull, or with app_deploy redeploy.", | |
| 34 | }; | |
| 35 | return { state: "not_running", says: "Nothing is running here yet." }; | |
| 36 | } |
src/http/mcp/tools/preview.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "preview", |
| 7 | doc: "`{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \\| null, check: \"green\" \\| \"red\" \\| \"running\" \\| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say", | |
| 7 | doc: "`{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \\| null, check: \"green\" \\| \"red\" \\| \"running\" \\| null, firstFailure, logUrl, working: [names], serving: { state: \"running\" \\| \"starting\" \\| \"failed\" \\| \"not_running\" \\| \"gone\", says, runUrl } }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going, `serving.says` is the sentence the not-serving page shows (a failure's error redacted), `runUrl` the run it links; an open pull is never `gone`; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say", | |
| 8 | 8 | description: |
| 9 | 9 | "An app URL's pull, check, first failure, log.", |
| 10 | 10 | inputSchema: { |
test/mcp-preview.test.ts +60 −2
| @@ -2,18 +2,19 @@ | ||
| 2 | 2 | * The `preview` tool tells an agent what the forge pill tells a person: the |
| 3 | 3 | * app, the environment, the pull, its check, its first failure and its log. |
| 4 | 4 | */ |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 5 | import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App } from "../src/app.ts"; |
| 10 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 10 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 11 | 11 | import { newApp } from "../src/actions/apps.ts"; |
| 12 | 12 | import { openPull } from "../src/actions/pulls.ts"; |
| 13 | 13 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 14 | 14 | import { getRepo } from "../src/core/repos.ts"; |
| 15 | 15 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 16 | 16 | import { getPull } from "../src/core/pulls.ts"; |
| 17 | import { setIssueState } from "../src/core/issues.ts"; | |
| 17 | 18 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 18 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | 20 | import { seedAdmin } from "./helpers/admin.ts"; |
| @@ -110,6 +111,63 @@ describe("MCP preview", () => { | ||
| 110 | 111 | firstFailure: null, |
| 111 | 112 | logUrl: null, |
| 112 | 113 | working: [], |
| 114 | serving: expect.objectContaining({ state: expect.any(String), says: expect.any(String) }), | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 118 | describe("serving", () => { | |
| 119 | let spy: ReturnType<typeof spyOn> | undefined; | |
| 120 | const stub = (status: EnvStatus | undefined) => { | |
| 121 | spy = spyOn(runtime, "envStatus").mockImplementation(() => status); | |
| 122 | }; | |
| 123 | afterEach(() => { | |
| 124 | spy?.mockRestore(); | |
| 125 | spy = undefined; | |
| 126 | }); | |
| 127 | const serving = async (label: string) => (await tool(bobRw, { url: host(label) })).value.serving; | |
| 128 | const base = { env: "production", commitOid: "x", url: "u" } as const; | |
| 129 | ||
| 130 | test("running", async () => { | |
| 131 | stub({ ...base, state: "running", runId: "r1" } as EnvStatus); | |
| 132 | const s = await serving("hello"); | |
| 133 | expect(s.state).toBe("running"); | |
| 134 | expect(s.runUrl).toBe(`${servers.url}/runs/r1`); | |
| 135 | }); | |
| 136 | test("starting", async () => { | |
| 137 | stub({ ...base, state: "deploying" } as EnvStatus); | |
| 138 | expect(await serving("hello")).toEqual({ state: "starting", says: "A new version is starting.", runUrl: null }); | |
| 139 | }); | |
| 140 | test("failed carries its error, redacted", async () => { | |
| 141 | stub({ ...base, state: "failed", error: "boom token=ghp_abcdefghijklmnopqrstuvwxyz0123456789", runId: "r2" } as EnvStatus); | |
| 142 | const s = await serving("hello"); | |
| 143 | expect(s.state).toBe("failed"); | |
| 144 | expect(s.says).toContain("boom"); | |
| 145 | expect(s.says).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz0123456789"); | |
| 146 | expect(s.runUrl).toBe(`${servers.url}/runs/r2`); | |
| 147 | }); | |
| 148 | test("production is never gone: not running, then running", async () => { | |
| 149 | stub(undefined); | |
| 150 | const off = await serving("hello"); | |
| 151 | expect(off.state).toBe("not_running"); | |
| 152 | expect(off.says).toBe("Nothing is running here yet."); | |
| 153 | spy?.mockRestore(); | |
| 154 | stub({ ...base, state: "running" } as EnvStatus); | |
| 155 | expect((await serving("hello")).state).toBe("running"); | |
| 156 | }); | |
| 157 | test("not running: an open pull with nothing deployed is never gone", async () => { | |
| 158 | stub(undefined); | |
| 159 | const s = await serving(`hello--${redPull}`); | |
| 160 | expect(s.state).toBe("not_running"); | |
| 161 | expect(s.says).toContain("starts again with the next push"); | |
| 162 | }); | |
| 163 | test("gone: a closed pull", async () => { | |
| 164 | stub(undefined); | |
| 165 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 166 | const n = await makePull(getUserByName(app.db, "alice")!, repo, "closing"); | |
| 167 | setIssueState(app.db, getPull(app.db, repo.id, n)!.id, "closed"); | |
| 168 | const s = await serving(`hello--${n}`); | |
| 169 | expect(s.state).toBe("gone"); | |
| 170 | expect(s.says).toContain("This preview is gone"); | |
| 113 | 171 | }); |
| 114 | 172 | }); |
| 115 | 173 |
new above
offline