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

When an app cannot start, the studio shows the forge's own filesystem path #217

Merged opened this

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

Merged

scout/forge:scout/202-when-an-app-cannot-start-the-studio-show → main 1 commit fork

  • Merged. scout/202-when-an-app-cannot-start-the-studio-show was merged into main as c60902b.

Review

Sign in to review this change.
2 files changed +147 −3
src/apps/process.ts +22 −3
@@ -143,6 +143,22 @@ function operatorVars(
143143 return out;
144144 }
145145
146 /**
147 * The forge's own directories stripped from a line of output — the data
148 * directory, the app's id, the environment and the per-commit checkout are
149 * the forge's private layout, not the app's; a stack trace naming
150 * `dir/src/server.ts:1:11` reads as `src/server.ts:1:11` instead, the path
151 * the app's own source control knows. Applied where output is captured, not
152 * filtered — a line with no match passes through whole, so a message never
153 * loses its stack for the sake of hiding a directory.
154 */
155 function stripForgeDirs(dirs: readonly string[], line: string): string {
156 return dirs.reduce(
157 (out, dir) => out.split(`${dir}/`).join("").split(dir).join("."),
158 line,
159 );
160 }
161
146162 /**
147163 * Run a step to completion, streaming its output. A non-zero exit, a
148164 * timeout, or an abort is an Err with the reason a person would want.
@@ -166,6 +182,8 @@ export async function runStep(
166182 stdout: "pipe",
167183 stderr: "pipe",
168184 });
185 const scrub = (line: string): void =>
186 options.log(stripForgeDirs([options.cwd], line));
169187 const timeoutMs = options.timeoutMs ?? STEP_TIMEOUT_MS;
170188 let why: string | undefined;
171189 const timer = setTimeout(() => {
@@ -180,8 +198,8 @@ export async function runStep(
180198 else options.signal.addEventListener("abort", onAbort, { once: true });
181199 try {
182200 const [, , code] = await Promise.all([
183 pump(proc.stdout, options.log),
184 pump(proc.stderr, options.log),
201 pump(proc.stdout, scrub),
202 pump(proc.stderr, scrub),
185203 proc.exited,
186204 ]);
187205 if (why !== undefined) return err(why);
@@ -262,7 +280,8 @@ export async function startProcess(
262280 });
263281 // Output is pumped for the life of the process, not just the start.
264282 const recent: string[] = [];
265 const remember = (line: string): void => {
283 const remember = (raw: string): void => {
284 const line = stripForgeDirs([input.dir, input.dataDir], raw);
266285 recent.push(line);
267286 if (recent.length > 20) recent.shift();
268287 log(line);
test/apps.test.ts +125
@@ -1095,6 +1095,131 @@ exec bun -e "Bun.serve({port:Number(process.env.PORT),hostname:'127.0.0.1',fetch
10951095 }, 60_000);
10961096 });
10971097
1098 describe("a start failure is said in the app's own terms, not the forge's paths (issue #202)", () => {
1099 let dataDir: string;
1100 let app: App;
1101 let servers: Servers;
1102 let runtime: AppsRuntime;
1103 let alice: User;
1104 let cookie: string;
1105 const DOMAIN = "leaky.test";
1106
1107 async function settled(
1108 where: (run: Run) => boolean,
1109 repoId: string,
1110 timeoutMs = 30_000,
1111 ): Promise<Run> {
1112 const deadline = Date.now() + timeoutMs;
1113 while (Date.now() < deadline) {
1114 const run = listRuns(app.db, repoId, { kind: "deploy", limit: 50 }).find(
1115 where,
1116 );
1117 if (
1118 run &&
1119 (run.state === "ok" ||
1120 run.state === "failed" ||
1121 run.state === "cancelled")
1122 )
1123 return run;
1124 await Bun.sleep(50);
1125 }
1126 throw new Error("run did not settle in time");
1127 }
1128
1129 const FORGE_JSON = JSON.stringify({
1130 app: { run: "bun run src/server.ts", install: "", health: "/", ready: 3 },
1131 });
1132
1133 beforeAll(async () => {
1134 dataDir = await mkdtemp(join(tmpdir(), "forge-leaky-"));
1135 app = createApp({ dataDir, port: 0 });
1136 servers = await serve(app);
1137 runtime = new AppsRuntime(app, {
1138 domain: DOMAIN,
1139 scheme: "http",
1140 publicPort: servers.port,
1141 forgeUrl: servers.url,
1142 concurrency: 2,
1143 });
1144 await runtime.start();
1145 const created = await createUser(app.db, {
1146 username: "alice",
1147 email: "[email protected]",
1148 password: "a-good-passphrase",
1149 adminIfFirst: true,
1150 });
1151 if (!created.ok) throw new Error(created.error);
1152 alice = created.value;
1153 cookie = (
1154 (
1155 await fetch(`${servers.url}/signin`, {
1156 method: "POST",
1157 body: new URLSearchParams({
1158 username: "alice",
1159 password: "a-good-passphrase",
1160 }),
1161 redirect: "manual",
1162 })
1163 ).headers.get("set-cookie") ?? ""
1164 ).split(";")[0]!;
1165 }, 30_000);
1166
1167 afterAll(async () => {
1168 await runtime.stop();
1169 servers.stop();
1170 app.close();
1171 await rm(dataDir, { recursive: true, force: true });
1172 });
1173
1174 test("a deploy whose app throws on import names its own file, never the forge's data directory or the app's id", async () => {
1175 const made = await createRepo(app, alice, {
1176 name: "throws",
1177 visibility: "public",
1178 });
1179 expect(made.ok).toBe(true);
1180 if (!made.ok) return;
1181 const repo = made.value;
1182 const committed = await commitToBranch(app, alice, repo, {
1183 message: "An app that throws on import",
1184 files: [
1185 { path: "forge.json", content: FORGE_JSON },
1186 { path: "src/server.ts", content: `throw new Error("boom");\n` },
1187 ],
1188 });
1189 expect(committed.ok).toBe(true);
1190 const enabled = enableApp(app, alice, repo, {});
1191 expect(enabled.ok).toBe(true);
1192 if (!enabled.ok) return;
1193 const record = enabled.value;
1194
1195 const run = await settled((r) => r.env === "production", repo.id);
1196 expect(run.state).toBe("failed");
1197
1198 const notForgePaths = (text: string): void => {
1199 expect(text).toContain("src/server.ts");
1200 expect(text).not.toContain(dataDir);
1201 expect(text).not.toContain(record.id);
1202 };
1203
1204 // The error the runtime stores — the same one a run's summary and the
1205 // studio's head card both draw from — names the app's own file.
1206 const status = runtime.envStatus(record, "production");
1207 notForgePaths(status?.error ?? "");
1208 notForgePaths(run.summary ?? "");
1209
1210 const { lines } = readRunLog(dataDir, run.id);
1211 notForgePaths(lines.join("\n"));
1212
1213 // The studio's app page renders the same error in its head, for a
1214 // reader who never asked to see the forge's own filesystem.
1215 const page = await fetch(`${servers.url}/${repo.ownerName}/${repo.name}`, {
1216 headers: { cookie: `${cookie}; forge_mode=studio` },
1217 });
1218 expect(page.status).toBe(200);
1219 notForgePaths(await page.text());
1220 }, 60_000);
1221 });
1222
10981223 describe("New app: turn the sentence into a plan and pick the crew", () => {
10991224 let dataDir: string;
11001225 let app: App;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs