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

Merged opened this

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

Merged

scout/forge:scout/8159-mcp-preview-says-whether-the-environment → main 3 commits fork

  • Merged. scout/8159-mcp-preview-says-whether-the-environment was merged into main as 88e8fd1.

Review

Sign in to review this change.
7 files changed +134 −16
docs/MCP.md +1 −1
@@ -130,7 +130,7 @@ for a tunnel and a forge that sleeps.
130130 | `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 | |
131131 | `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 |
132132 | `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 | |
134134 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
135135 | `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 |
136136 | `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";
1414 import { canRead, getRepoById } from "../core/repos.ts";
1515 import { getUserById, type User } from "../core/users.ts";
1616 import { resolveAppHostname } from "./proxy.ts";
17 import { appsRuntime } from "./runtime.ts";
18 import { servingWords, type ServingWords } from "./serving_words.ts";
1719
1820 export interface PreviewView {
1921 readonly app: string;
@@ -23,6 +25,8 @@ export interface PreviewView {
2325 readonly firstFailure: string | null;
2426 readonly logUrl: string | null;
2527 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 };
2630 }
2731
2832 export type PreviewRefusal = "foreign" | "no_such_app" | "no_such_pull";
@@ -50,11 +54,13 @@ export function previewOf(
5054 let firstFailure: string | null = null;
5155 let logUrl: string | null = null;
5256 let pullIssueId: string | undefined;
57 let pullIssueState: string | undefined;
5358 if (number !== undefined) {
5459 const found = getPull(db, repo.id, Number(number));
5560 if (found === undefined || !mayView(db, repo, found, user))
5661 return { ok: false, error: "no_such_pull", number: Number(number) };
5762 pullIssueId = found.id;
63 pullIssueState = found.state;
5864 pull = { number: found.number, title: found.title };
5965 const { state, run } = checkSummary(db, found.id, found.headOid);
6066 check =
@@ -73,8 +79,25 @@ export function previewOf(
7379 const who = run.actorId === null ? undefined : getUserById(db, run.actorId);
7480 if (who !== undefined) working.add(who.username);
7581 }
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}`;
7690 return {
7791 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 },
79102 };
80103 }
src/apps/proxy.ts +3 −11
@@ -46,6 +46,7 @@ import { feedback, mintProof } from "./feedback.ts";
4646 import { clientBucket } from "../http/peer.ts";
4747 import { DOOR_PULL_LIMIT, doorPage, type DoorPullLine } from "./door.ts";
4848 import { checkWord } from "./check_word.ts";
49 import { servingWords } from "./serving_words.ts";
4950 import { openPulls } from "./door_pulls.ts";
5051 import { notePhase, startedAt, takePhases, timePhase } from "../core/route_timing.ts";
5152 import { readServing } from "./serving.ts";
@@ -1602,17 +1603,8 @@ export function unavailable(
16021603 // edge, since there is nothing at this address yet. A stopped or failed
16031604 // environment keeps its 502.
16041605 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;
16161608 const links = discoveryLinks(runtime, target);
16171609 const report = links.length > 0;
16181610 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";
44
55 export const tool: Tool = {
66 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",
88 description:
99 "An app URL's pull, check, first failure, log.",
1010 inputSchema: {
test/apps-proxy-unavailable.test.ts +9
@@ -104,6 +104,15 @@ describe("unavailable page", () => {
104104 expect(res.status).toBeLessThan(500);
105105 });
106106
107 test("a failed deploy's error is redacted, and a running one says so", async () => {
108 const secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
109 const failed = await page(`pull-${open}`, { state: "failed", error: `boom token=${secret}` }).text();
110 expect(failed).toContain("boom");
111 expect(failed).not.toContain(secret);
112 const running = await page(`pull-${open}`, { state: "running", port: 1 }).text();
113 expect(running).toContain("It is running.");
114 });
115
107116 test("a closed pull, and one that does not exist, are a 404 gone", async () => {
108117 for (const n of [closed, 9999]) {
109118 const res = page(`pull-${n}`);
test/mcp-preview.test.ts +60 −2
@@ -2,18 +2,19 @@
22 * The `preview` tool tells an agent what the forge pill tells a person: the
33 * app, the environment, the pull, its check, its first failure and its log.
44 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5 import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
66 import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
99 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";
1111 import { newApp } from "../src/actions/apps.ts";
1212 import { openPull } from "../src/actions/pulls.ts";
1313 import { commitToBranch } from "../src/actions/repos.ts";
1414 import { getRepo } from "../src/core/repos.ts";
1515 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1616 import { getPull } from "../src/core/pulls.ts";
17 import { setIssueState } from "../src/core/issues.ts";
1718 import { createUser, getUserByName } from "../src/core/users.ts";
1819 import { serve, type Servers } from "../src/http/server.ts";
1920 import { seedAdmin } from "./helpers/admin.ts";
@@ -110,6 +111,63 @@ describe("MCP preview", () => {
110111 firstFailure: null,
111112 logUrl: null,
112113 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");
113171 });
114172 });
115173

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs