diff --git a/apps/server/scripts/dev-replay-agent.test.ts b/apps/server/scripts/dev-replay-agent.test.ts index c3abef65161b7..ee322c9f48fbf 100644 --- a/apps/server/scripts/dev-replay-agent.test.ts +++ b/apps/server/scripts/dev-replay-agent.test.ts @@ -6,7 +6,12 @@ import * as NodeURL from "node:url"; import { describe, expect, it } from "@effect/vitest"; -import { parseReplayTurns, SCENARIOS, streamChunks } from "./dev-replay-agent.ts"; +import { + parseReplayTurns, + scenarioTranscript, + SCENARIOS, + streamChunks, +} from "./dev-replay-agent.ts"; interface AgentMessage { readonly id?: number; @@ -89,8 +94,8 @@ describe("dev replay agent", () => { expect(new Set(toolCallIds).size).toBe(6); }); - it.each(SCENARIOS)("$id plays a turn without session setup frames", ({ file }) => { - const turns = parseReplayTurns(NodeFS.readFileSync(new URL(file, import.meta.url), "utf8")); + it.each(SCENARIOS)("$id plays a turn without session setup frames", (scenario) => { + const turns = parseReplayTurns(scenarioTranscript(scenario)); expect(turns.length).toBeGreaterThan(0); const updates = turns.flatMap((turn) => turn.frames.flatMap(({ frame }) => diff --git a/apps/server/scripts/dev-replay-agent.ts b/apps/server/scripts/dev-replay-agent.ts index b6eea893ad6b7..68663f5418508 100644 --- a/apps/server/scripts/dev-replay-agent.ts +++ b/apps/server/scripts/dev-replay-agent.ts @@ -10,6 +10,8 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeReadline from "node:readline"; +import { stressTranscript } from "./dev-replay/stressTranscript.ts"; + interface LogicalFrame { readonly kind: "notification" | "request" | "response"; readonly method: string; @@ -64,6 +66,8 @@ export const SCENARIOS = [ name: "Multi turn", file: `${FIXTURES}/multi_turn/registry_transcript.ndjson`, }, + // Generated, so `vp run stress` can load the app without committing a large recording. + { id: "stress", name: "Stress", generate: stressTranscript }, ] as const; // Session setup and vendor state stay out: they would replace the replay agent's own models. @@ -94,6 +98,12 @@ function isReplayedFrame(frame: LogicalFrame): boolean { return typeof update?.sessionUpdate === "string" && REPLAYED_UPDATES.has(update.sessionUpdate); } +export function scenarioTranscript(scenario: (typeof SCENARIOS)[number]): string { + return "generate" in scenario + ? scenario.generate() + : NodeFS.readFileSync(new URL(scenario.file, import.meta.url), "utf8"); +} + /** Splits a recorded transcript into the turns that follow each `session/prompt`. */ export function parseReplayTurns(ndjson: string): ReadonlyArray { const turns: Array<{ frames: Array; stopReason: string }> = []; @@ -177,9 +187,7 @@ function runAgent(): void { const cached = turnsByScenario.get(scenarioId); if (cached !== undefined) return cached; const scenario = SCENARIOS.find((candidate) => candidate.id === scenarioId) ?? SCENARIOS[0]; - const turns = parseReplayTurns( - NodeFS.readFileSync(new URL(scenario.file, import.meta.url), "utf8"), - ); + const turns = parseReplayTurns(scenarioTranscript(scenario)); turnsByScenario.set(scenarioId, turns); return turns; }; diff --git a/apps/server/scripts/dev-replay/stressTranscript.ts b/apps/server/scripts/dev-replay/stressTranscript.ts new file mode 100644 index 0000000000000..895e9b95c1522 --- /dev/null +++ b/apps/server/scripts/dev-replay/stressTranscript.ts @@ -0,0 +1,216 @@ +/** + * A seeded, long transcript for `vp run stress`: every turn thinks, plans, reads, runs a + * command with long output, asks to edit, and answers with a long Markdown reply. + */ + +const TURNS = 4; +const REPLY_SECTIONS = 14; + +function seeded(seed: number) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const WORDS = + "the timeline renders each row once while the stream appends text so finished blocks keep their layout and the list measures only what grew between frames when a reply streams the sidebar still updates every running thread".split( + " ", + ); +const FILES = [ + "apps/web/src/components/ChatMarkdown.tsx", + "apps/web/src/components/chat/MessagesTimeline.tsx", + "apps/server/src/orchestration-v2/Orchestrator.ts", + "packages/client-runtime/src/state/threadDetail.ts", + "apps/web/src/components/pullRequest/PullRequestCodeTab.tsx", +]; + +function generator(seed: number) { + const random = seeded(seed); + const pick = (values: ReadonlyArray) => values[Math.floor(random() * values.length)]!; + const words = (count: number) => Array.from({ length: count }, () => pick(WORDS)).join(" "); + const sentence = () => { + const text = words(8 + Math.floor(random() * 14)); + return `${text[0]!.toUpperCase()}${text.slice(1)}.`; + }; + const paragraph = () => + Array.from({ length: 2 + Math.floor(random() * 4) }, () => { + const roll = random(); + const text = sentence(); + if (roll < 0.2) return text.replace(/ (\w+) /, " `$1` "); + if (roll < 0.35) return text.replace(/ (\w+ \w+) /, " **$1** "); + if (roll < 0.45) + return `${text} See [${pick(FILES)}](${pick(FILES)}#L${10 + Math.floor(random() * 400)}).`; + if (roll < 0.5) return `${text} Details in [the docs](https://example.com/${pick(WORDS)}).`; + return text; + }).join(" "); + const code = () => + [ + "```ts", + ...Array.from({ length: 6 + Math.floor(random() * 14) }, (_, line) => + line % 4 === 0 + ? `export function ${pick(WORDS)}${line}(input: number): number {` + : line % 4 === 3 + ? "}" + : ` return input * ${line} + ${Math.floor(random() * 100)}; // ${words(4)}`, + ), + "```", + ].join("\n"); + const table = () => + [ + "| File | Change | Lines |", + "| --- | --- | ---: |", + ...Array.from( + { length: 3 + Math.floor(random() * 5) }, + () => `| \`${pick(FILES)}\` | ${words(4)} | ${Math.floor(random() * 300)} |`, + ), + ].join("\n"); + const list = (ordered: boolean) => + Array.from( + { length: 3 + Math.floor(random() * 4) }, + (_, index) => `${ordered ? `${index + 1}.` : "-"} ${sentence()}`, + ).join("\n"); + const reply = () => + Array.from({ length: REPLY_SECTIONS }, (_, section) => + [ + `## ${sentence().slice(0, 40)} ${section + 1}`, + paragraph(), + pick([code, table, () => list(false), () => list(true), () => `> ${sentence()}`])(), + paragraph(), + ].join("\n\n"), + ).join("\n\n"); + const terminal = () => + Array.from( + { length: 120 }, + (_, line) => + ` ✓ ${pick(FILES).replace(".tsx", ".test.tsx")} (${line} tests) ${Math.floor(random() * 90)}ms`, + ).join("\n"); + return { paragraph, reply, terminal, words }; +} + +export function stressTranscript(): string { + const lines: Array = [ + { type: "transcript_start", provider: "acpRegistry", scenario: "dev_replay_stress" }, + ]; + const update = (value: Record, afterMs?: number) => + lines.push({ + type: "emit_inbound", + frame: { + kind: "notification", + method: "session/update", + params: { sessionId: "dev-replay-session", update: value }, + }, + ...(afterMs === undefined ? {} : { afterMs }), + }); + const plan = (done: number) => ({ + sessionUpdate: "plan", + entries: ["Read the affected files", "Edit the renderer", "Run the tests"].map( + (content, index) => ({ + content, + priority: "high", + status: index < done ? "completed" : index === done ? "in_progress" : "pending", + }), + ), + }); + for (let turn = 0; turn < TURNS; turn++) { + const text = generator(turn + 1); + lines.push({ + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }); + update({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: text.paragraph() }, + }); + update(plan(0)); + for (const [index, path] of FILES.slice(0, 3).entries()) { + const id = `read-${index}`; + update({ + sessionUpdate: "tool_call", + toolCallId: id, + kind: "read", + title: `Read ${path}`, + status: "pending", + rawInput: { path: `/${path}` }, + }); + update({ + sessionUpdate: "tool_call_update", + toolCallId: id, + status: "completed", + content: [{ type: "content", content: { type: "text", text: text.paragraph() } }], + }); + } + update(plan(1)); + update({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `${text.paragraph()}\n\n` }, + }); + const edit = { + toolCallId: "edit", + kind: "edit", + title: `Edit ${FILES[0]}`, + rawInput: { path: `/${FILES[0]}` }, + }; + const diff = { + type: "diff", + path: `/${FILES[0]}`, + oldText: `${text.words(30)}\n`, + newText: `${text.words(30)}\n${text.words(30)}\n`, + }; + update({ sessionUpdate: "tool_call", ...edit, status: "pending", content: [diff] }); + lines.push({ + type: "emit_inbound", + frame: { + kind: "request", + method: "session/request_permission", + params: { + sessionId: "dev-replay-session", + toolCall: edit, + options: [ + { optionId: "allow-once", name: "Yes", kind: "allow_once" }, + { optionId: "reject-once", name: "No", kind: "reject_once" }, + ], + }, + }, + }); + lines.push({ + type: "expect_outbound", + frame: { kind: "response", method: "session/request_permission", result: "" }, + }); + update({ + sessionUpdate: "tool_call_update", + toolCallId: "edit", + status: "completed", + content: [diff], + }); + update(plan(2)); + update({ + sessionUpdate: "tool_call", + toolCallId: "test", + kind: "execute", + title: "vp test run", + status: "in_progress", + rawInput: { command: "vp test run" }, + }); + update( + { + sessionUpdate: "tool_call_update", + toolCallId: "test", + status: "completed", + content: [{ type: "content", content: { type: "text", text: text.terminal() } }], + }, + 1200, + ); + update(plan(3)); + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: text.reply() } }); + lines.push({ + type: "emit_inbound", + frame: { kind: "response", method: "session/prompt", result: { stopReason: "end_turn" } }, + }); + } + return lines.map((line) => JSON.stringify(line)).join("\n"); +} diff --git a/apps/server/src/pullRequest/PullRequestFixtures.test.ts b/apps/server/src/pullRequest/PullRequestFixtures.test.ts new file mode 100644 index 0000000000000..92a541c3ed7ce --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestFixtures.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { parseNumstat, patchPaths, splitPatch } from "./PullRequestFixtures.ts"; + +const patch = [ + "diff --git a/src/a.ts b/src/a.ts", + "index 1..2 100644", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1 +1 @@", + "-diff --git a/not/a/header b/in/content", + "+x", + "diff --git a/old name.ts b/new name.ts", + "similarity index 90%", + "rename from old name.ts", + "rename to new name.ts", + "", +].join("\n"); + +describe("PullRequestFixtures", () => { + it("splits a git diff into file patches and reads their paths, renames included", () => { + const files = splitPatch(patch); + expect(files).toHaveLength(2); + expect(files.join("")).toBe(patch); + expect(files.map(patchPaths)).toEqual([ + { oldPath: "src/a.ts", newPath: "src/a.ts" }, + { oldPath: "old name.ts", newPath: "new name.ts" }, + ]); + }); + + it("reads numstat -z output with renames and binary files", () => { + expect(parseNumstat("3\t1\tsrc/a.ts\0-\t-\timage.png\x002\t0\t\0old.ts\0new.ts\0")).toEqual([ + { path: "src/a.ts", additions: 3, deletions: 1 }, + { path: "image.png", additions: 0, deletions: 0 }, + { path: "new.ts", additions: 2, deletions: 0 }, + ]); + }); +}); diff --git a/apps/server/src/pullRequest/PullRequestFixtures.ts b/apps/server/src/pullRequest/PullRequestFixtures.ts new file mode 100644 index 0000000000000..f1bad01ff5556 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestFixtures.ts @@ -0,0 +1,328 @@ +/** + * Recorded GitHub change requests for `vp run stress` (see apps/web/stress/README.md): + * `T3CODE_PR_FIXTURES=` serves reads from calls/, and diffs from a bare clone in repos/ since + * the API truncates them; `T3CODE_PR_FIXTURES_RECORD=1` saves what the real host answers instead. + */ +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { + PullRequestProviderError, + type ProviderDiffSlice, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; +import * as Cache from "effect/Cache"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "../processRunner.ts"; + +const fixturesConfig = Config.all({ + dir: Config.String("T3CODE_PR_FIXTURES").pipe(Config.option), + record: Config.Boolean("T3CODE_PR_FIXTURES_RECORD").pipe(Config.withDefault(false)), +}); + +const RECORDED_READS = [ + "getViewer", + "listChangeRequests", + "listChangeRequestsAcross", + "listChangeRequestStats", + "getChangeRequestChecks", + "getChangeRequest", + "getChangeRequestPreview", + "getChangeRequestSummary", + "getChangeRequestWatchFingerprint", + "getChangeRequestStack", + "getChangeRequestActivity", + "getReviewThreadComments", + "getViewerPermissions", + "getFilesViewed", + "getFileRevisions", + "listReviewerCandidates", + "listLabelCandidates", + "listRepositoryLabels", + "searchRepositoryAuthors", +] as const satisfies ReadonlyArray; + +const DIFF_PAGE_FILES = 100; +const PATHS_PAGE_FILES = 1000; +const MAX_DIFF_BYTES = 2 * 1024 * 1024 * 1024; + +const RecordedCall = Schema.Struct({ input: Schema.Unknown, result: Schema.Unknown }); +const ChangeRevisions = Schema.Struct({ baseSha: Schema.String, headSha: Schema.String }); +const decodeCall = Schema.decodeUnknownEffect(Schema.fromJsonString(RecordedCall)); +const decodeRevisions = Schema.decodeUnknownEffect(Schema.fromJsonString(ChangeRevisions)); + +// Machine and account details differ between the recording and the replay. +const VOLATILE_INPUT_KEYS = new Set(["cwd", "host", "viewer"]); + +function stableKey(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableKey).join(",")}]`; + if (typeof value !== "object" || value === null) return JSON.stringify(value) ?? "null"; + return `{${Object.entries(value) + .filter(([key, entry]) => entry !== undefined && !VOLATILE_INPUT_KEYS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableKey(entry)}`) + .join(",")}}`; +} + +/** FNV-1a; names files, so collisions only matter between reads of one fixture set. */ +function hash(text: string): string { + let value = 0x811c9dc5; + for (let index = 0; index < text.length; index++) { + value ^= text.charCodeAt(index); + value = Math.imul(value, 0x01000193); + } + return (value >>> 0).toString(16).padStart(8, "0"); +} + +const callFile = (method: string, input: unknown) => `${method}-${hash(stableKey(input))}.json`; +const repositorySlug = (repository: string) => repository.replace("/", "__"); + +/** Splits `git diff` output into one patch per file, keeping each file's header lines. */ +export function splitPatch(patch: string): string[] { + return patch.split(/^(?=diff --git )/m).filter((part) => part.startsWith("diff --git ")); +} + +/** The old and new path of one file's patch, from its `---`/`+++` lines or its header. */ +export function patchPaths(filePatch: string): { oldPath: string; newPath: string } { + const header = /^diff --git a\/(.+?) b\/(.+)$/m.exec(filePatch); + const renameFrom = /^rename from (.+)$/m.exec(filePatch)?.[1]; + const renameTo = /^rename to (.+)$/m.exec(filePatch)?.[1]; + const oldPath = renameFrom ?? header?.[1] ?? ""; + const newPath = renameTo ?? header?.[2] ?? oldPath; + return { oldPath, newPath }; +} + +/** `git diff --numstat -z`: a rename leaves its path empty and follows with old and new paths. */ +export function parseNumstat(output: string) { + const fields = output.split("\0"); + const files: Array<{ path: string; additions: number; deletions: number }> = []; + for (let index = 0; index < fields.length; index++) { + const [additions, deletions, path] = fields[index]!.split("\t"); + if (additions === undefined || deletions === undefined || path === undefined) continue; + // Binary files report "-" for both counts. + const counts = { additions: Number(additions) || 0, deletions: Number(deletions) || 0 }; + if (path === "") { + files.push({ path: fields[index + 2] ?? "", ...counts }); + index += 2; + } else { + files.push({ path, ...counts }); + } + } + return files; +} + +/** Wraps a real provider so each read it answers is also written to `dir`. */ +const recordingProvider = Effect.fn("PullRequestFixtures.recordingProvider")(function* ( + provider: PullRequestProviderApi, + dir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const calls = path.join(dir, "calls"); + yield* fs.makeDirectory(calls, { recursive: true }).pipe(Effect.orDie); + const wrapped: Record = { ...provider }; + for (const method of RECORDED_READS) { + const read = provider[method] as + | ((input: unknown) => Effect.Effect) + | undefined; + if (read === undefined) continue; + wrapped[method] = (input: unknown) => + read(input).pipe( + Effect.tap((result) => + fs + .writeFileString( + path.join(calls, callFile(method, input)), + JSON.stringify({ input, result }), + ) + .pipe(Effect.ignore), + ), + ); + } + return wrapped as unknown as PullRequestProviderApi; +}); + +/** Serves `provider`'s kind from `dir` alone: recorded reads, diffs from the local clone. */ +const replayProvider = Effect.fn("PullRequestFixtures.replayProvider")(function* ( + provider: PullRequestProviderApi, + dir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processes = yield* ProcessRunner.ProcessRunner; + const kind: SourceControlProviderKind = provider.kind; + + const notRecorded = (operation: string, detail: string) => + new PullRequestProviderError({ provider: kind, operation, reason: "not-found", detail }); + + const recorded = (method: string, input: unknown) => + fs.readFileString(path.join(dir, "calls", callFile(method, input))).pipe( + Effect.flatMap(decodeCall), + Effect.map((call) => call.result), + Effect.mapError(() => notRecorded(method, `No recorded ${method} for this input.`)), + ); + + const git = (operation: string, repository: string, args: ReadonlyArray) => + processes + .run({ + command: "git", + args: ["--git-dir", path.join(dir, "repos", `${repositorySlug(repository)}.git`), ...args], + maxOutputBytes: MAX_DIFF_BYTES, + timeout: "10 minutes", + }) + .pipe( + Effect.mapError((error) => notRecorded(operation, `git failed: ${error.message}`)), + Effect.flatMap((output) => + output.code === 0 + ? Effect.succeed(output.stdout) + : Effect.fail(notRecorded(operation, output.stderr.trim())), + ), + ); + + const revisions = (repository: string, number: number) => + fs + .readFileString(path.join(dir, "changes", `${repositorySlug(repository)}__${number}.json`)) + .pipe( + Effect.flatMap(decodeRevisions), + Effect.mapError(() => + notRecorded("getDiff", `No recorded revisions for ${repository}#${number}.`), + ), + ); + + // One diff per change or commit, split once; the code tab pages through it many times. + const filePatches = yield* Cache.make({ + capacity: 8, + lookup: Effect.fn("PullRequestFixtures.filePatches")(function* (key: string) { + const [repository, number, commit] = key.split("\u0000") as [string, string, string]; + if (commit) { + return splitPatch( + yield* git("getDiff", repository, ["show", "--no-color", "--format=", "-M", commit]), + ); + } + const { baseSha, headSha } = yield* revisions(repository, Number(number)); + return splitPatch( + yield* git("getDiff", repository, ["diff", "--no-color", "-M", baseSha, headSha]), + ); + }), + }); + const patchesFor = (input: { + readonly repository: string; + readonly number: number; + readonly commit?: string | undefined; + }) => + Cache.get(filePatches, `${input.repository}\u0000${input.number}\u0000${input.commit ?? ""}`); + + const page = (items: ReadonlyArray, cursor: string | undefined, size: number) => { + const start = cursor === undefined ? 0 : Number(cursor); + const end = start + size; + return { items: items.slice(start, end), nextCursor: end < items.length ? String(end) : null }; + }; + + const fallbackViewer = "fixture"; + const viewer = recorded("getViewer", {}).pipe( + Effect.map((value) => (typeof value === "string" ? value : fallbackViewer)), + Effect.orElseSucceed(() => fallbackViewer), + ); + + const withVerifiedCredential: NonNullable = ( + _input, + use, + ) => + viewer.pipe( + Effect.flatMap((name) => + use({ accountId: "fixture", viewer: name, credentialFingerprint: "fixture" }), + ), + ); + const getDiffFileContents: NonNullable = Effect.fn( + "PullRequestFixtures.getDiffFileContents", + )(function* (input) { + const { baseSha, headSha } = input.commit + ? { baseSha: `${input.commit}^`, headSha: input.commit } + : yield* revisions(input.repository, input.number); + const show = (revision: string, file: string) => + git("getDiffFileContents", input.repository, ["show", `${revision}:${file}`]); + return { + oldContents: input.changeType === "new" ? "" : yield* show(baseSha, input.oldPath), + newContents: input.changeType === "deleted" ? "" : yield* show(headSha, input.newPath), + }; + }); + + const replayed: Record = { + ...provider, + withVerifiedCredential, + getRoutingIdentity: () => + viewer.pipe(Effect.map((name) => ({ accountId: "fixture", viewer: name }))), + getViewer: () => viewer, + getDiff: (input: Parameters[0]) => + patchesFor(input).pipe( + Effect.map((patches): ProviderDiffSlice => { + const { items, nextCursor } = page(patches, input.cursor, DIFF_PAGE_FILES); + return { patch: items.join(""), truncated: false, nextCursor }; + }), + ), + getDiffFilePaths: ( + input: Parameters>[0], + ) => + patchesFor(input).pipe( + Effect.map((patches) => { + const { items, nextCursor } = page( + patches.map(patchPaths), + input.cursor, + PATHS_PAGE_FILES, + ); + return { files: items, nextCursor }; + }), + ), + getFileStats: (input: Parameters>[0]) => + revisions(input.repository, input.number).pipe( + Effect.flatMap(({ baseSha, headSha }) => + git("getFileStats", input.repository, [ + "diff", + "--numstat", + "-z", + "-M", + baseSha, + headSha, + ]), + ), + Effect.map((numstat) => ({ files: parseNumstat(numstat), truncated: false })), + ), + getDiffFileContents, + getFilesViewed: (input: unknown) => + recorded("getFilesViewed", input).pipe( + Effect.orElseSucceed(() => ({ files: [], truncated: false })), + ), + forgetChangeRequestReads: () => Effect.void, + runAction: () => Effect.void, + updateChangeRequest: () => Effect.void, + comment: () => Effect.void, + updateComment: () => Effect.void, + submitReview: () => Effect.void, + setReviewerRequest: () => Effect.void, + setLabels: () => Effect.void, + replyToThread: () => Effect.void, + setReaction: () => Effect.void, + setThreadResolution: () => Effect.void, + setFilesViewed: () => Effect.void, + }; + for (const method of RECORDED_READS) { + if (method === "getViewer" || provider[method] === undefined) continue; + replayed[method] = (input: unknown) => recorded(method, input); + } + return replayed as unknown as PullRequestProviderApi; +}); + +/** The fixture form of `provider` when the environment asks for one, else `provider` itself. */ +export const applyFixtures = Effect.fn("PullRequestFixtures.applyFixtures")(function* ( + provider: PullRequestProviderApi, +) { + const config = yield* fixturesConfig; + const dir = Option.getOrUndefined(config.dir); + if (dir === undefined || provider.kind !== "github") return provider; + return config.record + ? yield* recordingProvider(provider, dir) + : yield* replayProvider(provider, dir); +}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 63323b6122d20..91f6042cfcb1e 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; +import * as PullRequestFixtures from "./PullRequestFixtures.ts"; import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; export class PullRequestProviderRegistry extends Context.Service< @@ -27,7 +28,12 @@ export function fromProviders( /** @public */ export const make = Effect.gen(function* () { const drivers = yield* BuiltInDrivers.SourceControlDrivers; - return fromProviders(drivers.flatMap(({ instance }) => instance.pullRequests ?? [])); + return fromProviders( + yield* Effect.forEach( + drivers.flatMap(({ instance }) => instance.pullRequests ?? []), + PullRequestFixtures.applyFixtures, + ), + ); }); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( diff --git a/apps/web/stress/README.md b/apps/web/stress/README.md new file mode 100644 index 0000000000000..d7d5b34e26927 --- /dev/null +++ b/apps/web/stress/README.md @@ -0,0 +1,36 @@ +# Stress rig + +`vp run stress` puts a production build of T3 Code under concurrent load and records how the page holds up. It finds performance work; it is not a CI gate. + +## What a run does + +1. Builds `apps/web/dist` in production mode without minification, so profiles name real functions. `--skip-build` reuses the last build. +2. Starts a server on a fresh home in `.t3/stress/home` (never your real data) with the `Replay (dev)` provider and a scratch git project. +3. Starts `--threads` threads (default 5) on the Replay `stress` scenario, each with `--turns-per-thread` turns queued (default 4). Every turn thinks, plans, reads, asks for an edit approval, prints long terminal output, and streams a long Markdown reply through the server's default paragraph streaming. +4. Opens headless Chromium, pairs, and for `--duration` seconds (default 60) acts like a busy user: switches threads, approves edits, types in the composer, opens and closes the right panel, and scrolls. +5. Writes `report.md`, `summary.json`, `profile.cpuprofile` (open in Chrome DevTools), screenshots, and `server.log` to `.t3/stress/runs/