Skip to content

Commit 5a144db

Browse files
committed
CLI/MCP calls join the run's distributed-trace ledger
The web app's HttpClient sends a traceparent on its own, but mcporter's plain fetch does not — so the terminal half of a session was invisible in traces.json. The MCP surface now wraps the global fetch: every POST to the target's MCP endpoint gets a minted traceparent (the worker and DO already join whatever arrives) and lands in the ledger with the JSON-RPC method or tool name as its label, duration, status, and source: terminal. traces.json becomes a shared append-merge ledger (src/trace-harvest.ts) so the browser surface's harvested entries and the MCP surface's minted ones interleave by wall clock, and the trace rail tags each row with its window (⌨ terminal / ⊕ browser).
1 parent 796748b commit 5a144db

6 files changed

Lines changed: 275 additions & 49 deletions

File tree

e2e/src/scenario.ts

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
// scenario × target matrix) plus whatever artifacts the surfaces produced
1111
// (browser video/trace/screenshots, terminal casts).
1212
import { execFileSync } from "node:child_process";
13-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13+
import {
14+
existsSync,
15+
mkdirSync,
16+
readFileSync,
17+
readdirSync,
18+
rmSync,
19+
writeFileSync,
20+
} from "node:fs";
1421
import { join } from "node:path";
1522
import { fileURLToPath } from "node:url";
1623

@@ -24,8 +31,23 @@ import { makeApiSurface } from "./surfaces/api";
2431
import { makeBrowserSurface } from "./surfaces/browser";
2532
import { makeCliSurface } from "./surfaces/cli";
2633
import { makeMcpSurface } from "./surfaces/mcp";
27-
import { completeOAuthConsent, hasOpenCode, makeOpenCodeHome, warmUp } from "./clients/opencode";
28-
import { Api, Billing, Browser, Cli, Mcp, OpenCode, RunDir, Target, TtlControl } from "./services";
34+
import {
35+
completeOAuthConsent,
36+
hasOpenCode,
37+
makeOpenCodeHome,
38+
warmUp,
39+
} from "./clients/opencode";
40+
import {
41+
Api,
42+
Billing,
43+
Browser,
44+
Cli,
45+
Mcp,
46+
OpenCode,
47+
RunDir,
48+
Target,
49+
TtlControl,
50+
} from "./services";
2951
import { buildManifest } from "./viewer/manifest";
3052

3153
export const RUNS_DIR = fileURLToPath(new URL("../runs/", import.meta.url));
@@ -41,24 +63,38 @@ export interface ScenarioOptions {
4163
readonly timeout?: number;
4264
}
4365

44-
type AllServices = Target | RunDir | Cli | Api | Browser | Mcp | Billing | OpenCode | TtlControl;
66+
type AllServices =
67+
| Target
68+
| RunDir
69+
| Cli
70+
| Api
71+
| Browser
72+
| Mcp
73+
| Billing
74+
| OpenCode
75+
| TtlControl;
4576

4677
/**
4778
* What this target on this host can provide. Services beyond the base are
4879
* conditional, so the claimed type is the full union — yielding an absent
4980
* one fails with Effect's missing-service defect, which the runner turns
5081
* into the skip.
5182
*/
52-
const contextFor = (target: TargetShape, dir: string): Context.Context<AllServices> => {
83+
const contextFor = (
84+
target: TargetShape,
85+
dir: string,
86+
): Context.Context<AllServices> => {
5387
let context = Context.empty().pipe(
5488
Context.add(Target, target),
5589
Context.add(RunDir, dir),
5690
Context.add(Cli, makeCliSurface()),
5791
) as Context.Context<AllServices>;
5892
const has = target.capabilities.has.bind(target.capabilities);
5993
if (has("api")) context = Context.add(context, Api, makeApiSurface(target));
60-
if (has("browser")) context = Context.add(context, Browser, makeBrowserSurface(dir, target));
61-
if (has("mcp-oauth")) context = Context.add(context, Mcp, makeMcpSurface(target));
94+
if (has("browser"))
95+
context = Context.add(context, Browser, makeBrowserSurface(dir, target));
96+
if (has("mcp-oauth"))
97+
context = Context.add(context, Mcp, makeMcpSurface(target, dir));
6298
if (has("billing")) context = Context.add(context, Billing, true);
6399
if (hasOpenCode()) {
64100
context = Context.add(context, OpenCode, {
@@ -101,24 +137,32 @@ export const scenario = (
101137
const endedAt = Date.now();
102138

103139
// Yielding a service this target can't provide is the skip signal.
104-
const missing = exit._tag === "Failure" ? missingServices(exit.cause) : [];
140+
const missing =
141+
exit._tag === "Failure" ? missingServices(exit.cause) : [];
105142
if (missing.length > 0) {
106143
rmSync(dir, { recursive: true, force: true });
107144
mkdirSync(dir, { recursive: true });
108145
writeFileSync(
109146
join(dir, "skipped.json"),
110-
JSON.stringify({ scenario: name, target: target.name, missing }, null, 1),
147+
JSON.stringify(
148+
{ scenario: name, target: target.name, missing },
149+
null,
150+
1,
151+
),
111152
);
112153
buildManifest(RUNS_DIR);
113154
return yield* Effect.sync(() =>
114155
testCtx.skip(`needs ${missing.join(", ")} — not on ${target.name}`),
115156
);
116157
}
117158

118-
const error = exit._tag === "Failure" ? failureMessage(exit.cause) : undefined;
159+
const error =
160+
exit._tag === "Failure" ? failureMessage(exit.cause) : undefined;
119161
// The test source is the review artifact — ship this scenario's code
120162
// (imports + sibling scenarios stripped) alongside the run.
121-
const source = testFile ? extractScenarioSource(testFile, name) : undefined;
163+
const source = testFile
164+
? extractScenarioSource(testFile, name)
165+
: undefined;
122166
if (source) writeFileSync(join(dir, "test.ts"), source);
123167
writeFileSync(
124168
join(dir, "result.json"),
@@ -152,7 +196,10 @@ export const scenario = (
152196
try {
153197
execFileSync(
154198
"bun",
155-
[fileURLToPath(new URL("../scripts/film.ts", import.meta.url)), dir],
199+
[
200+
fileURLToPath(new URL("../scripts/film.ts", import.meta.url)),
201+
dir,
202+
],
156203
{ stdio: "pipe", timeout: 120_000 },
157204
);
158205
} catch {
@@ -170,7 +217,9 @@ export const scenario = (
170217
};
171218

172219
/** Service keys (sans the e2e/ prefix) whose absence caused this failure. */
173-
const missingServices = (cause: Cause.Cause<unknown>): ReadonlyArray<string> => {
220+
const missingServices = (
221+
cause: Cause.Cause<unknown>,
222+
): ReadonlyArray<string> => {
174223
const rendered = String(Cause.squash(cause));
175224
return [...rendered.matchAll(/Service not found: e2e\/([^\s(]+)/g)]
176225
.map((match) => match[1] ?? "")
@@ -198,9 +247,15 @@ const captureTestFile = (): string | undefined => {
198247
* part of understanding the test). Falls back to undefined on any surprise so
199248
* a parsing edge case can never fail a run.
200249
*/
201-
const extractScenarioSource = (filePath: string, name: string): string | undefined => {
250+
const extractScenarioSource = (
251+
filePath: string,
252+
name: string,
253+
): string | undefined => {
202254
try {
203-
const source = readFileSync(filePath, "utf8").replace(/^import[\s\S]*?;[^\S\n]*$/gm, "");
255+
const source = readFileSync(filePath, "utf8").replace(
256+
/^import[\s\S]*?;[^\S\n]*$/gm,
257+
"",
258+
);
204259
const needle = "scenario(";
205260
const blocks: Array<{ start: number; end: number; mine: boolean }> = [];
206261
let index = 0;
@@ -218,7 +273,11 @@ const extractScenarioSource = (filePath: string, name: string): string | undefin
218273
}
219274
}
220275
if (end === -1) return undefined; // unbalanced — bail to be safe
221-
blocks.push({ start: index, end, mine: source.slice(index, end).includes(`"${name}"`) });
276+
blocks.push({
277+
start: index,
278+
end,
279+
mine: source.slice(index, end).includes(`"${name}"`),
280+
});
222281
index = end;
223282
}
224283
if (!blocks.some((b) => b.mine)) return undefined;

e2e/src/surfaces/browser.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
// everywhere), per-step screenshots, and a failure screenshot. The scenario
55
// drives `page` directly; assertions are vitest's job.
66
import { execFile } from "node:child_process";
7-
import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7+
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
88
import { join } from "node:path";
99
import { promisify } from "node:util";
1010

1111
import { Effect } from "effect";
1212
import { chromium, type Page } from "playwright";
1313

1414
import { markFocus, markNavigation, markRecordingStart } from "../timeline";
15+
import { appendTraces, type TraceEntry } from "../trace-harvest";
1516
import type { Identity, Target } from "../target";
1617

1718
export interface BrowserSession {
@@ -92,29 +93,25 @@ export const makeBrowserSurface = (
9293
// Harvest distributed-trace ids: every app API request carries a W3C
9394
// traceparent (Effect's HttpClient), and each id names one
9495
// click→server→DB trace in whatever OTLP store the run exported to
95-
// (motel locally). Written to traces.json so the runs viewer can
96-
// link a recording to its traces. Duration comes from the
97-
// finished/failed event so the viewer can answer "why did that
98-
// take so long" without leaving the run page.
99-
interface TraceRef {
100-
id: string;
101-
at: number;
102-
url: string;
103-
ms?: number;
104-
status?: number;
105-
}
106-
const traceIds: TraceRef[] = [];
107-
const inflight = new Map<unknown, TraceRef>();
96+
// (motel locally). Appended to traces.json (shared with the MCP
97+
// surface's terminal-side entries) so the runs viewer can link a
98+
// recording to its traces. Duration comes from the finished/failed
99+
// event so the viewer can answer "why did that take so long"
100+
// without leaving the run page.
101+
const traceIds: Array<TraceEntry & { ms?: number; status?: number }> =
102+
[];
103+
const inflight = new Map<unknown, (typeof traceIds)[number]>();
108104
page.on("request", (request) => {
109105
const traceparent = request.headers()["traceparent"];
110106
const match = traceparent
111107
? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent)
112108
: null;
113109
if (match?.[1]) {
114-
const entry: TraceRef = {
110+
const entry: (typeof traceIds)[number] = {
115111
id: match[1],
116112
at: Date.now(),
117113
url: request.url(),
114+
source: "browser",
118115
};
119116
traceIds.push(entry);
120117
inflight.set(request, entry);
@@ -175,12 +172,7 @@ export const makeBrowserSurface = (
175172
}),
176173
({ browser, context, page, videoTmp, traceIds }) =>
177174
Effect.promise(async () => {
178-
if (traceIds.length > 0) {
179-
writeFileSync(
180-
join(dir, "traces.json"),
181-
JSON.stringify(traceIds, null, 1),
182-
);
183-
}
175+
appendTraces(dir, traceIds);
184176
await context.tracing
185177
.stop({ path: join(dir, "trace.zip") })
186178
.catch(() => {});

0 commit comments

Comments
 (0)