From 8c61e254fd07249b8c420c44d62e8ecd72a78a20 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:02:09 +0530 Subject: [PATCH 01/59] fix(web): stop wide ordered list markers from clipping (#16523) --- apps/web/src/components/ChatMarkdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3b736cae2653..a56db444d200 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -378,7 +378,7 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits one-character * markers. Wider markers can extend past it and get clipped by a collapsed * message's overflow. Widen the gutter to fit the widest marker, including a - * negative marker's minus sign. + * negative marker's minus sign, the period, and the trailing space. */ function orderedListGutterStyle( itemCount: number, @@ -389,7 +389,7 @@ function orderedListGutterStyle( const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); if (markerWidth <= 1) return undefined; - return { "--list-gutter": `${markerWidth + 1}ch` }; + return { "--list-gutter": `${markerWidth + 2}ch` }; } type MarkdownImageHastNode = { From 9b2c03ee25321c16928eda74f4036f36f0b1afce Mon Sep 17 00:00:00 2001 From: Lorenzo <150276837+Bombatomica64@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:41:30 +0200 Subject: [PATCH 02/59] fix(desktop): build AppImage with the static runtime toolset (fixes libfuse2 launch failure) (#7765) Co-authored-by: Julius Marminge --- scripts/build-desktop-artifact.test.ts | 3 +++ scripts/build-desktop-artifact.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index aa733d531631..4b9294e16887 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -684,6 +684,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); + assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" }); + assert.notProperty(mac, "toolsets"); + assert.notProperty(win, "toolsets"); assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f8f5a6ca2d52..6bf280469c69 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2762,6 +2762,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "linux") { + // electron-builder 26 defaults to its legacy AppImage runtime, which + // dynamically loads the system libfuse2 library. Pin the static runtime so + // the AppImage also launches on distributions that only provide FUSE 3. + buildConfig.toolsets = { appimage: "1.0.3" }; buildConfig.linux = { // The .deb is built from the same unpacked app after the AppImage. // electron-builder lists both in latest-linux.yml and writes From 7bf6de174171ffcf0215bf55556f0e3ee7e6d78f Mon Sep 17 00:00:00 2001 From: Benedikt Rump Date: Tue, 6 Oct 2026 22:01:45 +0200 Subject: [PATCH 03/59] fix(mobile): keep usage-limit notice opaque (#15602) --- apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 0b902e25db60..48f17f28fbc3 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -67,7 +67,7 @@ export function UsageLimitRecoveryCard({ } } return ( - + {resetAt ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` From 1564aaa5d29deb2520d1211f9f35dd6150b1512e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:14:12 -0700 Subject: [PATCH 04/59] feat(server): GitHub API transport that uses gh only for the token (#16319) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 497 +++++++++++++++ apps/server/src/sourceControl/GitHubApi.ts | 593 ++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 7 +- .../src/sourceControl/GitHubCredentials.ts | 189 ++++++ 4 files changed, 1281 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/sourceControl/GitHubApi.test.ts create mode 100644 apps/server/src/sourceControl/GitHubApi.ts create mode 100644 apps/server/src/sourceControl/GitHubCredentials.ts diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts new file mode 100644 index 000000000000..85fe9a2dcfbe --- /dev/null +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; +import { ChildProcessSpawner } from "effect/process"; +import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +const NOW = Date.parse("2026-10-05T12:00:00.000Z"); + +function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { + const requests: Array = []; + let tokens = ["first", "second"]; + let invalidations = 0; + const credentials = Layer.succeed( + GitHubCredentials.GitHubCredentials, + GitHubCredentials.GitHubCredentials.of({ + get: (host) => + Effect.sync(() => ({ + host, + token: Redacted.make(tokens[0]!), + source: "gh" as const, + fingerprint: `${host}:${tokens[0]}`, + })), + invalidate: () => + Effect.sync(() => { + invalidations++; + tokens = tokens.slice(1); + }), + }), + ); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request); + return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))); + }), + ); + const layer = GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), + ); + return { layer, requests, invalidations: () => invalidations }; +} + +const json = (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + +describe("gitHubApiUrls", () => { + it("maps github.com, GHE.com and GitHub Enterprise Server", () => { + expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({ + rest: "https://api.github.com", + graphql: "https://api.github.com/graphql", + }); + expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe( + "https://api.acme.ghe.com/graphql", + ); + expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({ + rest: "https://git.acme.internal/api/v3", + graphql: "https://git.acme.internal/api/graphql", + }); + }); +}); + +describe("environmentToken", () => { + it("follows gh's precedence per kind of host", () => { + const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" }; + expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh"); + expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( + "github", + ); + // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. + expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); + expect( + GitHubCredentials.environmentToken("git.acme.internal", { + ...env, + GH_HOST: "git.acme.internal", + }), + ).toBe("ghe"); + expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); + }); +}); + +describe("GitHubApi", () => { + it.effect("sends GraphQL with the token and records the reported budget", () => { + const { layer, requests } = harness(() => + json({ + data: { + viewer: { login: "julius" }, + rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" }, + }, + }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const body = yield* api.graphql({ + host: "github.com", + operation: "viewer", + query: "query { viewer { login } }", + }); + expect(body).toContain('"login":"julius"'); + expect(requests[0]!.url).toBe("https://api.github.com/graphql"); + expect(requests[0]!.headers.authorization).toBe("Bearer first"); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "traces the operation, path, query and cost, never the query string or variables", + () => { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const { layer } = harness((request) => + request.url.endsWith("/graphql") + ? json( + { + data: { + viewer: { login: "julius" }, + rateLimit: { + cost: 3, + limit: 5000, + remaining: 4990, + resetAt: "2026-10-05T13:00:00Z", + }, + }, + }, + { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, + ) + : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + yield* api.graphql({ + host: "github.com", + operation: "getPullRequestDetail", + query: "query($body: String!) { viewer { login } }", + variables: { body: "secret user text" }, + }); + yield* api.rest({ + host: "github.com", + operation: "listWorkflowRuns", + path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx", + }); + const byName = (name: string) => spans.filter((span) => span.name === name); + const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes); + expect(graphql).toMatchObject({ + "github.operation": "getPullRequestDetail", + "github.graphql.query": "query($body: String!) { viewer { login } }", + "github.graphql.cost": 3, + "github.graphql.remaining": 4990, + }); + expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); + const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); + expect(rest).toMatchObject({ + "github.operation": "listWorkflowRuns", + "github.kind": "rest", + "url.path": "/repos/acme/web/actions/runs", + "http.response.status_code": 200, + "github.ratelimit.remaining": 4800, + }); + // Nothing recorded carries the query string, the variables, or the token. + const everything = spans + .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) + .join("\n"); + expect(everything).not.toContain("head_sha"); + expect(everything).not.toContain("secret user text"); + expect(everything).not.toContain("first"); + expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.withTracer(tracer)); + }, + ); + + it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { + const { layer } = harness(() => + json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }), + ); + expect(error._tag).toBe("GitHubApiResponseError"); + expect(error.message).toContain("Resource not accessible"); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "reads a NOT_FOUND answer with another error as a failure, not a missing resource", + () => { + const { layer } = harness(() => + json({ + data: null, + errors: [ + { type: "NOT_FOUND", message: "Could not resolve" }, + { message: "Something went wrong" }, + ], + }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ + host: "github.com", + operation: "detail", + query: "query { viewer { id } }", + }), + ); + expect(error._tag).toBe("GitHubApiResponseError"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { + const reset = Math.floor(NOW / 1000) + 600; + const { layer, requests } = harness(() => + json( + { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, + { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const first = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }), + ); + expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); + const second = yield* Effect.flip( + api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), + ); + expect(second).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("carries GitHub's own reason for a refused REST request", () => { + const { layer } = harness(() => + json( + { + message: "Validation Failed", + errors: [ + { + resource: "PullRequest", + code: "custom", + message: "A pull request already exists for acme:feature.", + }, + ], + }, + { status: 422 }, + ), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.rest({ + host: "github.com", + operation: "createPullRequest", + method: "POST", + path: "repos/acme/web/pulls", + }), + ); + expect(error.message).toBe( + "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.", + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets an interactive request through a pause a background read recorded", () => { + const reset = Math.floor(NOW / 1000) + 600; + let call = 0; + const { layer, requests } = harness(() => + ++call === 1 + ? json( + { message: "API rate limit exceeded" }, + { + status: 403, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }, + }, + ) + : json({ ok: true }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + yield* Effect.flip(api.rest(read)); + expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError"); + const merged = yield* api + .rest({ + host: "github.com", + operation: "merge", + method: "PUT", + path: "repos/acme/web/pulls/7/merge", + }) + .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + expect(merged.status).toBe(200); + expect(requests).toHaveLength(2); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reads an untyped GraphQL quota error as a rate limit", () => { + const reset = Math.floor(NOW / 1000) + 900; + const { layer } = harness(() => + json( + { errors: [{ message: "API rate limit already exceeded for user ID 1." }] }, + { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }), + ); + expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { + let call = 0; + const { layer } = harness(() => + ++call === 1 + ? new Response(null, { status: 304 }) + : json( + { message: "API rate limit exceeded" }, + { + status: 403, + headers: { "retry-after": "60" }, + }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const notModified = yield* api.rest({ + host: "github.com", + operation: "checks", + path: "repos/acme/web/pulls/7", + ifNoneMatch: '"abc"', + }); + expect(notModified.status).toBe(304); + const error = yield* Effect.flip( + api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }), + ); + expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("drops a refused token so the next request asks the source again", () => { + const { layer, requests, invalidations } = harness((request) => + request.headers.authorization === "Bearer first" + ? new Response(null, { status: 401 }) + : json({ ok: true }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const refused = yield* Effect.flip( + api.rest({ host: "github.com", operation: "user", path: "user" }), + ); + expect(refused._tag).toBe("GitHubApiAuthenticationError"); + expect(invalidations()).toBe(1); + const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" }); + expect(answered.status).toBe(200); + expect(requests.map((request) => request.headers.authorization)).toEqual([ + "Bearer first", + "Bearer second", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses to send a pinned credential to another host", () => { + const { layer, requests } = harness(() => json({})); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.rest({ host: "git.acme.internal", operation: "user", path: "user" }), + ).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("pinned"), + credentialFingerprint: "github.com:pinned", + }), + ); + expect(error._tag).toBe("GitHubApiAuthenticationError"); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubCredentials", () => { + const credentialsWith = (run: VcsProcess.VcsProcess["Service"]["run"]) => + GitHubCredentials.layer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), + Layer.provide(NodeServices.layer), + ); + + it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => + Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("git.acme.internal")); + expect(error._tag).toBe("GitHubCliMissingError"); + }).pipe( + Effect.provide( + credentialsWith(() => + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitHubCredentials.get", + command: "gh", + cwd: "/", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + ), + ), + ), + ); + + it.effect("does not cache a gh failure that is not about signing in", () => { + let calls = 0; + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const first = yield* Effect.flip(credentials.get("github.com")); + expect(first._tag).toBe("GitHubCliFailedError"); + expect((yield* credentials.get("github.com")).source).toBe("gh"); + expect(calls).toBe(2); + }).pipe( + Effect.provide( + credentialsWith(() => + ++calls === 1 + ? Effect.fail( + new VcsProcessTimeoutError({ + operation: "GitHubCredentials.get", + command: "gh", + cwd: "/", + timeoutMs: 10_000, + }), + ) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "token\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ), + ); + }); + + it.effect("fails with GitHubNotSignedInError when gh prints no token", () => + Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("git.acme.internal")); + expect(error._tag).toBe("GitHubNotSignedInError"); + }).pipe( + Effect.provide( + credentialsWith(() => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts new file mode 100644 index 000000000000..7b1a9f316a9c --- /dev/null +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -0,0 +1,593 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +const DEFAULT_TIMEOUT = Duration.seconds(30); +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ +const CONCURRENCY = 8; +const API_VERSION = "2022-11-28"; + +/** + * A credential already verified for one host. Every request made under it must target that + * host, so a page cannot read one account's data with another's token mid-flight. Server-local: + * never put its value in RPC payloads or cache keys. + */ +export const PinnedGitHubCredential = Context.Reference<{ + readonly host: string; + readonly token: Redacted.Redacted; + readonly credentialFingerprint: string; +} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); + +/** + * Set by interactive callers (a user's read or write, not a background sweep). Requests made + * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a + * pull request should not be refused because a background read exhausted the quota. + */ +export const AllowGitHubReserve = Context.Reference( + "t3/sourceControl/AllowGitHubReserve", + { defaultValue: () => false }, +); + +export class GitHubApiRequestError extends Schema.TaggedError()( + "GitHubApiRequestError", + { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not reach GitHub at ${this.host}.`; + } +} + +export class GitHubApiAuthenticationError extends Schema.TaggedError()( + "GitHubApiAuthenticationError", + { host: Schema.String, operation: Schema.String }, +) { + override get message(): string { + return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`; + } +} + +export class GitHubApiRateLimitError extends Schema.TaggedError()( + "GitHubApiRateLimitError", + { + host: Schema.String, + operation: Schema.String, + retryAt: Schema.optionalKey(Schema.Finite), + }, +) { + override get message(): string { + return "GitHub API rate limit exceeded."; + } +} + +export class GitHubApiNotFoundError extends Schema.TaggedError()( + "GitHubApiNotFoundError", + { host: Schema.String, operation: Schema.String }, +) { + override get message(): string { + return "GitHub could not find the requested resource, or the credential cannot see it."; + } +} + +/** + * GitHub answered with a failure that is none of the above. `githubErrors` carries GitHub's own + * error messages, from a GraphQL `errors` list or a REST `message`/`errors` body: they name the + * field and the reason ("A pull request already exists for acme:feature"), never a token. + */ +export class GitHubApiResponseError extends Schema.TaggedError()( + "GitHubApiResponseError", + { + host: Schema.String, + operation: Schema.String, + status: Schema.Int, + githubErrors: Schema.optionalKey(Schema.Array(Schema.String)), + }, +) { + override get message(): string { + return this.githubErrors !== undefined && this.githubErrors.length > 0 + ? `GitHub returned an error: ${this.githubErrors.join("; ")}` + : `GitHub returned HTTP ${this.status}.`; + } +} + +export type GitHubApiError = + | GitHubCredentials.GitHubCredentialUnavailableError + | GitHubApiRequestError + | GitHubApiAuthenticationError + | GitHubApiRateLimitError + | GitHubApiNotFoundError + | GitHubApiResponseError + | SourceControlRateLimit.SourceControlRateLimitPausedError; + +export interface GitHubRestResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly truncated: boolean; +} + +export interface GitHubRestInput { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ + readonly path: string; + readonly body?: unknown; + /** Defaults to `application/vnd.github+json`. */ + readonly accept?: string; + /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ + readonly ifNoneMatch?: string; + readonly maxResponseBytes?: number; + /** Overrides `AllowGitHubReserve` for this one request. */ + readonly allowReserve?: boolean; +} + +export interface GitHubGraphQlInput { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables?: Readonly>; + readonly allowReserve?: boolean; +} + +export class GitHubApi extends Context.Service< + GitHubApi, + { + /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */ + readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect; + readonly rest: (input: GitHubRestInput) => Effect.Effect; + /** The credential a request to `host` would carry right now. */ + readonly credential: ( + host: string, + ) => Effect.Effect< + { readonly token: Redacted.Redacted; readonly fingerprint: string }, + GitHubApiError + >; + } +>()("t3/sourceControl/GitHubApi") {} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} + +/** + * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` + * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. + */ +export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { + const normalized = normalizeHost(host); + if (normalized === "github.com") { + return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" }; + } + if (normalized.endsWith(".ghe.com")) { + return { + rest: `https://api.${normalized}`, + graphql: `https://api.${normalized}/graphql`, + }; + } + return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; +} + +/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ +const TRACED_QUERY_MAX_CHARS = 4_000; + +/** A stable short id for a GraphQL document, so traces group by query without its text. */ +function queryHash(query: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < query.length; index++) { + hash ^= query.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** The numeric rate-limit headers GitHub sends, as span attributes. */ +function rateLimitAttributes( + headers: Readonly>, +): Record { + const attributes: Record = {}; + for (const [header, name] of [ + ["x-ratelimit-limit", "github.ratelimit.limit"], + ["x-ratelimit-remaining", "github.ratelimit.remaining"], + ["x-ratelimit-used", "github.ratelimit.used"], + ["x-ratelimit-reset", "github.ratelimit.reset"], + ] as const) { + const value = Number(headers[header]); + if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; + } + const resource = headers["x-ratelimit-resource"]; + if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; + return attributes; +} + +const decodeGraphQlCost = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }), + }), + }), + ), +); + +/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ +function retryAtFrom( + headers: Readonly>, + now: number, +): number | undefined { + const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); + if (fromRetryAfter !== undefined) return fromRetryAfter; + const reset = Number(headers["x-ratelimit-reset"]) * 1_000; + return Number.isFinite(reset) && reset > now ? reset : undefined; +} + +const decodeGraphQlErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + errors: Schema.NonEmptyArray( + Schema.Struct({ + type: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ +const decodeRestErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + message: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array( + Schema.Union([ + Schema.String, + Schema.Struct({ + message: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + }), + ]), + ), + ), + }), + ), +); + +function restErrorMessages(body: string): ReadonlyArray | undefined { + return Option.match(decodeRestErrors(body), { + onNone: () => undefined, + onSome: (decoded) => { + const details = (decoded.errors ?? []).flatMap((error) => + typeof error === "string" + ? [error] + : error.message !== undefined + ? [error.message] + : error.field !== undefined && error.code !== undefined + ? [`${error.field} ${error.code}`] + : [], + ); + const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; + return messages.length > 0 ? messages : undefined; + }, + }); +} + +/** What one GitHub answer means, decided once from its status, headers and body. */ +type Answer = Data.TaggedEnum<{ + Ok: {}; + RateLimited: {}; + Unauthorized: {}; + NotFound: {}; + Failed: { readonly messages: ReadonlyArray | undefined }; +}>; +const Answer = Data.taggedEnum(); + +/** + * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body + * is read for its error types as well as its status. `gh api graphql` failed those too, and + * callers rely on that to fall back to narrower reads. + */ +function classify(input: { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly graphql: boolean; + readonly acceptNotModified: boolean; +}): Answer { + const { status, headers, body } = input; + const errors = input.graphql + ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors + : undefined; + const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; + const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); + if ( + status === 429 || + types.includes("RATE_LIMITED") || + // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already + // exceeded" error; the headers say the same thing. + (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || + (input.graphql && + messages !== undefined && + messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || + headers["retry-after"] !== undefined || + /rate limit/i.test(body))) + ) { + return Answer.RateLimited(); + } + if (status === 401) return Answer.Unauthorized(); + if ( + status === 404 || + (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) + ) { + return Answer.NotFound(); + } + if (errors !== undefined) return Answer.Failed({ messages }); + if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { + return Answer.Ok(); + } + return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitHubCredentials.GitHubCredentials; + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const gate = yield* Semaphore.make(CONCURRENCY); + + const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( + function* (host) { + const normalized = normalizeHost(host); + const pinned = yield* PinnedGitHubCredential; + if (pinned !== null) { + // A pinned page only ever talks to the host it verified. + if (pinned.host !== normalized) { + return yield* new GitHubApiAuthenticationError({ + host: normalized, + operation: "credential", + }); + } + return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; + } + const held = yield* credentials.get(normalized); + return { token: held.token, fingerprint: held.fingerprint }; + }, + ); + + /** + * Sends one request under the host's rate-limit pause and fails a refusal with the error that + * says why. `onSuccess` sees the answer only once it is known to be one. + */ + const send = Effect.fn("GitHubApi.send")(function* (input: { + readonly host: string; + readonly operation: string; + readonly request: HttpClientRequest.HttpClientRequest; + readonly maxResponseBytes: number; + readonly allowReserve: boolean; + readonly acceptNotModified: boolean; + /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ + readonly graphql?: boolean; + readonly timeout?: Duration.Duration | undefined; + }) { + const host = normalizeHost(input.host); + // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. + yield* Effect.annotateCurrentSpan({ + "github.host": host, + "github.operation": input.operation, + "github.kind": input.graphql === true ? "graphql" : "rest", + "http.request.method": input.request.method, + "url.path": new URL(input.request.url).pathname, + }); + const { token, fingerprint } = yield* credential(host); + const scope = yield* SourceControlRateLimit.CredentialScope; + const key = { provider: "github" as const, host }; + const run = Effect.gen(function* () { + const lease = yield* limits + .check(key, input.allowReserve ? { allowPaused: true } : undefined) + .pipe( + Effect.tapError((paused) => + Effect.annotateCurrentSpan({ + "github.paused": true, + "github.retry_at": paused.retryAt, + }), + ), + ); + // One deadline covers the headers and the body: a host that answers headers and then stalls + // must not hold a slot of the shared gate for undici's own five-minute body timeout. + const { response, collected } = yield* Effect.gen(function* () { + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + input.request.pipe( + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeaders({ + "x-github-api-version": API_VERSION, + "user-agent": "t3code", + }), + ), + ) + .pipe( + // `GitHubApi.send` is the request's span. The client's own span would add `url.full` + // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + ); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: input.maxResponseBytes, + }).pipe( + // 204, 304 and many refusals carry no body at all, which is an empty answer. + Effect.catchIf( + (error) => error.reason._tag === "EmptyBodyError", + () => Effect.succeed({ text: "", truncated: false }), + ), + ); + return { response, collected }; + }).pipe( + Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), + Effect.mapError( + (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), + ), + ); + const headers = response.headers; + const status = response.status; + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": status, + ...rateLimitAttributes(headers), + }); + const context = { host, operation: input.operation }; + return yield* Answer.$match( + classify({ + status, + headers, + body: collected.text, + graphql: input.graphql === true, + acceptNotModified: input.acceptNotModified, + }), + { + Ok: () => + limits.recordSuccess({ ...key, lease }).pipe( + Effect.as({ + status, + headers, + body: collected.text, + truncated: collected.truncated, + }), + ), + RateLimited: () => + Effect.gen(function* () { + const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); + yield* limits.recordRateLimit({ ...key, lease, retryAt }); + yield* Effect.annotateCurrentSpan({ + "github.rate_limited": true, + ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), + }); + return yield* new GitHubApiRateLimitError({ + ...context, + ...(retryAt === undefined ? {} : { retryAt }), + }); + }), + // The source may hold a newer token than the one that was refused. + Unauthorized: () => + credentials + .invalidate(host) + .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), + NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), + Failed: ({ messages }) => + Effect.fail( + new GitHubApiResponseError({ + ...context, + status, + ...(messages === undefined ? {} : { githubErrors: messages }), + }), + ), + }, + ); + }); + // The rate-limit scope follows the credential, so a pause on one account never blocks another. + return yield* gate + .withPermit(run) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + }); + + const rest: GitHubApi["Service"]["rest"] = (input) => { + const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; + const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( + HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), + ); + const withEtag = + input.ifNoneMatch === undefined + ? base + : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); + const request = + input.body === undefined + ? withEtag + : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); + return AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + send({ + host: input.host, + operation: input.operation, + request, + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + allowReserve: input.allowReserve ?? interactive, + acceptNotModified: input.ifNoneMatch !== undefined, + }), + ), + ); + }; + + const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( + function* (input) { + const host = normalizeHost(input.host); + const { fingerprint } = yield* credential(host); + const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; + const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); + // The document, never its variables: user text (bodies, search terms) travels as variables. + yield* Effect.annotateCurrentSpan({ + "github.operation": input.operation, + "github.graphql.query_hash": queryHash(input.query), + "github.graphql.query": + input.query.length > TRACED_QUERY_MAX_CHARS + ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` + : input.query, + }); + return yield* Effect.gen(function* () { + const query = yield* budget.query( + host, + input.query, + allowReserve ? { allowReserve: true } : undefined, + ); + const response = yield* send({ + host, + operation: input.operation, + request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), + ), + maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, + allowReserve, + acceptNotModified: false, + graphql: true, + }); + // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. + if (response.truncated) { + return yield* new GitHubApiResponseError({ + host, + operation: input.operation, + status: response.status, + }); + } + yield* budget.observe(host, response.body); + const cost = Option.getOrUndefined(decodeGraphQlCost(response.body)); + if (cost !== undefined) { + yield* Effect.annotateCurrentSpan({ + "github.graphql.cost": cost.data.rateLimit.cost, + "github.graphql.remaining": cost.data.rateLimit.remaining, + }); + } + return response.body; + }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); + }, + ); + + return GitHubApi.of({ graphql, rest, credential }); +}); + +export const layer = Layer.effect(GitHubApi, make); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 3a13ac69e9ef..0097794c7b9d 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -23,6 +23,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { @@ -35,11 +36,7 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ -export const PinnedGitHubCredential = Context.Reference<{ - readonly host: string; - readonly token: Redacted.Redacted; - readonly credentialFingerprint: string; -} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); +export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; export const AllowGitHubReserve = Context.Reference( "t3/sourceControl/AllowGitHubReserve", diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts new file mode 100644 index 000000000000..7207373bcf31 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -0,0 +1,189 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export const GitHubCredentialSource = Schema.Literals(["env", "gh"]); +export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; + +export interface GitHubCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: GitHubCredentialSource; + /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ + readonly fingerprint: string; +} + +/** Nothing in the environment, and no `gh` on PATH to ask. */ +export class GitHubCliMissingError extends Schema.TaggedError()( + "GitHubCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; + } +} + +/** `gh` is installed but holds no login for the host. */ +export class GitHubNotSignedInError extends Schema.TaggedError()( + "GitHubNotSignedInError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`; + } +} + +/** `gh auth token` timed out or failed for a reason other than having no login. */ +export class GitHubCliFailedError extends Schema.TaggedError()( + "GitHubCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; + } +} + +/** There is no token for the host. */ +export type GitHubCredentialUnavailableError = + | GitHubCliMissingError + | GitHubNotSignedInError + | GitHubCliFailedError; + +/** + * Where GitHub tokens come from. Callers ask per host and never see how the token was found, + * so another source (an in-app OAuth login) slots in here without touching any of them. + */ +export class GitHubCredentials extends Context.Service< + GitHubCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitHub refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("t3/sourceControl/GitHubCredentials") {} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} + +/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ +function isGitHubDotCom(host: string): boolean { + return host === "github.com" || host.endsWith(".ghe.com"); +} + +/** + * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to + * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL + * picks the host and a hostile one must not receive an enterprise token. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const names = isGitHubDotCom(host) + ? ["GH_TOKEN", "GITHUB_TOKEN"] + : env.GH_HOST?.trim().toLowerCase() === host + ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] + : []; + for (const name of names) { + const value = env[name]?.trim(); + if (value) return value; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const crypto = yield* Crypto.Crypto; + const environment = yield* HostProcessEnvironment; + const workingDirectory = yield* HostProcessWorkingDirectory; + + /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ + const fingerprintOf = (host: string, token: string) => + crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( + Effect.map((digest) => `${host}:${Hex.encode(digest)}`), + // Hashing a string in memory has no platform failure worth a typed error. + Effect.orDie, + ); + + const fromGh = (host: string) => + process + .run({ + operation: "GitHubCredentials.get", + command: "gh", + args: ["auth", "token", "--hostname", host], + cwd: workingDirectory, + // Never let gh print the token into a debug log. + env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitHubCliMissingError({ host }) + : // gh exits non-zero with "no oauth token" when it has no login for the host. + error._tag === "VcsProcessExitError" + ? new GitHubNotSignedInError({ host }) + : new GitHubCliFailedError({ host, cause: error }), + ), + Effect.map((output) => output.stdout.trim()), + Effect.filterOrFail( + (token) => token !== "", + () => new GitHubNotSignedInError({ host }), + ), + ); + + const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, environment); + const token = fromEnv ?? (yield* fromGh(host)); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "gh", + fingerprint: yield* fingerprintOf(host, token), + } satisfies GitHubCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 32, + // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitHubCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitHubCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitHubCredentials, make); From adc3c9327abedda6a36694e2344e620fe0d62ae4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:14:13 -0700 Subject: [PATCH 05/59] feat(server): pull requests talk to GitHub's API instead of the gh CLI (#16320) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/measure-pr-preview.ts | 117 +- .../pullRequest/GitHubPullRequestCli.test.ts | 2041 +++++++++-------- .../src/pullRequest/GitHubPullRequestCli.ts | 1750 +++++++------- .../GitHubPullRequestProvider.test.ts | 13 +- .../pullRequest/GitHubPullRequestProvider.ts | 34 +- .../PullRequestProviderRateLimit.test.ts | 4 +- .../PullRequestProviderRegistry.ts | 14 +- .../gitHubConditionalChecks.test.ts | 118 +- .../pullRequest/gitHubConditionalChecks.ts | 149 +- .../pullRequest/gitHubPullRequestJson.test.ts | 259 ++- .../src/pullRequest/gitHubPullRequestJson.ts | 659 ++++-- .../pullRequest/githubStackActions.test.ts | 110 +- .../src/pullRequest/githubStackActions.ts | 177 +- apps/server/src/sourceControl/GitHubApi.ts | 10 +- apps/server/src/sourceControl/GitHubCli.ts | 5 +- 15 files changed, 2867 insertions(+), 2593 deletions(-) diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index f967d1f26a8e..ae291cd17cc6 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -1,18 +1,21 @@ // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. -// CLI-generated GraphQL -// queries are replayed with rateLimit.cost, outside the timed section, to measure -// their cost without confusing other applications' traffic with this process's. +// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { FetchHttpClient } from "effect/http"; import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; -import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; +import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; const [repository, ...numbers] = process.argv.slice(2); @@ -20,77 +23,58 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number..."); } -type Read = { graphqlRequests: number; restRequests: number; cost: number; debug: string }; +type Read = { graphqlRequests: number; restRequests: number; cost: number }; const reads: Read[] = []; -const layerMeasuredProcess = Layer.effect( - VcsProcess.VcsProcess, +const decodeCost = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ rateLimit: Schema.Struct({ cost: Schema.Number }) }), + }), + ), +); +const measuredApi = Layer.effect( + GitHubApi.GitHubApi, Effect.gen(function* () { - const vcs = yield* VcsProcess.VcsProcess; - return VcsProcess.VcsProcess.of({ - run: (input) => - vcs.run({ ...input, env: { ...input.env, GH_DEBUG: input.env?.GH_DEBUG ?? "api" } }).pipe( - Effect.tap((output) => - Effect.sync(() => { - const graphqlRequests = [...output.stderr.matchAll(/^> POST \/graphql /gm)].length; - const requests = [...output.stderr.matchAll(/^> (?:GET|POST) /gm)].length; - const cost = /"rateLimit"\s*:\s*\{[^}]*"cost"\s*:\s*(\d+)/.exec(output.stdout)?.[1]; + const api = yield* GitHubApi.make; + return GitHubApi.GitHubApi.of({ + ...api, + graphql: (input) => + api.graphql(input).pipe( + Effect.tap((body) => + Effect.sync(() => reads.push({ - graphqlRequests, - restRequests: requests - graphqlRequests, - cost: Number(cost ?? 0), - debug: output.stderr, - }); - }), + graphqlRequests: 1, + restRequests: 0, + cost: Option.match(decodeCost(body), { + onNone: () => 0, + onSome: (decoded) => decoded.data.rateLimit.cost, + }), + }), + ), ), ), + rest: (input) => + api + .rest(input) + .pipe( + Effect.tap(() => + Effect.sync(() => reads.push({ graphqlRequests: 0, restRequests: 1, cost: 0 })), + ), + ), }); }), -).pipe(Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer)); - -const layerServices = GitHubPullRequestCli.layer.pipe( - Layer.provide(GitHubCli.layer), - Layer.provideMerge(layerMeasuredProcess), +).pipe( + Layer.provide(GitHubCredentials.layer), + Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(VcsProcess.layer), Layer.provide(NodeServices.layer), ); -const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); +const services = GitHubPullRequestCli.layer.pipe(Layer.provideMerge(measuredApi)); + const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const graphqlCost = Effect.fn("measurePrPreview.graphqlCost")(function* (read: Read) { - // The REST quota probe synthesizes rateLimit.cost for budget admission. - // It does not spend GraphQL points. - if (!read.graphqlRequests) return 0; - if (read.cost) return read.cost; - const vcs = yield* VcsProcess.VcsProcess; - let cost = 0; - let found = 0; - for (const match of read.debug.matchAll( - /GraphQL query:\n([\s\S]*?)\nGraphQL variables: (\{[^\n]*\})/g, - )) { - const query = match[1]!.replace(/(\bquery\b[^{]*\{)/, "$1 rateLimit { cost }"); - const output = yield* vcs.run({ - operation: "measurePrPreview.cost", - command: "gh", - cwd: process.cwd(), - args: [ - "api", - "graphql", - "--hostname", - "github.com", - "--input", - "-", - "--jq", - ".data.rateLimit.cost", - ], - stdin: encodeJson({ query, variables: decodeJson(match[2]!) }), - env: { GH_DEBUG: "" }, - }); - cost += Number(output.stdout.trim()); - found++; - } - if (found !== read.graphqlRequests) - throw new Error("Could not account for every GraphQL request"); - return cost; -}); for (const mode of ["detail", "preview"] as const) { // Start each side cold, then retain the caches shared across PRs in one session. @@ -108,8 +92,7 @@ for (const mode of ["detail", "preview"] as const) { : provider.getChangeRequestPreview!(input); const elapsedMs = Math.round(performance.now() - start); const measured = [...reads]; - const costs = yield* Effect.forEach(measured, graphqlCost); - const points = costs.reduce((total, cost) => total + cost, 0); + const points = measured.reduce((total, read) => total + read.cost, 0); return { repository, number, @@ -123,7 +106,7 @@ for (const mode of ["detail", "preview"] as const) { }), ); }), - ).pipe(Effect.provide(layerServices)), + ).pipe(Effect.provide(services)), ); for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row))); } diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 44730cf13350..33f0df8e8bcb 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -7,13 +7,17 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; +import * as Redacted from "effect/Redacted"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -48,60 +52,141 @@ const coreResponse = (pullRequest: Readonly> = {}) => ({ }, }); -const mockedExecute = vi.fn(); -const mockedStackMemberships = vi.fn(() => +/** One request the CLI made, as the transport received it. */ +type ApiCall = + | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) + | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); + +type ApiAnswer = Effect.Effect; + +const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); +const defaultCredential = (host: string) => + Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); +const mockedCredential = vi.fn(defaultCredential); +const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => Effect.succeed(output('{"data":{}}')), ); -const mockedGetPullRequest = vi.fn(); + +/** + * A transport that answers from the mocks above but spends the real GraphQL budget, the way + * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. + */ +const mockApi = Layer.effect( + GitHubApi.GitHubApi, + Effect.gen(function* () { + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + return GitHubApi.GitHubApi.of({ + graphql: (input) => + budget + .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) + .pipe( + Effect.flatMap((query) => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input, query }), + ), + Effect.map((answer) => answer.body), + Effect.tap((body) => budget.observe(input.host, body)), + ), + rest: (input) => mockedExecute({ kind: "rest", ...input }), + credential: (host) => mockedCredential(host), + }); + }), +); const layer = it.layer( GitHubPullRequestCli.layer.pipe( - Layer.provide( - Layer.mock(GitHubCli.GitHubCli)({ - execute: (input) => - input.args.some((arg) => arg.includes("query PullRequestStackMemberships")) - ? mockedStackMemberships(input) - : mockedExecute(input), - getPullRequest: mockedGetPullRequest, - }), - ), + Layer.provideMerge(mockApi), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provide(NodeCrypto.layer), ), ); -function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated, - stderrTruncated: false, - stdoutInvalidUtf8, - }; +/** A successful answer; GraphQL reads take its body. */ +function output( + body: string, + truncated = false, + invalidUtf8 = false, +): GitHubApi.GitHubRestResponse { + return { status: 200, headers: {}, body, truncated, invalidUtf8 }; } -function pullRequests( +/** + * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most + * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. + */ +function rows( count: number, firstNumber: number, overrides: (number: number) => Readonly> = () => ({}), -): string { - return JSON.stringify( - Array.from({ length: count }, (_, index) => ({ - number: firstNumber + index, - title: `Pull request ${firstNumber + index}`, - url: `https://github.com/acme/web/pull/${firstNumber + index}`, +): ReadonlyArray> { + return Array.from({ length: count }, (_, index) => { + const number = firstNumber + index; + const { reviewRequests, checks, ...rest } = overrides(number) as { + reviewRequests?: ReadonlyArray>; + checks?: string; + } & Record; + return { + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, headRefName: "feat/page", baseRefName: "main", createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-02T00:00:00Z", - ...overrides(firstNumber + index), - })), - ); + repository: { nameWithOwner: "acme/web" }, + ...(reviewRequests === undefined + ? {} + : { + reviewRequests: { + nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), + }, + }), + ...(checks === undefined + ? {} + : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), + ...rest, + }; + }); +} + +/** A page of the searched listing. */ +function pullRequests( + count: number, + firstNumber: number, + overrides?: (number: number) => Readonly>, +): string { + return encodeJson({ + data: { + search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, + }, + }); +} + +/** A page of a repository's own list, which is what the search-free fallback reads. */ +function listedPullRequests( + count: number, + firstNumber: number, + overrides?: (number: number) => Readonly>, +): string { + return encodeJson({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false }, + nodes: rows(count, firstNumber, overrides), + }, + }, + }, + }); } +/** A search that found nothing, which is also what GitHub says for a repository it does not index. */ +const emptySearch = () => output(pullRequests(0, 1)); +const emptyList = () => output(listedPullRequests(0, 1)); + function pullRequestFiles(count: number, firstIndex: number): string { - return JSON.stringify( + return encodeJson( Array.from({ length: count }, (_, index) => ({ filename: `src/file${firstIndex + index}.ts`, status: "modified", @@ -139,7 +224,7 @@ function reviewThreadsPage( nodes: ReadonlyArray>, endCursor: string | null, ): string { - return JSON.stringify({ + return encodeJson({ data: { repository: { pullRequest: { @@ -160,7 +245,7 @@ function threadCommentsPage( totalCount: number, pullRequestId = "PR_7", ): string { - return JSON.stringify({ + return encodeJson({ data: { repository: { pullRequest: { id: "PR_7" } }, node: { @@ -171,26 +256,43 @@ function threadCommentsPage( }); } -/** What `gh pr diff` answers on a pull request GitHub will not serve a diff for. */ -const diffRefused = new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/w", - cause: new Error("HTTP 406: the diff exceeded the maximum number of files (300)"), +/** What GitHub answers for a pull request it will not serve a whole diff for. */ +const diffRefused = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "getPullRequestDiff", + status: 406, }); -/** The whole invocation the nth call made, so both argv and stdin can be asserted. */ -function callAt(index: number) { +/** The nth request the CLI made. */ +function callAt(index: number): ApiCall { const call = mockedExecute.mock.calls[index]; assert.isDefined(call); return call[0]; } -/** The one argument `--search` carries, which is where every listing filter ends up. */ +/** The GraphQL variables of the nth request. */ +function varsAt(index: number): Readonly> { + const call = callAt(index); + assert.strictEqual(call.kind, "graphql"); + return call.kind === "graphql" ? (call.variables ?? {}) : {}; +} + +/** The GraphQL document of the nth request. */ +function queryAt(index: number): string { + const call = callAt(index); + return call.kind === "graphql" ? call.query : ""; +} + +/** The REST path of the nth request. */ +function pathAt(index: number): string { + const call = callAt(index); + return call.kind === "rest" ? call.path : ""; +} + +/** The search the nth request carried, or undefined for a read that did not search. */ function searchOfCall(index: number): string | undefined { - const args = callAt(index).args; - const flag = args.indexOf("--search"); - // Absent is its own answer: a read that carries no `--search` at all is what the fallback is. - return flag === -1 ? undefined : args[flag + 1]; + const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; + return typeof q === "string" ? q : undefined; } /** One row as a search answers it, which is the listing's row one connection deeper. */ @@ -214,19 +316,143 @@ function searchItem(number: number, repository: string, updatedAt: string) { } function searchPage(nodes: ReadonlyArray, hasNextPage = false) { - return output(JSON.stringify({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); + return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); } -/** The search a batched read sent, which travels in the request body rather than in argv. */ -function searchQueryOfCall(index: number): string | undefined { - const body = JSON.parse(callAt(index).stdin ?? "{}") as { variables?: { q?: string } }; - return body.variables?.q; +const searchQueryOfCall = searchOfCall; + +/** + * Answers every request with the body of the first route whose needle its GraphQL document or + * REST path contains, and anything unrouted with an empty object, which a mutation never reads. + */ +function route(...routes: ReadonlyArray): void { + mockedExecute.mockImplementation((call) => { + const target = call.kind === "graphql" ? call.query : call.path; + const match = routes.find(([needle]) => target.includes(needle)); + return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); + }); +} + +/** The variables of every GraphQL request whose document contains `needle`, in order. */ +function variablesOf(needle: string): ReadonlyArray>> { + return mockedExecute.mock.calls.flatMap(([call]) => + call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], + ); +} + +/** Every REST request whose path contains `needle`, in order. */ +function restCallsTo(needle: string): ReadonlyArray { + return mockedExecute.mock.calls.flatMap(([call]) => + call.kind === "rest" && call.path.includes(needle) ? [call] : [], + ); } +/** + * The check that a client-given node hangs off the pull request it names. Routed before the node + * id lookup, whose document it contains. + */ +const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; +const subjectScope = (subjectId: string, pullRequestId: string) => ({ + data: { + repository: { pullRequest: { id: pullRequestId } }, + node: { id: subjectId, pullRequest: { id: pullRequestId } }, + }, +}); + +/** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ +const checkContextsPage = ( + nodes: ReadonlyArray>, + endCursor: string | null, + headRefOid = "abc123", +) => + encodeJson({ + data: { + repository: { + pullRequest: { + headRefOid, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, + }, + }, + }, + ], + }, + }, + }, + }, + }); + +/** A pull request's base and head, as the REST read a file expansion starts from answers them. */ +const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); + +/** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ +const crossRepositoryDetail = (headRefOid = "abc123") => + coreResponse({ + headRefName: "feat/page", + headRefOid, + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + }); + +/** Open pull requests on one head branch, every one of them from the same fork head. */ +const heads = (numbers: ReadonlyArray) => ({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: numbers.map((number) => ({ + number, + headRefOid: "abc123", + isCrossRepository: true, + headRepositoryOwner: { login: "octocat" }, + })), + }, + }, + }, +}); + +const workflowRuns = (ids: ReadonlyArray) => ({ + workflow_runs: ids.map((id) => ({ + id, + name: id === 10 ? "build" : `run ${id}`, + html_url: `https://example.com/${id}`, + })), +}); + +/** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ +function workflowApprovalRoutes( + detail: () => unknown, + headsAnswer: unknown, + runsAnswer: unknown, +): void { + mockedExecute.mockImplementation((call) => + Effect.sync(() => + output( + call.kind === "rest" + ? call.path.endsWith("/approve") + ? "" + : encodeJson(runsAnswer) + : call.query.includes("headRefName: $head") + ? encodeJson(headsAnswer) + : encodeJson(detail()), + ), + ), + ); +} + +/** The pull request node id lookup, which the layer caches for every test after the first. */ +const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; +const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); + afterEach(() => { mockedExecute.mockReset(); mockedStackMemberships.mockReset(); - mockedGetPullRequest.mockReset(); + mockedCredential.mockReset(); + mockedCredential.mockImplementation(defaultCredential); }); it.effect( @@ -234,22 +460,41 @@ it.effect( () => Effect.gen(function* () { let activeToken = "broad-credential"; - const commands: VcsProcess.VcsProcessInput[] = []; - const github = yield* GitHubCli.make.pipe( - Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer)), - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - Effect.sync(() => { - commands.push(input); - if (input.args[0] === "auth") return output(activeToken); - if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}'); - return output(""); - }), + const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = + []; + const credentials = Layer.succeed( + GitHubCredentials.GitHubCredentials, + GitHubCredentials.GitHubCredentials.of({ + get: (host) => + Effect.sync(() => ({ + host, + token: Redacted.make(activeToken), + source: "gh" as const, + fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, + })), + invalidate: () => Effect.void, }), ); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ url: request.url, authorization: request.headers["authorization"] }); + const body = request.url.endsWith("/user") + ? { id: 123, login: "same-account" } + : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; + return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); + }), + ), + ); const cli = yield* GitHubPullRequestCli.make.pipe( - Effect.provideService(GitHubCli.GitHubCli, github), - Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, NodeCrypto.layer)), + Effect.provide( + GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(SourceControlRateLimit.layer), + ), + ), ); const input = { cwd: "/repo", host: "github.com" }; const first = yield* cli.withVerifiedCredential(input, (identity) => @@ -270,16 +515,17 @@ it.effect( expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); expect(encodeJson([first, second])).not.toContain("broad-credential"); expect(encodeJson([first, second])).not.toContain("restricted-credential"); - expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({ - GH_TOKEN: "broad-credential", - GITHUB_TOKEN: "broad-credential", - GH_DEBUG: "", - }); + // The comment was written under the credential the page verified, not the switched one. + expect( + requests + .filter((request) => request.url.endsWith("/graphql")) + .map((request) => request.authorization), + ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); expect( - commands - .filter((command) => command.args[0] === "api") - .map((command) => command.env?.GH_TOKEN), - ).toEqual(["broad-credential", "restricted-credential"]); + requests + .filter((request) => request.url.endsWith("/user")) + .map((request) => request.authorization), + ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); expect(yield* cli.getRoutingIdentity(input)).toEqual({ accountId: "123", viewer: "same-account", @@ -394,18 +640,14 @@ layer("GitHubPullRequestCli.layer", (it) => { }, }); expect(mockedExecute).toHaveBeenCalledTimes(1); - expect(callAt(0).args).toEqual( - expect.arrayContaining(["api", "graphql", "--hostname", "github.example"]), - ); + expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); }), ); it.effect("coalesces concurrent identity verification for the same host and credential", () => Effect.gen(function* () { - mockedExecute.mockImplementation((input) => - input.args[0] === "auth" - ? Effect.succeed(output("shared-credential")) - : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), + mockedExecute.mockImplementation(() => + Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const results = yield* Effect.all( @@ -417,7 +659,8 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(results).toEqual( Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), ); - expect(mockedExecute.mock.calls.filter(([input]) => input.args[0] === "api")).toHaveLength(1); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(pathAt(0)).toBe("user"); }), ); @@ -426,15 +669,9 @@ layer("GitHubPullRequestCli.layer", (it) => { () => Effect.gen(function* () { const firstStarted = yield* Deferred.make(); - const secondStarted = yield* Deferred.make(); - let tokens = 0; let verifications = 0; - mockedExecute.mockImplementation((input) => + mockedExecute.mockImplementation(() => Effect.gen(function* () { - if (input.args[0] === "auth") { - if (++tokens === 2) yield* Deferred.succeed(secondStarted, undefined); - return output("cancel-credential"); - } if (++verifications === 1) { yield* Deferred.succeed(firstStarted, undefined); return yield* Effect.never; @@ -446,8 +683,9 @@ layer("GitHubPullRequestCli.layer", (it) => { const input = { cwd: "/w", host: "github.identity-cancel.test" }; const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); yield* Deferred.await(firstStarted); + // The second reader is queued on the same credential's lock behind the first. const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); - yield* Deferred.await(secondStarted); + yield* Effect.yieldNow; yield* Fiber.interrupt(first); expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); expect(verifications).toBe(2); @@ -479,8 +717,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), @@ -526,7 +763,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ); assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - const document = callAt(0).args.at(-1) ?? ""; + const document = queryAt(0); expect(document).toContain( 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', ); @@ -575,7 +812,8 @@ layer("GitHubPullRequestCli.layer", (it) => { // GitHub had no answer for #8, so its watch reads it in full. expect(eight).toBeNull(); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(callAt(0).args.at(-1) ?? "").toContain( + const call = callAt(0); + expect(call.kind === "graphql" ? call.query : "").toContain( 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', ); }), @@ -588,34 +826,28 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { - __typename: "CheckRun", - status: "COMPLETED", - conclusion: "SUCCESS", - name: "ci", + encodeJson({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, }, - ], - body: "", + }, }), ), ), @@ -631,15 +863,7 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.strictEqual(summary.headBranch, "feat/summary"); assert.strictEqual(summary.checksState, "passing"); assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(callAt(1).args).toEqual([ - "pr", - "view", - "7", - "--repo", - "github.com/acme/web", - "--json", - expect.stringContaining("statusCheckRollup"), - ]); + expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); }), ); @@ -648,8 +872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ + encodeJson([ { id: 42, number: 3, @@ -688,12 +911,11 @@ layer("GitHubPullRequestCli.layer", (it) => { { number: 7, headBranch: "feat/two", state: "open" }, ], }); - assert.deepStrictEqual(callAt(0).args, [ - "api", - "--hostname", - "ghe.example.com", - "repos/acme/web/stacks?pull_request=7", - ]); + expect(callAt(0)).toMatchObject({ + kind: "rest", + host: "ghe.example.com", + path: "repos/acme/web/stacks?pull_request=7", + }); }), ); @@ -707,13 +929,11 @@ layer("GitHubPullRequestCli.layer", (it) => { { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, ], }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ ...minimal, pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], }), @@ -733,12 +953,7 @@ layer("GitHubPullRequestCli.layer", (it) => { headSha: "abc123", isDraft: false, }); - expect(callAt(1).args).toEqual([ - "api", - "--hostname", - "github.com", - "repos/acme/web/stacks/3", - ]); + expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); }), ); @@ -760,13 +975,11 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reads a host that refuses the stacks preview as not stacked", () => Effect.gen(function* () { - // The CLI classifies a missing preview endpoint as not found. mockedExecute.mockReturnValueOnce( Effect.fail( - new GitHubCli.GitHubPullRequestNotFoundError({ - command: "gh", - cwd: "/w", - cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), + new GitHubApi.GitHubApiNotFoundError({ + host: "github.com", + operation: "getPullRequestStack", }), ), ); @@ -783,14 +996,13 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("does not read a signed-out gh as an unstacked pull request", () => + it.effect("does not read a refused credential as an unstacked pull request", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( Effect.fail( - new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", - cwd: "/w", - cause: new Error("gh auth login"), + new GitHubApi.GitHubApiAuthenticationError({ + host: "github.com", + operation: "getPullRequestStack", }), ), ); @@ -805,16 +1017,16 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); + assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); }), ); it.effect("preserves transient stack failures instead of reporting no stack", () => Effect.gen(function* () { - const failure = new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/w", - cause: new Error("HTTP 503"), + const failure = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "getPullRequestStack", + status: 503, }); mockedExecute.mockReturnValueOnce(Effect.fail(failure)); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -867,13 +1079,9 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.strictEqual(batch.items.length, 3); assert.isFalse(batch.truncated); - const args = callAt(0).args; - expect(args).toContain("--repo"); - expect(args).toContain("github.com/acme/web"); - expect(args).toContain("--state"); - expect(args).toContain("open"); - expect(args).toContain("--limit"); - expect(args).toContain("11"); + expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); + expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); + expect(queryAt(0)).toContain("first: 11"); }), ); @@ -899,7 +1107,13 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("excludes merged pull requests from the Closed tab", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -912,14 +1126,20 @@ layer("GitHubPullRequestCli.layer", (it) => { limit: 10, }); - // `--state closed` includes merged pull requests, so the tab narrows through search. - expect(searchOfCall(0)).toBe("is:unmerged sort:updated-desc"); + // A closed pull request may also be a merged one, so the tab narrows through search. + expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); }), ); it.effect("narrows to the author on the authored tab", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -932,15 +1152,19 @@ layer("GitHubPullRequestCli.layer", (it) => { limit: 10, }); - const args = callAt(0).args; - expect(args).toContain("--author"); - expect(args).toContain("bilal"); + expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); }), ); it.effect("narrows through search on the reviewing tab", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -953,7 +1177,9 @@ layer("GitHubPullRequestCli.layer", (it) => { limit: 10, }); - expect(searchOfCall(0)).toBe("review-requested:bilal sort:updated-desc"); + expect(searchOfCall(0)).toBe( + "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", + ); }), ); @@ -1008,7 +1234,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("keeps a searched-for qualifier inside the phrase, and out of argv", () => + it.effect("keeps a searched-for qualifier inside the phrase", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -1024,13 +1250,11 @@ layer("GitHubPullRequestCli.layer", (it) => { query: 'x" is:merged repo:evil/repo', }); - // Quoted and escaped, so the words a reader typed narrow the listing rather than widening - // it — and the whole document travels over stdin rather than in a visible argv. + // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. assert.strictEqual( searchQueryOfCall(0), 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', ); - expect(callAt(0).args).not.toContain("-f"); }), ); @@ -1139,12 +1363,12 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("enriches only the visible fallback rows after filtering and widening", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( - Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), + Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), ); mockedExecute.mockReturnValueOnce( - Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), + Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), ); mockedStackMemberships.mockReturnValueOnce( Effect.succeed( @@ -1180,7 +1404,8 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(batch.truncated).toBe(true); expect(batch.continues).toBe(false); expect(mockedStackMemberships).toHaveBeenCalledTimes(1); - const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? ""; + const membership = mockedStackMemberships.mock.calls[0]?.[0]; + const query = membership?.kind === "graphql" ? membership.query : ""; expect(query).toContain("pullRequest(number: 4)"); expect(query).toContain("pullRequest(number: 5)"); expect(query).not.toContain("pullRequest(number: 1)"); @@ -1192,12 +1417,12 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); mockedStackMemberships.mockImplementation((input) => - input.args.at(-1)?.includes("pullRequest(number: 26)") + input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") ? Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/w", - cause: new Error("HTTP 502"), + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "listPullRequestStackMemberships", + status: 502, }), ) : Effect.succeed( @@ -1238,8 +1463,8 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("skips membership enrichment for empty pages and enterprise hosts", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const input = { @@ -1268,7 +1493,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // Every chunk answers for its first alias only, so a row GitHub said nothing about is // dropped rather than shown as a change of no size. Effect.succeed( - output(JSON.stringify({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), + output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -1285,7 +1510,7 @@ layer("GitHubPullRequestCli.layer", (it) => { { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, ]); - const document = callAt(0).args.at(-1) ?? ""; + const document = queryAt(0); expect(document).toContain('s0: repository(owner: "acme", name: "web")'); expect(document).toContain("pullRequest(number: 25)"); }), @@ -1310,7 +1535,13 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("hands a search to GitHub rather than to the rows already read", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1326,13 +1557,21 @@ layer("GitHubPullRequestCli.layer", (it) => { // The recency qualifier rides along, because free text would otherwise reorder the page // by relevance and truncation would drop the newest matches. - expect(searchOfCall(0)).toBe('"pull requests page" sort:updated-desc'); + expect(searchOfCall(0)).toBe( + 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', + ); }), ); it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1346,16 +1585,21 @@ layer("GitHubPullRequestCli.layer", (it) => { query: "page", }); - // One `--search` is all gh reads, so a second would silently drop the first. - const args = callAt(0).args; - assert.strictEqual(args.filter((arg) => arg === "--search").length, 1); - expect(searchOfCall(0)).toBe('review-requested:bilal is:unmerged "page" sort:updated-desc'); + expect(searchOfCall(0)).toBe( + 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', + ); }), ); it.effect("carries the further narrowings into the search as qualifiers", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1379,15 +1623,21 @@ layer("GitHubPullRequestCli.layer", (it) => { // Quotes around anything a reader typed, and the one character that could end a quoted // value early dropped rather than escaped. expect(searchOfCall(0)).toBe( - 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' + - "review:changes_requested status:failure sort:updated-desc", + 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + + "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", ); }), ); it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1401,13 +1651,19 @@ layer("GitHubPullRequestCli.layer", (it) => { filters: { author: "me" }, }); - expect(searchOfCall(0)).toBe('author:"bilal" sort:updated-desc'); + expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); }), ); it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1422,8 +1678,9 @@ layer("GitHubPullRequestCli.layer", (it) => { }); // One qualifier satisfied by either size, and a second one that must hold as well. - expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc'); - expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc'); + expect(searchOfCall(0)).toBe( + 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', + ); }), ); @@ -1431,15 +1688,12 @@ layer("GitHubPullRequestCli.layer", (it) => { "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( Effect.succeed( output( - pullRequests(2, 1, (number) => ({ - statusCheckRollup: - number === 1 - ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] - : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }], + listedPullRequests(2, 1, (number) => ({ + checks: number === 1 ? "SUCCESS" : "FAILURE", })), ), ), @@ -1471,15 +1725,9 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails a checks filter for a row whose checks are still pending", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - pullRequests(1, 1, () => ({ - statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }], - })), - ), - ), + Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -1504,9 +1752,9 @@ layer("GitHubPullRequestCli.layer", (it) => { "falls back for a repository the index does not cover even under a judgeable filter", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( - Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), + Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -1555,7 +1803,13 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1572,15 +1826,20 @@ layer("GitHubPullRequestCli.layer", (it) => { // Every word stays inside one phrase: nothing before it, nothing after it, and the // leading dashes are text rather than the start of another argument. expect(searchOfCall(0)).toBe( - String.raw`"-- is:merged label:secret \"widen me\"" sort:updated-desc`, + String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, ); - expect(callAt(0).args).not.toContain("is:merged"); }), ); it.effect("escapes a backslash before the quote it would otherwise let out", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1596,13 +1855,21 @@ layer("GitHubPullRequestCli.layer", (it) => { // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where // this says it does; escaping the quote alone would have closed it early. - expect(searchOfCall(0)).toBe(String.raw`"a\\\" is:merged" sort:updated-desc`); + expect(searchOfCall(0)).toBe( + String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, + ); }), ); it.effect("asks for nothing but the order when the reader typed only spaces", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1618,7 +1885,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // An empty phrase would match nothing rather than everything, so it is left out; the // order the page reads rows in is asked for whether or not anything was typed. - expect(searchOfCall(0)).toBe("sort:updated-desc"); + expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); }), ); @@ -1640,7 +1907,9 @@ layer("GitHubPullRequestCli.layer", (it) => { // Inclusive, so the rows already sent at that instant come back for the caller to drop — // which is what keeps the ones beside them from being skipped. - expect(searchOfCall(0)).toBe("updated:<=2026-07-02T00:00:00Z sort:updated-desc"); + expect(searchOfCall(0)).toBe( + "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", + ); assert.isTrue(batch.continues); }), ); @@ -1650,7 +1919,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // The fallback is for a repository the index does not cover. Under a text search an empty // answer means the text matched nothing, and listing everything instead would fill the // page with rows the reader did not search for. - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const batch = yield* cli.listPullRequests({ @@ -1669,12 +1938,12 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads a repository GitHub will not search the way gh lists one", () => + it.effect("reads a repository GitHub will not search from its own list", () => Effect.gen(function* () { // GitHub answers for a repository outside its search index with no rows and no error. - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( - Effect.succeed(output(pullRequests(3, 1, () => ({ state: "CLOSED" })))), + Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -1689,20 +1958,21 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(batch.items.length, 3); - // The fallback itself uses no search, then narrows the decoded rows locally. They still - // arrive in gh's own order, so nothing can carry on from them. + // The fallback itself uses no search, then narrows the decoded rows locally. They arrive + // newest-created first, so nothing can carry on from them. expect(searchOfCall(1)).toBeUndefined(); + expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); assert.isFalse(batch.continues); }), ); it.effect("keeps state and involvement filters on the search-free fallback", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( Effect.succeed( output( - pullRequests(4, 1, (number) => ({ + listedPullRequests(4, 1, (number) => ({ state: number === 4 ? "OPEN" : "CLOSED", ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), reviewRequests: @@ -1734,12 +2004,14 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("grows the search-free fallback until it fills the filtered page", () => Effect.gen(function* () { const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1, unrelated)))); + mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(listedPullRequests(3, 1, unrelated))), + ); mockedExecute.mockReturnValueOnce( Effect.succeed( output( - pullRequests(4, 1, (number) => + listedPullRequests(4, 1, (number) => number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), ), ), @@ -1758,25 +2030,34 @@ layer("GitHubPullRequestCli.layer", (it) => { }); expect(batch.items.map((item) => item.number)).toEqual([4]); - const firstFallbackArgs = callAt(1).args; - const secondFallbackArgs = callAt(2).args; - expect(firstFallbackArgs[firstFallbackArgs.indexOf("--limit") + 1]).toBe("3"); - expect(secondFallbackArgs[secondFallbackArgs.indexOf("--limit") + 1]).toBe("6"); + expect(queryAt(1)).toContain("first: 3,"); + expect(queryAt(2)).toContain("first: 6,"); assert.isFalse(batch.truncated); }), ); it.effect("bounds a sparse search-free fallback and reports the unread tail", () => Effect.gen(function* () { - mockedExecute.mockImplementation((_input) => { - if (mockedExecute.mock.calls.length === 1) return Effect.succeed(output("[]")); - const args = callAt(mockedExecute.mock.calls.length - 1).args; - const limit = Number(args[args.indexOf("--limit") + 1]); + // A repository with far more pull requests than the bound, none of them for this reader. + let listed = 0; + mockedExecute.mockImplementation((call) => { + if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { + return Effect.succeed(emptySearch()); + } + const first = Number(/first: (\d+)/.exec(call.query)?.[1]); + const nodes = rows(first, listed + 1, () => ({ + reviewRequests: [{ login: "somebody-else" }], + })); + listed += first; return Effect.succeed( output( - pullRequests(limit, 1, () => ({ - reviewRequests: [{ login: "somebody-else" }], - })), + encodeJson({ + data: { + repository: { + pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, + }, + }, + }), ), ); }); @@ -1792,8 +2073,10 @@ layer("GitHubPullRequestCli.layer", (it) => { limit: 2, }); - const finalArgs = callAt(mockedExecute.mock.calls.length - 1).args; - expect(finalArgs[finalArgs.indexOf("--limit") + 1]).toBe("1000"); + // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. + const last = mockedExecute.mock.calls.length - 1; + expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); + expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); assert.strictEqual(batch.items.length, 0); assert.isTrue(batch.truncated); }), @@ -1801,7 +2084,13 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes an empty slice for a repository that has run out, not one to read again", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -1821,9 +2110,32 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + /** The state a merge or branch update reads first: behind its base and blocked on checks. */ + const actionState = (pullRequest: Readonly> = {}) => ({ + data: { + repository: { + pullRequest: { + id: "PR_7", + headRefOid: "abc123", + isMergeQueueEnabled: false, + mergeStateStatus: "BLOCKED", + baseRef: { compare: { behindBy: 2 } }, + ...pullRequest, + }, + }, + }, + }); + const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ + data: { + repository: { + pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, + }, + }, + }); + it.effect("updates a stale branch with a merge commit unless asked to rebase", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route(["query PullRequestActionState", actionState()]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -1833,9 +2145,6 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, action: "update-branch", }); - // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise. - expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]); - yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -1844,20 +2153,18 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "update-branch", updateMethod: "rebase", }); - expect(callAt(1).args).toEqual([ - "pr", - "update-branch", - "7", - "--repo", - "github.com/acme/web", - "--rebase", + + // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. + expect(variablesOf("updatePullRequestBranch(")).toEqual([ + { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, + { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, ]); }), ); it.effect("merges with the strategy it was asked for", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route(["query PullRequestActionState", actionState()]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -1869,14 +2176,10 @@ layer("GitHubPullRequestCli.layer", (it) => { mergeMethod: "squash", }); - expect(callAt(0).args).toEqual([ - "pr", - "merge", - "7", - "--repo", - "github.com/acme/web", - "--squash", + expect(variablesOf("mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, ]); + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); }), ); @@ -1884,26 +2187,15 @@ layer("GitHubPullRequestCli.layer", (it) => { "removes agent credits from the proposed message for %s", (action) => Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - isMergeQueueEnabled: false, - headRefOid: "abc123", - viewerMergeBodyText: - "Details\n\nCo-authored-by: Alice \nCo-authored-by: Claude ", - }, - }, - }, - }), - ), + route( + [ + "query PullRequestMergeMessage", + mergeMessage( + "Details\n\nCo-authored-by: Alice \nCo-authored-by: Claude ", ), - ) - .mockReturnValueOnce(Effect.succeed(output(""))); + ], + ["query PullRequestActionState", actionState()], + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ cwd: "/w", @@ -1914,12 +2206,20 @@ layer("GitHubPullRequestCli.layer", (it) => { mergeMethod: "squash", removeAgentCreditsOnMerge: true, }); - expect(callAt(0).args).toContain("method=SQUASH"); - expect(callAt(1).args.slice(-2)).toEqual(["--body-file", "-"]); - expect(callAt(1).args).toContain("--match-head-commit"); - expect(callAt(1).args).toContain("abc123"); - expect(callAt(1).stdin).toBe("Details\n\nCo-authored-by: Alice "); - expect(callAt(1).args.join(" ")).not.toContain("alice@example.com"); + expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); + expect( + variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), + ).toEqual([ + { + input: { + pullRequestId: "PR_7", + mergeMethod: "SQUASH", + // Pinned to the head the message was read from, so it cannot describe other commits. + expectedHeadOid: "abc123", + commitBody: "Details\n\nCo-authored-by: Alice ", + }, + }, + ]); }), ); @@ -1936,25 +2236,10 @@ layer("GitHubPullRequestCli.layer", (it) => { }, ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - isMergeQueueEnabled: queued, - headRefOid: "abc123", - viewerMergeBodyText: body, - }, - }, - }, - }), - ), - ), - ) - .mockReturnValueOnce(Effect.succeed(output(""))); + route( + ["query PullRequestMergeMessage", mergeMessage(body, queued)], + ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ cwd: "/w", @@ -1964,33 +2249,23 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "merge", removeAgentCreditsOnMerge: true, }); - expect(callAt(0).args).toContain("method=MERGE"); - expect(callAt(1).args).not.toContain("--body-file"); - expect(callAt(1).stdin).toBeUndefined(); + expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); + // A merge queue takes the pull request through auto-merge, with its own message. + expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, + ]); }), ); it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - isMergeQueueEnabled: false, - headRefOid: "abc123", - viewerMergeBodyText: "Co-authored-by: Claude ", - }, - }, - }, - }), - ), - ), - ) - .mockReturnValueOnce(Effect.succeed(output(""))); + route( + [ + "query PullRequestMergeMessage", + mergeMessage("Co-authored-by: Claude "), + ], + ["query PullRequestActionState", actionState()], + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2000,14 +2275,22 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "merge", removeAgentCreditsOnMerge: true, }); - expect(callAt(1).stdin).toBe(""); - expect(callAt(1).args).toContain("--body-file"); + expect(variablesOf("mergePullRequest(")).toEqual([ + { + input: { + pullRequestId: "PR_7", + mergeMethod: "MERGE", + expectedHeadOid: "abc123", + commitBody: "", + }, + }, + ]); }), ); it.effect("does not fetch a message for rebase merges", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route(["query PullRequestActionState", actionState()]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2018,15 +2301,18 @@ layer("GitHubPullRequestCli.layer", (it) => { mergeMethod: "rebase", removeAgentCreditsOnMerge: true, }); - expect(mockedExecute).toHaveBeenCalledTimes(1); - expect(callAt(0).args).toContain("--rebase"); + expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); + expect(variablesOf("mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, + ]); }), ); it.effect("refuses to merge when the proposed message cannot be read", () => Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed(output('{"data":{"repository":{"pullRequest":null}}}')), + route( + ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], + ["query PullRequestActionState", actionState()], ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const result = yield* Effect.result( @@ -2040,13 +2326,13 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); expect(result._tag).toBe("Failure"); - expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(variablesOf("mergePullRequest(")).toEqual([]); }), ); it.effect("arms auto-merge with the same strategy a merge would have used", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route(["query PullRequestActionState", actionState()]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -2057,39 +2343,47 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "enable-auto-merge", mergeMethod: "squash", }); - expect(callAt(0).args).toEqual([ - "pr", - "merge", - "7", - "--repo", - "github.com/acme/web", - "--auto", - "--squash", + // No strategy asked for is GitHub's own default, exactly as it is for a merge now. + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + }); + + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, + { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, ]); + expect(variablesOf("mergePullRequest(")).toEqual([]); + }), + ); + + it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => + Effect.gen(function* () { + route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - // No strategy asked for is GitHub's own default, exactly as it is for a merge now. yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7, action: "enable-auto-merge", + mergeMethod: "squash", }); - expect(callAt(1).args).toEqual([ - "pr", - "merge", - "7", - "--repo", - "github.com/acme/web", - "--auto", - "--merge", + + expect(variablesOf("mergePullRequest(")).toEqual([ + { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, ]); + expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); }), ); it.effect("takes auto-merge back off without naming a strategy", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -2101,30 +2395,13 @@ layer("GitHubPullRequestCli.layer", (it) => { mergeMethod: "squash", }); - expect(callAt(0).args).toEqual([ - "pr", - "merge", - "7", - "--repo", - "github.com/acme/web", - "--disable-auto", - ]); + expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); }), ); it.effect("opens a pull request that reverts a merged pull request", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh GraphQL response. - JSON.stringify({ - data: { repository: { pullRequest: { id: "PR_7" } } }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -2135,19 +2412,7 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "revert", }); - expect(callAt(0).args).toContain("owner=acme"); - expect(callAt(0).args).toContain("name=web"); - expect(callAt(0).args).toContain("number=7"); - expect(callAt(1).args).toEqual([ - "api", - "graphql", - "--hostname", - "github.com", - "--input", - "-", - ]); - expect(callAt(1).stdin).toContain("revertPullRequest"); - expect(callAt(1).stdin).toContain('"pullRequestId":"PR_7"'); + expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); }), ); @@ -2189,55 +2454,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("finds and approves every workflow waiting on a maintainer", () => Effect.gen(function* () { - const detail = output( - encodeJson( - coreResponse({ - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }), - ), - ); - const heads = output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify([ - { - number: 7, - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - }, - ]), - ); - const runs = output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify([ - { databaseId: 10, workflowName: "build", url: "https://example.com/10" }, - { databaseId: 11, workflowName: "test", url: "https://example.com/11" }, - ]), - ); - for (const result of [ - detail, - heads, - runs, - detail, - heads, - runs, - output(""), - detail, - heads, - runs, - output(""), - ]) { - mockedExecute.mockReturnValueOnce(Effect.succeed(result)); - } + workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -2248,98 +2465,70 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "approve-workflows", }); - expect(callAt(1).args).toEqual([ - "pr", - "list", - "--repo", - "github.com/acme/web", - "--state", - "open", - "--head", - "feat/page", - "--limit", - "1001", - "--json", - "number,headRefOid,isCrossRepository,headRepositoryOwner", - ]); - expect(callAt(2).args).toEqual([ - "run", - "list", - "--repo", - "github.com/acme/web", - "--commit", - "abc123", - "--branch", - "feat/page", - "--event", - "pull_request", - "--status", - "action_required", - "--limit", - "1001", - "--json", - "databaseId,workflowName,url", - ]); - expect([callAt(6).args, callAt(10).args]).toEqual([ - [ - "api", - "--method", - "POST", - "--hostname", - "github.com", - "repos/acme/web/actions/runs/10/approve", - "--silent", - ], - [ - "api", - "--method", - "POST", - "--hostname", - "github.com", - "repos/acme/web/actions/runs/11/approve", - "--silent", - ], + // Open pull requests on this head branch, and runs scoped to this exact head. + expect(variablesOf("headRefName: $head")[0]).toEqual({ + owner: "acme", + name: "web", + head: "feat/page", + after: null, + }); + expect(restCallsTo("actions/runs?")[0]?.path).toBe( + "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", + ); + // Each run is approved only after the head is read again and still lists it. + expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ + ["POST", "repos/acme/web/actions/runs/10/approve"], + ["POST", "repos/acme/web/actions/runs/11/approve"], ]); - expect(mockedExecute).toHaveBeenCalledTimes(11); + assert.strictEqual(restCallsTo("actions/runs?").length, 3); }), ); - it.effect("refuses a stale workflow approval after the pull request head changes", () => + it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => Effect.gen(function* () { - const detail = { - number: 7, - title: "Pull request 7", - url: "https://github.com/acme/web/pull/7", - headRefName: "feat/page", - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - }; - for (const value of [ - coreResponse(detail), - [ - { - number: 7, - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - }, - ], - [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }], - coreResponse({ ...detail, headRefOid: "def456" }), - ]) { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify(value), - ), - ), + workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const runs = yield* cli + .listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }) + .pipe( + Effect.provideService(KnownWorkflowRuns, { + headSha: "abc123", + runs: [ + // Live shape: waiting on approval is completed + action_required. + { + id: 10, + status: "completed", + conclusion: "action_required", + head_branch: "feat/page", + }, + { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, + ], + }), ); - } + + expect(runs.map((run) => run.id)).toEqual([10]); + assert.strictEqual(restCallsTo("actions/runs?").length, 0); + }), + ); + + it.effect("refuses a stale workflow approval after the pull request head changes", () => + Effect.gen(function* () { + let detailReads = 0; + workflowApprovalRoutes( + () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), + heads([7]), + workflowRuns([10]), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const error = yield* Effect.flip( @@ -2356,7 +2545,8 @@ layer("GitHubPullRequestCli.layer", (it) => { _tag: "GitHubWorkflowApprovalHeadChangedError", number: 7, }); - expect(mockedExecute).toHaveBeenCalledTimes(4); + assert.strictEqual(detailReads, 2); + expect(restCallsTo("/approve")).toEqual([]); }), ); @@ -2364,21 +2554,15 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { const headsStarted = yield* Deferred.make(); const runsStarted = yield* Deferred.make(); - mockedExecute.mockImplementation(({ args }) => - args[0] === "pr" + mockedExecute.mockImplementation((call) => + call.kind === "graphql" ? Deferred.succeed(headsStarted, undefined).pipe( Effect.andThen(Deferred.await(runsStarted)), - Effect.as( - output( - '[{"number":7,"headRefOid":"abc123","isCrossRepository":true,"headRepositoryOwner":{"login":"octocat"}}]', - ), - ), + Effect.as(output(encodeJson(heads([7])))), ) : Deferred.succeed(runsStarted, undefined).pipe( Effect.andThen(Deferred.await(headsStarted)), - Effect.as( - output('[{"databaseId":10,"workflowName":"build","url":"https://example.com/10"}]'), - ), + Effect.as(output(encodeJson(workflowRuns([10])))), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -2400,22 +2584,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses workflow approval when one head belongs to several pull requests", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify( - [7, 8].map((number) => ({ - number, - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - })), - ), - ), - ), - ); + workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const error = yield* Effect.flip( @@ -2438,8 +2607,7 @@ layer("GitHubPullRequestCli.layer", (it) => { observedCount: 2, limit: 1_000, }); - expect(error.detail).toContain("instead of uniquely matching #7"); - expect(mockedExecute).toHaveBeenCalledTimes(2); + expect(error.message).toContain("instead of uniquely matching #7"); }), ); @@ -2487,29 +2655,18 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("surfaces a workflow run list beyond the safe approval bound", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify([ - { - number: 7, - headRefOid: "abc123", - isCrossRepository: true, - headRepositoryOwner: { login: "octocat" }, - }, - ]), - ), - ), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed( + let runPages = 0; + mockedExecute.mockImplementation((call) => { + if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); + const page = runPages++; + return Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response. - JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))), + encodeJson( + workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), + ), ), - ), - ); + ); + }); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const error = yield* Effect.flip( @@ -2525,21 +2682,22 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + // Paged a hundred at a time, it stops at the first page past the bound. expect(error).toMatchObject({ _tag: "GitHubWorkflowApprovalRefusedError", reason: "run-list-truncated", number: 7, - observedCount: 1_001, + observedCount: 1_100, limit: 1_000, }); - expect(error.detail).toContain("more than 1000 workflow runs"); - expect(mockedExecute).toHaveBeenCalledTimes(2); + expect(error.message).toContain("more than 1000 workflow runs"); + assert.strictEqual(runPages, 11); }), ); - it.effect("returns a pull request to draft by undoing ready", () => + it.effect("returns a pull request to draft by converting it", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.runPullRequestAction({ @@ -2550,21 +2708,14 @@ layer("GitHubPullRequestCli.layer", (it) => { action: "draft", }); - // gh has no `draft` command; going back is `ready --undo`. - expect(callAt(0).args).toEqual([ - "pr", - "ready", - "7", - "--repo", - "github.com/acme/web", - "--undo", - ]); + expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); + expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); }), ); - it.effect("sends a comment body over stdin, never in argv", () => + it.effect("sends a comment body as a variable, never inside the document", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output(""))); + route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.commentOnPullRequest({ @@ -2575,24 +2726,20 @@ layer("GitHubPullRequestCli.layer", (it) => { body: "Looks good.", }); - // argv shows up in process listings and in process-runner failure messages. - expect(callAt(0).args).toEqual([ - "pr", - "comment", - "7", - "--repo", - "github.com/acme/web", - "--body-file", - "-", - ]); - expect(callAt(0).stdin).toBe("Looks good."); - expect(callAt(0).args).not.toContain("Looks good."); + expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); + expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); }), ); it.effect("names the host on every repository it addresses", () => Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + mockedExecute.mockImplementation((call) => + Effect.succeed( + call.kind === "graphql" && call.query.includes("pullRequests(") + ? emptyList() + : emptySearch(), + ), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.listPullRequests({ @@ -2605,8 +2752,12 @@ layer("GitHubPullRequestCli.layer", (it) => { limit: 10, }); - // A bare `owner/repo` resolves against github.com, which is a different repository. - expect(callAt(0).args).toContain("github.acme.dev/acme/web"); + // A request sent to github.com would read a different repository of the same name. + assert.isAbove(mockedExecute.mock.calls.length, 0); + expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( + new Set(["github.acme.dev"]), + ); + expect(searchOfCall(0)).toContain("repo:acme/web"); }), ); @@ -2615,8 +2766,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, }, @@ -2633,11 +2783,8 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); - const args = callAt(0).args; - expect(args).toContain("--hostname"); - expect(args).toContain("github.acme.dev"); - expect(args).toContain("owner=acme"); - expect(args).toContain("name=web"); + expect(callAt(0).host).toBe("github.acme.dev"); + expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); }), ); @@ -2653,13 +2800,18 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); + assert.strictEqual(diff.patch, "diff --git a/a b/a"); assert.isNull(diff.nextCursor); assert.isFalse(diff.truncated); // The common case pays for one request and not the files API on top of it. assert.strictEqual(mockedExecute.mock.calls.length, 1); - // `--patch` asks gh for a format-patch stream, which repeats a file once per commit. - // The review needs GitHub's combined pull-request diff: one section per changed file. - expect(callAt(0).args).not.toContain("--patch"); + // GitHub's combined pull-request diff: one section per changed file, not one per commit. + const call = callAt(0); + assert.strictEqual(call.kind, "rest"); + if (call.kind === "rest") { + assert.strictEqual(call.path, "repos/acme/web/pulls/7"); + assert.strictEqual(call.accept, "application/vnd.github.diff"); + } }), ); @@ -2682,10 +2834,8 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.isNull(diff.nextCursor); expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); - const args = callAt(1).args; - expect(args).toContain("--hostname"); - expect(args).toContain("github.acme.dev"); - expect(args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=1"); + assert.strictEqual(callAt(1).host, "github.acme.dev"); + assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); }), ); @@ -2709,7 +2859,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("carries on from a cursor without asking `gh pr diff` again", () => + it.effect("carries on from a cursor without asking for the whole diff again", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); @@ -2725,7 +2875,7 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); // The second slice is one request: the cursor already says where to read. assert.strictEqual(mockedExecute.mock.calls.length, 3); - expect(callAt(2).args).toContain("repos/acme/web/pulls/7/files?per_page=100&page=2"); + assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); }), ); @@ -2748,9 +2898,11 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads a named commit from the commit endpoint rather than from `gh pr diff`", () => + it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const diff = yield* cli.getPullRequestDiff({ @@ -2761,22 +2913,23 @@ layer("GitHubPullRequestCli.layer", (it) => { commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", }); - // One request: the commit's own changes never take the `gh pr diff` road. + // One request: the commit's own changes never take the whole-diff road. assert.strictEqual(mockedExecute.mock.calls.length, 1); assert.isNull(diff.nextCursor); + // The commit endpoint wraps its files in an object, which the decoder unwraps. expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); - const args = callAt(0).args; - expect(args).toContain( + assert.strictEqual( + pathAt(0), "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", ); - // The commit endpoint wraps its files in an object, which jq unwraps for the decoder. - expect(args).toContain(".files // []"); }), ); it.effect("pages inside a commit the way it pages the pull request's own files", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const target = { cwd: "/w", @@ -2788,11 +2941,14 @@ layer("GitHubPullRequestCli.layer", (it) => { const first = yield* cli.getPullRequestDiff(target); assert.isNotNull(first.nextCursor); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), + ); const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); assert.isNull(second.nextCursor); - expect(callAt(1).args).toContain("repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); + expect(second.patch).toContain("src/file100.ts"); + assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); }), ); @@ -2817,7 +2973,9 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("expands a new file from a root commit without requiring a parent", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("\ta1b2c3d\n"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), + ); mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -2834,7 +2992,8 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(callAt(1).args.join(" ")).toContain("contents/src/root.ts?ref=a1b2c3d"); + assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); + assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); }), ); @@ -2866,7 +3025,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports an oversized diff file with its path and reason", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -2887,15 +3046,15 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.strictEqual(error.path, "src/large.ts"); assert.strictEqual(error.reason, "oversized"); } + // A deleted file is read at the base revision only. + assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); }), ); it.effect("reports undecodable diff file contents as binary", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output("binary\uFFFDcontents", false, true)), - ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const error = yield* Effect.flip( @@ -2920,8 +3079,8 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("returns valid text containing a literal replacement character", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("a1b2c3d\tb1c2d3e\n"))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const contents = yield* cli.getPullRequestDiffFileContents({ @@ -2934,7 +3093,7 @@ layer("GitHubPullRequestCli.layer", (it) => { newPath: "docs/encoding.md", }); - assert.strictEqual(contents.oldContents, "before\uFFFDafter"); + assert.strictEqual(contents.oldContents, "before�after"); }), ); @@ -2996,8 +3155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], rateLimit: { @@ -3020,8 +3178,8 @@ layer("GitHubPullRequestCli.layer", (it) => { ids: ["MDQ6VXNlcjE="], }); - expect(callAt(0).args).toContain("ids[]=MDQ6VXNlcjE="); - expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }"); + expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); + expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); expect(avatars.get("octocat")).toBe("https://avatars/octocat"); }), ); @@ -3039,16 +3197,16 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("looks up the authenticated account on the requested enterprise host", () => Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential"))) - .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}'))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), + ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); expect(login).toBe("enterprise-user"); - expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]); - expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]); + expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); + expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); }), ); @@ -3056,47 +3214,42 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const input = { cwd: "/w", host: "github.identity-cache.test" }; - mockedExecute - .mockReturnValueOnce(Effect.succeed(output("test-credential-a"))) - .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); + mockedCredential.mockImplementation((host) => + Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); expect(yield* cli.getRoutingIdentity(input)).toEqual({ accountId: "123", viewer: "maria-rcks", }); - expect(callAt(1).env).toMatchObject({ - GH_ENTERPRISE_TOKEN: "test-credential-a", - GH_DEBUG: "", - }); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("test-credential-a"))); + // The same credential again is answered from what was verified, without asking GitHub. expect(yield* cli.getRoutingIdentity(input)).toEqual({ accountId: "123", viewer: "maria-rcks", }); - expect(mockedExecute).toHaveBeenCalledTimes(3); + expect(mockedExecute).toHaveBeenCalledTimes(1); - mockedExecute - .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) - .mockReturnValueOnce( - Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/w", - cause: new Error("upstream failed with test-credential-b"), - }), - ), - ); + // A switched credential is not trusted on the strength of the old one's answer. + mockedCredential.mockImplementation((host) => + Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), + ); + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "getRoutingIdentity", + status: 502, + }), + ), + ); + // GitHub's own refusal is reported as itself, so the page can say what went wrong. const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); - expect(failure._tag).toBe("GitHubViewerLoginUnavailableError"); + expect(failure._tag).toBe("GitHubApiResponseError"); expect(String(failure)).not.toContain("test-credential-b"); - expect(callAt(4).env).toMatchObject({ - GH_ENTERPRISE_TOKEN: "test-credential-b", - GH_DEBUG: "", - }); + expect(mockedExecute).toHaveBeenCalledTimes(2); - mockedExecute - .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) - .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); expect(yield* cli.getRoutingIdentity(input)).toEqual({ accountId: "456", viewer: "maria-rcks", @@ -3104,7 +3257,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("sends a whole review as one request body over stdin", () => + it.effect("sends a whole review as one request", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -3119,28 +3272,23 @@ layer("GitHubPullRequestCli.layer", (it) => { comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }); - expect(callAt(0).args).toEqual([ - "api", - "--method", - "POST", - "--hostname", - "github.com", - "repos/acme/web/pulls/7/reviews", - "--input", - "-", - ]); // One request, so nothing is on the pull request until the verdict is. assert.strictEqual(mockedExecute.mock.calls.length, 1); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ - event: "APPROVE", - body: "Looks right.", - comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], - }); + expect(restCallsTo("/reviews")).toMatchObject([ + { + method: "POST", + path: "repos/acme/web/pulls/7/reviews", + body: { + event: "APPROVE", + body: "Looks right.", + comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], + }, + }, + ]); }), ); - it.effect("sends a reply body over stdin, never in argv", () => + it.effect("sends a reply body as a variable, never inside the document", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -3153,23 +3301,10 @@ layer("GitHubPullRequestCli.layer", (it) => { body: "Fixed in 42ff8ec.", }); - // A reply is the reader's own words, so it travels the same way a comment body does. - expect(callAt(0).args).toEqual([ - "api", - "graphql", - "--hostname", - "github.com", - "--input", - "-", + expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ + { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, ]); - // @effect-diagnostics-next-line preferSchemaOverJson:off - const request = JSON.parse(callAt(0).stdin ?? "") as { - query: string; - variables: Record; - }; - expect(request.query).toContain("addPullRequestReviewThreadReply"); - expect(request.variables).toEqual({ threadId: "PRRT_1", body: "Fixed in 42ff8ec." }); - expect(callAt(0).args.join(" ")).not.toContain("Fixed in 42ff8ec."); + expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); }), ); @@ -3193,30 +3328,18 @@ layer("GitHubPullRequestCli.layer", (it) => { resolved: false, }); - const parse = (index: number) => JSON.parse(callAt(index).stdin ?? "") as { query: string }; - expect(parse(0).query).toContain("resolveReviewThread("); - expect(parse(1).query).toContain("unresolveReviewThread("); + expect(queryAt(0)).toContain("resolveReviewThread("); + expect(queryAt(0)).not.toContain("unresolveReviewThread("); + expect(queryAt(1)).toContain("unresolveReviewThread("); + expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); // A GitHub Enterprise thread is resolved on its own host, not on github.com. - expect(callAt(0).args).toContain("github.acme.dev"); + expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); }), ); it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - data: { - repository: { pullRequest: { id: "PR_kwDOA" } }, - node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, - }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.setReaction({ @@ -3230,18 +3353,10 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - const scopeCheck = callAt(0).args; - expect(scopeCheck).toContain("owner=acme"); - expect(scopeCheck).toContain("name=web"); - expect(scopeCheck).toContain("number=7"); - expect(scopeCheck).toContain("subjectId=IC_1"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - const request = JSON.parse(callAt(1).stdin ?? "") as { - query: string; - variables: Record; - }; - expect(request.query).toContain("addReaction("); - expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" }); + expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ + { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, + ]); + expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); }), ); @@ -3250,8 +3365,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { id: "PR_thisOne" } }, // A comment on pull request #99 of a different repository, named as though it @@ -3284,15 +3398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("looks up the pull request's own node id when no subject was given", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; // Its own pull request: a node id looked up once is remembered for the life of the service. @@ -3306,36 +3412,14 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - const lookup = callAt(0).args; - expect(lookup).toContain("owner=acme"); - expect(lookup).toContain("name=web"); - expect(lookup).toContain("number=21"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - const request = JSON.parse(callAt(1).stdin ?? "") as { - query: string; - variables: Record; - }; - expect(request.query).toContain("addReaction("); - expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" }); + expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); + expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); }), ); it.effect("takes a reaction back through the remove mutation", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - data: { - repository: { pullRequest: { id: "PR_kwDOA" } }, - node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, - }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; yield* cli.setReaction({ @@ -3348,22 +3432,14 @@ layer("GitHubPullRequestCli.layer", (it) => { reacted: false, }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - const request = JSON.parse(callAt(1).stdin ?? "") as { query: string }; - expect(request.query).toContain("removeReaction("); + expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); + expect(variablesOf("addReaction(")).toEqual([]); }), ); it.effect("rewrites only the words a request named", () => Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), - ), - ), - ); + route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const rewrite = (fields: { readonly title?: string; readonly body?: string }) => cli.updatePullRequest({ @@ -3379,38 +3455,18 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* rewrite({ title: "Both", body: "at once." }); // One node id lookup for the pull request, then a mutation per rewrite. - const variablesAt = (index: number) => - (JSON.parse(callAt(index).stdin ?? "") as { variables: Record }).variables; - expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); - expect(variablesAt(2)).toEqual({ - pullRequestId: "PR_kwDOA", - body: "A better description.", - }); - expect(variablesAt(3)).toEqual({ - pullRequestId: "PR_kwDOA", - title: "Both", - body: "at once.", - }); - // The reader's own words, so they travel the way every other body does. - expect(callAt(3).args.join(" ")).not.toContain("at once."); + assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); + expect(variablesOf("updatePullRequest(")).toEqual([ + { pullRequestId: "PR_kwDOA", title: "A better title" }, + { pullRequestId: "PR_kwDOA", body: "A better description." }, + { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, + ]); }), ); it.effect("rewrites a remark through the mutation its kind needs", () => Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - data: { - repository: { pullRequest: { id: "PR_kwDOA" } }, - node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, - }, - }), - ), - ), - ); + route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; const rewrite = (kind: "issue-comment" | "review-comment") => cli.updateComment({ @@ -3426,16 +3482,16 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* rewrite("issue-comment"); yield* rewrite("review-comment"); - const parse = (index: number) => - JSON.parse(callAt(index).stdin ?? "") as { - query: string; - variables: Record; - }; - expect(callAt(0).args).toContain("subjectId=IC_1"); - expect(parse(1).query).toContain("updateIssueComment("); - expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); - expect(parse(3).query).toContain("updatePullRequestReviewComment("); - expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); + expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ + "IC_1", + "IC_1", + ]); + expect(variablesOf("updateIssueComment(")).toEqual([ + { commentId: "IC_1", body: "Reworded." }, + ]); + expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ + { commentId: "IC_1", body: "Reworded." }, + ]); }), ); @@ -3444,8 +3500,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { id: "PR_thisOne" } }, node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, @@ -3469,7 +3524,8 @@ layer("GitHubPullRequestCli.layer", (it) => { ); assert.strictEqual(error._tag, "GitHubSubjectScopeError"); - expect(error.message).toContain("updateComment"); + if (error._tag === "GitHubSubjectScopeError") + assert.strictEqual(error.operation, "updateComment"); // Refused before any mutation was sent. assert.strictEqual(mockedExecute.mock.calls.length, 1); }), @@ -3500,14 +3556,8 @@ layer("GitHubPullRequestCli.layer", (it) => { output( encodeJson( coreResponse({ - number: 7, title: "Progressive detail", - url: "https://github.com/acme/web/pull/7", author: { login: "octocat" }, - headRefName: "feature", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", body: "Core body", changedFiles: 2, }), @@ -3518,12 +3568,17 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - author: { login: "octocat" }, - comments: [], - reviews: [], - commits: [], + encodeJson({ + data: { + repository: { + pullRequest: { + author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, + commits: { nodes: [] }, + comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }, + }, }), ), ), @@ -3541,15 +3596,19 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(detail.body).toBe("Core body"); expect(activity.author?.login).toBe("octocat"); - expect(callAt(0).args).toContain("headRef=refs/pull/7/head"); - expect(callAt(0).args.at(-1)).toContain("viewerCanUpdateBranch"); + expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); + expect(queryAt(0)).toContain("viewerCanUpdateBranch"); + expect(queryAt(0)).not.toContain("reviews("); expect(detail.viewerAccess.mergeCapabilities).toEqual({ merge: true, squash: false, rebase: true, }); expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); - expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); + // Conversation activity is its own read, and asks for the head of the conversation once. + expect(queryAt(1)).toContain("reviews("); + expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); }), ); @@ -3621,35 +3680,40 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reads every check when the combined response has another page", () => Effect.gen(function* () { - const response = coreResponse({ - commits: { - nodes: [ - { - commit: { - statusCheckRollup: { - contexts: { - nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], - pageInfo: { hasNextPage: true }, - }, + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson( + coreResponse({ + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + contexts: { + nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], + pageInfo: { hasNextPage: true, endCursor: "c1" }, + }, + }, + }, + }, + ], }, - }, - }, - ], - }, - }); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); + }), + ), + ), + ), + ); mockedExecute.mockReturnValueOnce( Effect.succeed( output( - encodeJson({ - ...response.data.repository.pullRequest, - reviewRequests: [], - labels: [], - statusCheckRollup: [ + checkContextsPage( + [ { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, ], - }), + null, + ), ), ), ); @@ -3663,7 +3727,9 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(detail.checks).toHaveLength(2); expect(detail.checksState).toBe("failing"); expect(detail.checksTruncated).toBe(false); - expect(callAt(1).args.slice(0, 2)).toEqual(["pr", "view"]); + // The whole rollup is walked from its start, so no check is counted twice or skipped. + expect(varsAt(1)).toMatchObject({ number: 7, after: null }); + assert.strictEqual(mockedExecute.mock.calls.length, 2); }), ); @@ -3744,9 +3810,7 @@ layer("GitHubPullRequestCli.layer", (it) => { const error = yield* Effect.flip(cli.getPullRequestDetail(input)); expect(error._tag).toBe("SourceControlRateLimitPausedError"); expect(mockedExecute).toHaveBeenCalledOnce(); - yield* cli - .getPullRequestDetail(input) - .pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); + yield* cli.getPullRequestDetail(input).pipe(Effect.provideService(AllowGitHubReserve, true)); expect(mockedExecute).toHaveBeenCalledTimes(2); }), ); @@ -3769,13 +3833,13 @@ layer("GitHubPullRequestCli.layer", (it) => { // What matters is that it fails at all: an empty patch with no cursor would render as a // change with no files and report the rest of it as already read. The refusal that sent // the read down this road is the one reported, by design. - assert.strictEqual(error._tag, "GitHubCliCommandError"); + assert.strictEqual(error, diffRefused); }), ); it.effect("pages an oversized patch by file rather than handing back a severed one", () => Effect.gen(function* () { - // `gh pr diff` succeeded but its output was cut at a byte, which lands mid-file. + // The whole diff came back, but cut at a byte, which lands mid-file. mockedExecute.mockReturnValueOnce( Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), ); @@ -3790,8 +3854,9 @@ layer("GitHubPullRequestCli.layer", (it) => { }); // The severed patch is thrown away; what comes back is assembled from whole files. - expect(callAt(1).args.join(" ")).toContain("/pulls/7/files"); + assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); expect(slice.patch).toContain("src/file1.ts"); + expect(slice.patch).not.toContain("diff --git a/a b/a"); assert.strictEqual(mockedExecute.mock.calls.length, 2); }), ); @@ -3813,9 +3878,9 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); - // The first page asks from the beginning, which gh only sends as a typed JSON null. - expect(callAt(0).args).toContain("cursor=null"); - expect(callAt(1).args).toContain("cursor=Y3Vyc29yOjE"); + // The first page asks from the beginning; the next carries on from where it stopped. + expect(varsAt(0)["cursor"]).toBeNull(); + expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); assert.isFalse(conversation.truncated); }), @@ -3897,11 +3962,13 @@ layer("GitHubPullRequestCli.layer", (it) => { cursor: "Y3Vyc29yOjI", }); - expect(callAt(0).args).toContain("owner=acme"); - expect(callAt(0).args).toContain("name=web"); - expect(callAt(0).args).toContain("number=7"); - expect(callAt(0).args).toContain("threadId=PRRT_1"); - expect(callAt(0).args).toContain("cursor=Y3Vyc29yOjI"); + expect(varsAt(0)).toMatchObject({ + owner: "acme", + name: "web", + number: 7, + threadId: "PRRT_1", + cursor: "Y3Vyc29yOjI", + }); assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); expect(page.nextCursor).toBeNull(); @@ -3937,8 +4004,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValue( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { mergeCommitAllowed: true, @@ -3963,10 +4029,8 @@ layer("GitHubPullRequestCli.layer", (it) => { // One request, because both answers hang off the same repository object. assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(callAt(0).args).toContain("number=7"); - expect(callAt(0).args.at(-1)).toContain( - "mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed", - ); + expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); + expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); expect(access).toEqual({ mergeCapabilities: { merge: true, squash: false, rebase: true }, canWrite: false, @@ -3977,13 +4041,12 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("sends the base comparison's variables as gh flags, not as bare words", () => + it.effect("sends the base comparison's head as a variable, not inside the document", () => Effect.gen(function* () { mockedExecute.mockReturnValue( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { @@ -4006,26 +4069,14 @@ layer("GitHubPullRequestCli.layer", (it) => { headRef: "fork:feat/page", }); - // The tuples are flattened straight into argv, so a variable without its flag is a - // positional argument gh refuses outright. - const args = callAt(0).args; - expect(args.slice(0, -2)).toEqual([ - "api", - "graphql", - "--hostname", - "github.com", - "-f", - "owner=acme", - "-f", - "name=web", - "-F", - "number=7", - "-f", - "headRef=fork:feat/page", - ]); + expect(varsAt(0)).toEqual({ + owner: "acme", + name: "web", + number: 7, + headRef: "fork:feat/page", + }); expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); - expect(args.at(-2)).toBe("-f"); - expect(args.at(-1)).toContain(`query=${BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)}`); + expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)); }), ); @@ -4034,8 +4085,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValue( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { @@ -4064,7 +4114,7 @@ layer("GitHubPullRequestCli.layer", (it) => { } as const; yield* cli.getPullRequestBaseComparison(input); - expect(callAt(0).args.at(-1)).toContain("rateLimit { cost limit remaining resetAt }"); + expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input)); @@ -4083,8 +4133,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { @@ -4106,8 +4155,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { mergeCommitAllowed: true, @@ -4167,21 +4215,11 @@ layer("GitHubPullRequestCli.layer", (it) => { requested: true, }); - const call = callAt(0); - expect(call.args).toEqual([ - "api", - "--method", - "POST", - "--hostname", - "github.com", - "repos/acme/web/pulls/7/requested_reviewers", - "--input", - "-", - ]); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(call.stdin ?? "")).toEqual({ - reviewers: ["octocat"], - team_reviewers: ["reviewers"], + expect(callAt(0)).toMatchObject({ + kind: "rest", + method: "POST", + path: "repos/acme/web/pulls/7/requested_reviewers", + body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, }); }), ); @@ -4200,13 +4238,11 @@ layer("GitHubPullRequestCli.layer", (it) => { requested: false, }); - const call = callAt(0); - expect(call.args).toContain("DELETE"); - expect(call.args).toContain("repos/acme/web/pulls/7/requested_reviewers"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(call.stdin ?? "")).toEqual({ - reviewers: ["octocat"], - team_reviewers: [], + expect(callAt(0)).toMatchObject({ + kind: "rest", + method: "DELETE", + path: "repos/acme/web/pulls/7/requested_reviewers", + body: { reviewers: ["octocat"], team_reviewers: [] }, }); }), ); @@ -4216,8 +4252,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValue( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { assignableUsers: { @@ -4246,7 +4281,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // The people, who has been asked and who opened the pull request all hang off the same // repository object, so the menu costs one request. assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(callAt(0).args).toContain("number=7"); + expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ ["octocat", true], ["hubot", false], @@ -4269,19 +4304,12 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 1); - const call = callAt(0); - expect(call.args).toEqual([ - "api", - "--method", - "POST", - "--hostname", - "github.com", - "repos/acme/web/issues/7/labels", - "--input", - "-", - ]); - // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body. - expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] }); + expect(callAt(0)).toMatchObject({ + kind: "rest", + method: "POST", + path: "repos/acme/web/issues/7/labels", + body: { labels: ["bug", "size:XL"] }, + }); }), ); @@ -4300,9 +4328,14 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue"); - expect(callAt(0).args).toContain("DELETE"); - expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); + expect(callAt(0)).toMatchObject({ + method: "DELETE", + path: "repos/acme/web/issues/7/labels/good%20first%20issue", + }); + expect(callAt(1)).toMatchObject({ + method: "DELETE", + path: "repos/acme/web/issues/7/labels/area%2Fweb", + }); }), ); @@ -4311,7 +4344,7 @@ layer("GitHubPullRequestCli.layer", (it) => { const page = (index: number, hasNextPage: boolean) => Effect.succeed( output( - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { @@ -4343,9 +4376,11 @@ layer("GitHubPullRequestCli.layer", (it) => { assert.strictEqual(mockedExecute.mock.calls.length, 3); // The first page asks from the start; each one after it carries the cursor before it. - assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after="))); - expect(callAt(1).args).toContain("after=cursor-0"); - expect(callAt(2).args).toContain("after=cursor-1"); + expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ + null, + "cursor-0", + "cursor-1", + ]); assert.isFalse(viewed.truncated); expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ ["src/file0.ts", "viewed"], @@ -4363,8 +4398,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValue( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ + encodeJson({ data: { repository: { pullRequest: { @@ -4399,8 +4433,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute .mockReturnValueOnce( Effect.succeed( - // @effect-diagnostics-next-line preferSchemaOverJson:off - output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })), + output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); @@ -4419,14 +4452,9 @@ layer("GitHubPullRequestCli.layer", (it) => { // One request to learn the pull request's node id, one for every press together. assert.strictEqual(mockedExecute.mock.calls.length, 2); - // @effect-diagnostics-next-line preferSchemaOverJson:off - const sent = JSON.parse(callAt(1).stdin ?? "") as { - query: string; - variables: Record; - }; - expect(sent.query).toContain("f0: markFileAsViewed"); - expect(sent.query).toContain("f1: unmarkFileAsViewed"); - expect(sent.variables).toEqual({ + expect(queryAt(1)).toContain("f0: markFileAsViewed"); + expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); + expect(varsAt(1)).toEqual({ pullRequestId: "PR_1", path0: "src/a.ts", path1: "src/b.ts", @@ -4455,8 +4483,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute .mockReturnValueOnce( Effect.succeed( - // @effect-diagnostics-next-line preferSchemaOverJson:off - output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_24" } } } })), + output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), ), ) .mockReturnValue(Effect.succeed(output("{}"))); @@ -4475,11 +4502,12 @@ layer("GitHubPullRequestCli.layer", (it) => { // One lookup, then a mutation per write, every one of them addressed by the id it answered. assert.strictEqual(mockedExecute.mock.calls.length, 4); - expect(callAt(0).args).toContain("number=24"); - const idSentAt = (index: number) => - (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) - .variables.pullRequestId; - expect([idSentAt(1), idSentAt(2), idSentAt(3)]).toEqual(["PR_24", "PR_24", "PR_24"]); + expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); + expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ + "PR_24", + "PR_24", + "PR_24", + ]); }), ); @@ -4489,8 +4517,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) .mockReturnValueOnce( Effect.succeed( - // @effect-diagnostics-next-line preferSchemaOverJson:off - output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_25" } } } })), + output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); @@ -4510,10 +4537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* write(); assert.strictEqual(mockedExecute.mock.calls.length, 3); - const idSentAt = (index: number) => - (JSON.parse(callAt(index).stdin ?? "") as { variables: { pullRequestId: string } }) - .variables.pullRequestId; - expect(idSentAt(2)).toEqual("PR_25"); + expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); }), ); it.effect("keeps the pull request being ticked through, not the one looked up first", () => @@ -4525,9 +4549,10 @@ layer("GitHubPullRequestCli.layer", (it) => { const HOT = 9_000; const lookupsOf = new Map(); mockedExecute.mockImplementation((input) => { - const asked = input.args.find((arg) => arg.startsWith("number=")); - if (asked === undefined) return Effect.succeed(output("{}")); - const number = Number(asked.slice("number=".length)); + if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { + return Effect.succeed(output("{}")); + } + const number = Number(input.variables?.["number"]); lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); return Effect.succeed( output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index a860ccdc0470..d393db9fa78b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,21 +1,19 @@ import { removeAgentCredits } from "./mergeMessage.ts"; -import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; import * as Request from "effect/Request"; import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import * as Hex from "effect/encoding/Hex"; import { resolvePullRequestAuthorFilter, PositiveInt, @@ -23,6 +21,8 @@ import { type PullRequestAction, type PullRequestStackHead, type PullRequestActor, + type PullRequestComment, + type PullRequestCommit, type PullRequestFileViewed, type PullRequestInvolvement, type PullRequestListFilters, @@ -41,19 +41,28 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { ACTOR_AVATARS_GRAPHQL_QUERY, ADD_REACTION_GRAPHQL_MUTATION, - buildReviewSubmissionJson, - buildReviewerRequestJson, + buildReviewSubmission, + buildReviewerRequest, buildSetFilesViewedGraphQlMutation, decodeActorAvatarsJson, decodePullRequestActivityJson, - decodePullRequestDetailJson, + decodePullRequestCheckContextsJson, decodePullRequestCoreJson, + decodeCommitFilesJson, + pullRequestCheckContextsGraphQlQuery, + pullRequestChecksFromContexts, + pullRequestListGraphQlQuery, + pullRequestSummaryGraphQlQuery, + PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, + PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decodeWorkflowRunsJson, + type GitHubCheckContext, pullRequestCoreGraphQlQuery, type GitHubPullRequestCore, type GitHubPullRequestSummary, @@ -73,7 +82,7 @@ import { decodeReactionSubjectScopeJson, decodeReviewerCandidatesJson, decodeLabelCandidatesJson, - buildLabelRequestJson, + buildLabelRequest, LABEL_CANDIDATES_GRAPHQL_QUERY, decodeReviewDismissalsJson, decodeReviewThreadCommentsJson, @@ -83,14 +92,10 @@ import { buildPullRequestWatchFingerprintsGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, - encodeGraphQlRequestJson, pullRequestSearchGraphQlQuery, PULL_REQUEST_SEARCH_MAX_ROWS, - PULL_REQUEST_ACTIVITY_JSON_FIELDS, BASE_COMPARISON_GRAPHQL_QUERY, decodeBaseComparisonJson, - PULL_REQUEST_DETAIL_JSON_FIELDS, - PULL_REQUEST_LIST_JSON_FIELDS, PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, @@ -110,9 +115,10 @@ import { UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, VIEWER_PERMISSIONS_GRAPHQL_QUERY, decodeViewerPermissionsJson, - decodeWorkflowRunApprovalsJson, type GitHubBaseComparison, type GitHubPullRequestActivity, + type GitHubPullRequestActivityPage, + type GitHubWorkflowRunPage, type GitHubPullRequestHead, type GitHubPullRequestListItem, type GitHubPullRequestSearchItem, @@ -139,12 +145,8 @@ export class GitHubPullRequestReadError extends Schema.TaggedError> = { + all: ["OPEN", "CLOSED", "MERGED"], + open: ["OPEN"], + // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out + // locally by `matchesUnsortedListing`. + closed: ["CLOSED", "MERGED"], + merged: ["MERGED"], +}; + +interface GitHubPullRequestListPage { + readonly items: ReadonlyArray; + readonly rawCount: number; + readonly endCursor: string | null; +} + /** A search-free fallback may scan older rows for local filters, but never the whole repository. */ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; @@ -413,6 +387,9 @@ export const NODE_ID_CACHE_CAPACITY = 128; */ const REVIEW_THREAD_PAGES = 10; +/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ +const CHECK_CONTEXT_PAGES = 10; + export interface GitHubPullRequestListBatch { readonly items: ReadonlyArray; readonly truncated: boolean; @@ -960,50 +937,6 @@ function matchesFilters( ); } -function involvementArgs(input: { - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly query?: string | undefined; - /** Where to carry on from, which only a search can express. */ - readonly cursor?: ProviderListCursor | undefined; - /** - * Ask GitHub for the order the page reads its rows in. False on the fallback read, which - * cannot use search at all and takes whatever order `gh pr list` answers in. - */ - readonly sorted: boolean; - readonly filters?: PullRequestListFilters | undefined; -}): ReadonlyArray { - // `--state closed` includes merged pull requests, so the Closed tab additionally excludes - // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh` - // takes one `--search`, so the reader's text joins the qualifiers rather than replacing them. - const query = input.query?.trim() ?? ""; - // The fallback read exists because this repository's search index answered nothing, so it goes - // nowhere near search: no order, cursor or qualifiers. Its decoded rows are narrowed by state - // and involvement below, since widening either would put unrelated pull requests on the page. - const searchTerms = !input.sorted - ? [] - : [ - ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), - ...(input.state === "closed" ? ["is:unmerged"] : []), - ...(query.length === 0 ? [] : [searchPhrase(query)]), - // The instant the last slice ended on, and everything before it. Inclusive, because rows - // sharing one instant are ordinary and the caller drops the ones it has already sent — - // asking for strictly older would lose the rest of them instead. - ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), - ...filterQualifiers(input.filters, input.viewer), - // `gh pr list` answers newest-created first, which is not the order the page reads rows in - // and not an order a continuation can carry on from: a change request opened last year and - // touched this morning belongs at the top of the list and at the front of the first slice. - // Free text would otherwise come back in best-match order, which is worse again. - "sort:updated-desc", - ]; - return [ - ...(input.involvement === "authored" ? ["--author", input.viewer] : []), - ...(searchTerms.length > 0 ? ["--search", searchTerms.join(" ")] : []), - ]; -} - /** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ function matchesUnsortedListing( item: GitHubPullRequestListItem, @@ -1029,13 +962,11 @@ function matchesUnsortedListing( const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; /** - * The same listing as one GitHub search across several repositories, which is the only way to - * read a whole host in one request. + * A listing as one GitHub search, across one repository or several: the only way to read a + * whole host in one request, and the only order (`updated`) a continuation can carry on from. * - * Every narrowing `involvementArgs` hands to `gh pr list` as a flag is a qualifier here instead, - * because a search has no flags to borrow: `--author X` is `author:X`, `--state open` is - * `is:open`, and `--state closed` — which includes merged pull requests — is `is:closed - * is:unmerged`. The two belong together; a tab added to one wants adding to the other. + * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is + * `is:closed is:unmerged` here. * * Null where a repository is not `owner/name`. A name is written into the query as itself, and a * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its @@ -1073,15 +1004,6 @@ function searchQuery(input: { ].join(" "); } -/** - * The `after` a paged read carries. gh sends a JSON null only through a typed field, and an - * untyped `cursor=` would send the empty string, which GitHub refuses as a cursor rather than - * reading as "start at the beginning". - */ -function cursorVariable(cursor: string | null): readonly [string, string] { - return cursor === null ? ["-F", "cursor=null"] : ["-f", `cursor=${cursor}`]; -} - const MERGE_MESSAGE_GRAPHQL_QUERY = ` query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) { repository(owner: $owner, name: $name) { @@ -1093,6 +1015,18 @@ query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $m } }`; +/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ +const decodeRevisionRefs = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + sha: Schema.optional(Schema.String), + parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))), + base: Schema.optional(Schema.Struct({ sha: Schema.String })), + head: Schema.optional(Schema.Struct({ sha: Schema.String })), + }), + ), +); + const decodeMergeMessageResponse = Schema.decodeUnknownResult( Schema.fromJsonString( Schema.Struct({ @@ -1111,45 +1045,93 @@ const decodeMergeMessageResponse = Schema.decodeUnknownResult( const decodeMergeMessage = (raw: string) => Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest); -function actionArgs( - action: PullRequestAction, - mergeMethod: PullRequestMergeMethod | undefined, - updateMethod: PullRequestUpdateMethod | undefined, -): ReadonlyArray { - switch (action) { - case "merge": - return ["merge", `--${mergeMethod ?? "merge"}`]; - // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub - // stores the strategy with the standing instruction rather than choosing one at merge time. - case "enable-auto-merge": - return ["merge", "--auto", `--${mergeMethod ?? "merge"}`]; - case "disable-auto-merge": - return ["merge", "--disable-auto"]; - // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default. - case "update-branch": - return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])]; - case "ready": - return ["ready"]; - case "draft": - return ["ready", "--undo"]; - case "close": - return ["close"]; - case "reopen": - return ["reopen"]; - case "revert": - throw new Error("Revert requires a GraphQL mutation"); - // Handled separately because it may approve several workflow runs rather than mutate the - // pull request itself. - case "approve-workflows": - throw new Error("Workflow approval requires run discovery"); +/** What `gh pr merge` and `gh pr update-branch` read before they act. */ +const ACTION_STATE_GRAPHQL_QUERY = ` +query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + id + headRefOid + isMergeQueueEnabled + mergeStateStatus + baseRef { compare(headRef: $headRef) { behindBy } } + } } -} +}`; + +const decodeActionStateResponse = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + isMergeQueueEnabled: Schema.optional(Schema.Boolean), + mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)), + baseRef: Schema.optional( + Schema.NullOr( + Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }), + ), + ), + }), + }), + }), + }), + ), +); +const decodeActionState = (raw: string) => + Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); + +const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { + mergePullRequest(input: $input) { clientMutationId } +}`; +const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { clientMutationId } +}`; +const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { + updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } +}`; +const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { + reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } +}`; +const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { + addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } +}`; + +const GRAPHQL_MERGE_METHODS = { + merge: "MERGE", + squash: "SQUASH", + rebase: "REBASE", +} as const satisfies Record; + +/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */ +const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); + +/** The mutations that only need the pull request's node id. */ +const SIMPLE_ACTION_MUTATIONS = { + "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION, + ready: READY_FOR_REVIEW_GRAPHQL_MUTATION, + draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION, + close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION, + reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION, +} as const satisfies Partial>; /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; - const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const crypto = yield* Crypto.Crypto; + const api = yield* GitHubApi.GitHubApi; const revalidateChecks = yield* makeChecksRevalidator; const routingIdentities = new Map< string, @@ -1172,25 +1154,9 @@ export const make = Effect.gen(function* () { const unavailable = () => new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); const host = input.host.toLowerCase(); - const pinned = yield* GitHubCli.PinnedGitHubCredential; - if (pinned !== null && pinned.host !== host) return yield* unavailable(); - // Only the digest is retained. Never attach credential lookup output to an error. - const token = - pinned !== null - ? Redacted.value(pinned.token) - : (yield* github - .execute({ - cwd: input.cwd, - args: ["auth", "token", "--hostname", host], - env: { GH_DEBUG: "" }, - }) - .pipe(Effect.mapError(unavailable))).stdout.trim(); - if (!token) return yield* unavailable(); - const tokenHash = yield* crypto - .digest("SHA-256", new TextEncoder().encode(token)) - .pipe(Effect.map(Hex.encode), Effect.orDie); - const key = `${host}:${tokenHash}`; - const credential = { host, token: Redacted.make(token), credentialFingerprint: key }; + // A missing or signed-out credential keeps its own error, so the page can say which. + const { token, fingerprint: key } = yield* api.credential(host); + const credential = { host, token, credentialFingerprint: key }; // A cold page may ask several times. Wait per credential and check again after the // first verification; cancellation releases the next waiter without losing its request. return yield* Effect.acquireUseRelease( @@ -1208,21 +1174,13 @@ export const make = Effect.gen(function* () { if (cached !== undefined && now - cached.at < 10 * 60_000) return { ...credential, ...cached.value }; // Pin this read so an auth switch cannot poison its cache entry. - const response = yield* github - .execute({ - cwd: input.cwd, - args: ["api", "user", "--hostname", host], - env: { - GH_HOST: host, - GH_TOKEN: token, - GITHUB_TOKEN: token, - GH_ENTERPRISE_TOKEN: token, - GITHUB_ENTERPRISE_TOKEN: token, - GH_DEBUG: "", - }, - }) - .pipe(Effect.mapError(unavailable)); - const identity = yield* decodeRoutingIdentity(response.stdout).pipe( + const response = yield* api + .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) + .pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), + Effect.provideService(SourceControlRateLimit.CredentialScope, key), + ); + const identity = yield* decodeRoutingIdentity(response.body).pipe( Effect.mapError(unavailable), ); const value = { accountId: String(identity.id), viewer: identity.login }; @@ -1248,7 +1206,7 @@ export const make = Effect.gen(function* () { Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => use({ accountId, viewer, credentialFingerprint }).pipe( Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint), - Effect.provideService(GitHubCli.PinnedGitHubCredential, { + Effect.provideService(GitHubApi.PinnedGitHubCredential, { host, token, credentialFingerprint, @@ -1293,11 +1251,7 @@ export const make = Effect.gen(function* () { host: input.host, operation: input.operation, allowReserve: true, - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ], + variables: { owner, name, number: input.number }, query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, decode: decodePullRequestNodeIdJson, }).pipe( @@ -1333,46 +1287,35 @@ export const make = Effect.gen(function* () { host: input.host, operation: input.operation, allowReserve: true, - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ["-f", `subjectId=${input.subjectId}`], - ], + variables: { owner, name, number: input.number, subjectId: input.subjectId }, query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, decode: decodeReactionSubjectScopeJson, }); }; - // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is - // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own - // install rather than to a same-named repository on github.com. - const repositoryArgs = (input: { readonly host: string; readonly repository: string }) => [ - "--repo", - `${input.host}/${input.repository}`, - ]; - - /** - * A GraphQL mutation whose answer is not read back. `gh` exits non-zero on a GraphQL error, - * so a failed mutation is already a failed command rather than a body to inspect. - * - * The query and its variables travel over stdin as one document: a variable can carry a - * body the reader wrote, and argv is visible in process listings and echoed back inside - * process-runner failure messages. - */ + /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */ const graphql = (input: { - readonly cwd: string; readonly host: string; + readonly operation: string; readonly query: string; - readonly variables: Readonly>; - }) => - github - .execute({ - cwd: input.cwd, - args: ["api", "graphql", "--hostname", input.host, "--input", "-"], - stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }), - }) - .pipe(Effect.asVoid); + readonly variables: Readonly>; + }) => api.graphql(input).pipe(Effect.asVoid); + + const readError = (cwd: string, operation: string, cause: unknown) => + new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); + + /** Decodes an answer, reporting a failure against the read that made it. */ + const decodeWith = ( + cwd: string, + operation: string, + decode: (raw: string) => Result.Result, + raw: string, + ): Effect.Effect => { + const decoded = decode(raw.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(readError(cwd, operation, decoded.failure)); + }; /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */ const graphqlRead = (input: { @@ -1380,75 +1323,45 @@ export const make = Effect.gen(function* () { readonly host: string; readonly operation: string; readonly allowReserve?: boolean | undefined; - /** Variables as `-f` flags, for values this module composed itself. */ - readonly variables?: ReadonlyArray; - /** - * Variables carrying words the reader typed. Document and variables travel over stdin - * together, because argv is visible in process listings and is echoed back inside a - * process-runner failure message. - */ - readonly privateVariables?: Readonly>; + readonly variables?: Readonly>; readonly query: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => { - return graphQlBudget - .query( - input.host, - input.query, - input.allowReserve === true ? { allowReserve: true } : undefined, - ) + }): Effect.Effect => + api + .graphql({ + host: input.host, + operation: input.operation, + query: input.query, + ...(input.variables === undefined ? {} : { variables: input.variables }), + ...(input.allowReserve === true ? { allowReserve: true } : {}), + }) + .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); + + /** A REST read whose JSON answer is decoded the same way. */ + const restRead = (input: { + readonly cwd: string; + readonly host: string; + readonly operation: string; + readonly path: string; + readonly decode: (raw: string) => Result.Result; + }): Effect.Effect => + api + .rest({ host: input.host, operation: input.operation, path: input.path }) .pipe( - Effect.flatMap((query) => - github.execute( - input.privateVariables === undefined - ? { - cwd: input.cwd, - args: [ - "api", - "graphql", - "--hostname", - input.host, - ...(input.variables ?? []).flat(), - "-f", - `query=${query}`, - ], - } - : { - cwd: input.cwd, - args: ["api", "graphql", "--hostname", input.host, "--input", "-"], - stdin: encodeGraphQlRequestJson({ - query, - variables: input.privateVariables, - }), - }, - ), + Effect.flatMap((response) => + decodeWith(input.cwd, input.operation, input.decode, response.body), ), - Effect.tap((result) => graphQlBudget.observe(input.host, result.stdout)), - Effect.flatMap((result) => { - const decoded = input.decode(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: input.operation, - cause: decoded.failure, - }), - ); - }), ); - }; /** - * One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300 - * changed files, and still serves those files' hunks here. + * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed + * files, and still serves those files' hunks here. * * A page is a whole number of files, so each one parses on its own; the caller carries on from * `nextCursor` for as long as GitHub keeps handing pages back. * * A named commit is read from the commit endpoint, which lists the same file entries and pages - * them the same way — only wrapped in an object, which jq unwraps before they are decoded. + * them the same way, only wrapped in an object. */ const diffFilesPage = (input: { readonly cwd: string; @@ -1460,61 +1373,51 @@ export const make = Effect.gen(function* () { }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; - return github - .execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, + return api + .rest({ + host: input.host, + operation: "getPullRequestDiff", + path: input.commit === undefined ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}` : `repos/${owner}/${name}/commits/${input.commit}?${paging}`, - // An empty commit carries no `files` at all, which is a commit with nothing in it - // rather than an answer that could not be read. - ...(input.commit === undefined ? [] : ["--jq", ".files // []"]), - ], - maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, - timeoutMs: DIFF_TIMEOUT_MS, + maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, }) .pipe( - Effect.flatMap((result) => { + Effect.flatMap((response) => { // Checked before decoding: a byte-truncated response is a JSON prefix, which would // fail to parse. Nothing of this page can be shown, and an empty patch would render // as a change with no files rather than as the failure it is; slices already handed // over stay with the reader either way. - if (result.stdoutTruncated) { + if (response.truncated) { return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDiff", - cause: new Error(`Page ${input.page} of the changed files was too large to read.`), - }), - ); - } - const decoded = decodePullRequestFilesJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDiff", - cause: decoded.failure, - }), + readError( + input.cwd, + "getPullRequestDiff", + new Error(`Page ${input.page} of the changed files was too large to read.`), + ), ); } + return decodeWith( + input.cwd, + "getPullRequestDiff", + input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson, + response.body, + ); + }), + Effect.map((files) => { // Counted before decoding, so a page whose files all failed to decode still moves on // rather than pointing the reader back at the page it just read. - const morePages = decoded.success.rawCount >= DIFF_FILES_PAGE_SIZE; - return Effect.succeed({ - patch: decoded.success.patch, - truncated: decoded.success.truncated, + const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; + return { + patch: files.patch, + truncated: files.truncated, nextCursor: morePages ? String(input.page + 1) : null, - ...(decoded.success.omittedFileStats.length === 0 + ...(files.omittedFileStats.length === 0 ? {} - : { omittedFileStats: decoded.success.omittedFileStats }), - }); + : { omittedFileStats: files.omittedFileStats }), + }; }), ); }; @@ -1526,33 +1429,32 @@ export const make = Effect.gen(function* () { return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); } const { owner, name } = parseRepositorySelector(input.repository); - const refsResult = yield* github.execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, + const refsResponse = yield* api.rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + path: input.commit === undefined ? `repos/${owner}/${name}/pulls/${input.number}` : `repos/${owner}/${name}/commits/${input.commit}`, - "--jq", - input.commit === undefined - ? "[.base.sha, .head.sha] | @tsv" - : "[.parents[0].sha, .sha] | @tsv", - ], - maxOutputBytes: 1024, - timeoutMs: DIFF_TIMEOUT_MS, + timeout: DIFF_TIMEOUT, }); - // Keep a leading tab: a root commit has no parent, and jq represents that absent old - // revision as the empty field before the tab. Every file in it is new, so that is a - // usable answer whenever the caller does not need the old side. - const [baseRef, headRef, ...extraRefs] = refsResult.stdout.trimEnd().split("\t"); + const refs = decodeRevisionRefs(refsResponse.body); + // A root commit has no parent, which is an absent old revision. Every file in it is new, + // so that is a usable answer whenever the caller does not need the old side. + const baseRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.base?.sha + : (refs.value.parents?.[0]?.sha ?? "") + : undefined; + const headRef = Option.isSome(refs) + ? input.commit === undefined + ? refs.value.head?.sha + : refs.value.sha + : undefined; const rootCommitNewFile = input.commit !== undefined && input.changeType === "new" && baseRef === ""; if ( - refsResult.stdoutTruncated || - !headRef || - extraRefs.length > 0 || + headRef === undefined || (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) || !isCommitSha(headRef) ) { @@ -1565,43 +1467,38 @@ export const make = Effect.gen(function* () { } const readFile = (revision: string, filePath: string) => - github - .execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - "--header", - "Accept: application/vnd.github.raw+json", - `repos/${owner}/${name}/contents/${filePath - .split("/") - .map(encodeURIComponent) - .join("/")}?ref=${encodeURIComponent(revision)}`, - ], - maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, - timeoutMs: DIFF_TIMEOUT_MS, + api + .rest({ + host: input.host, + operation: "getPullRequestDiffFileContents", + accept: "application/vnd.github.raw+json", + path: `repos/${owner}/${name}/contents/${filePath + .split("/") + .map(encodeURIComponent) + .join("/")}?ref=${encodeURIComponent(revision)}`, + maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, }) .pipe( - Effect.flatMap((result) => - result.stdoutTruncated || - result.stdout.includes("\0") || - result.stdoutInvalidUtf8 === true + Effect.flatMap((response) => + response.truncated || response.body.includes("\0") || response.invalidUtf8 ? Effect.fail( new GitHubDiffFileContentsUnavailableError({ command: "gh", cwd: input.cwd, path: filePath, - reason: result.stdoutTruncated ? "oversized" : "binary", + reason: response.truncated ? "oversized" : "binary", }), ) - : Effect.succeed(result.stdout), + : Effect.succeed(response.body), ), ); const [oldContents, newContents] = yield* Effect.all( [ - input.changeType === "new" ? Effect.succeed("") : readFile(baseRef, input.oldPath), + input.changeType === "new" + ? Effect.succeed("") + : readFile(baseRef ?? "", input.oldPath), input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath), ], { concurrency: 2 }, @@ -1609,262 +1506,266 @@ export const make = Effect.gen(function* () { return { oldContents, newContents }; }); - const readLegacyDetail = ( + /** + * Every check context of the head commit, a page at a time, for a rollup the detail read cut at + * its first hundred. Each page names the head it read, so a push mid-walk fails rather than + * mixing two revisions' checks. + */ + const readAllCheckContexts = ( input: Parameters[0], + allowReserve: boolean, ) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDetail", - cause: decoded.failure, - }), - ); - }), - ); + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const contexts: GitHubCheckContext[] = []; + let headSha: string | null = null; + let after: string | null = null; + for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { + const read: { + readonly headSha: string; + readonly contexts: ReadonlyArray; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }); + if (headSha !== null && read.headSha !== headSha) { + return yield* readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ); + } + headSha = read.headSha; + contexts.push(...read.contexts); + after = read.nextCursor; + if (after === null) break; + } + return { headSha, contexts, truncated: after !== null }; + }); const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return GitHubCli.AllowGitHubReserve.pipe( + return AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => graphqlRead({ allowReserve, cwd: input.cwd, host: input.host, operation: "getPullRequestDetail", - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ["-f", `headRef=refs/pull/${input.number}/head`], - ], + variables: { + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + }, query: pullRequestCoreGraphQlQuery(input.host), decode: decodePullRequestCoreJson, - }), - ), - // gh already pages check contexts. Keep its complete, deduplicated result for - // large check suites instead of letting the first 100 checks imply success. - Effect.filterOrElse( - (core) => !core.checksTruncated, - (core) => - readLegacyDetail(input).pipe( - Effect.filterOrFail( - (detail) => detail.headSha === core.headSha, - () => - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDetail", - cause: new Error("Pull request head changed while reading checks."), - }), - ), - Effect.map((detail) => ({ - ...core, - checks: detail.checks, - checksState: detail.checksState, - checksTruncated: false, - })), + }).pipe( + // Past a hundred checks the first page would let the rest imply success, so the whole + // rollup is walked instead. + Effect.filterOrElse( + (core) => !core.checksTruncated, + (core) => + readAllCheckContexts(input, allowReserve).pipe( + Effect.filterOrFail( + (all) => all.headSha === core.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + Effect.map((all) => ({ + ...core, + ...pullRequestChecksFromContexts(all.contexts), + checksTruncated: all.truncated, + })), + ), ), + ), ), ); }; const workflowApprovalLimit = 1_000; - const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1); const workflowApprovalReadError = (cwd: string, cause: unknown) => - new GitHubPullRequestReadError({ - command: "gh", - cwd, - operation: "listWorkflowRunsRequiringApproval", - cause, + readError(cwd, "listWorkflowRunsRequiringApproval", cause); + + /** Every open pull request whose head branch carries this name, up to one past the limit. */ + const listHeadsByBranch = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headBranch: string; + }) => + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + const heads: GitHubPullRequestHead[] = []; + let after: string | null = null; + do { + const page: { + readonly heads: ReadonlyArray; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + variables: { owner, name, head: input.headBranch, after }, + query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decode: decodePullRequestHeadsJson, + }); + heads.push(...page.heads); + after = page.nextCursor; + } while (after !== null && heads.length <= workflowApprovalLimit); + return heads; + }); + + /** + * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is + * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. + */ + const listActionRequiredRuns = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly headSha: string; + readonly headBranch: string; + }) => + Effect.gen(function* () { + // The checks revalidator may have just confirmed every run of this head as current. + const known = yield* KnownWorkflowRuns; + if (known !== null && known.headSha === input.headSha) { + return known.runs.flatMap((run) => + // A run waiting on a maintainer reports `completed` with an `action_required` + // conclusion; the `status=action_required` query matches either. + (run.conclusion === "action_required" || run.status === "action_required") && + run.head_branch === input.headBranch + ? [ + { + id: run.id, + name: run.name?.trim() || `Workflow run ${run.id}`, + url: run.html_url?.trim() || null, + }, + ] + : [], + ); + } + const { owner, name } = parseRepositorySelector(input.repository); + const runs: GitHubWorkflowRunApproval[] = []; + for (let page = 1; runs.length <= workflowApprovalLimit; page++) { + const read: GitHubWorkflowRunPage = yield* restRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`, + decode: decodeWorkflowRunsJson, + }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error))); + runs.push(...read.runs); + if (read.rawCount < 100) break; + } + return runs; }); + const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = (input) => Effect.all( [ - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - ...repositoryArgs(input), - "--state", - "open", - "--head", - input.headBranch, - "--limit", - workflowApprovalProbeLimit, - "--json", - "number,headRefOid,isCrossRepository,headRepositoryOwner", - ], - }) - .pipe( - Effect.flatMap( - ( - result, - ): Effect.Effect< - GitHubPullRequestHead, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const decoded = decodePullRequestHeadsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); - } - const exactHeads = decoded.success.filter( - (pullRequest) => - pullRequest.headSha === input.headSha && - pullRequest.isCrossRepository === true && - pullRequest.headRepositoryOwner?.toLowerCase() === - input.headRepositoryOwner.toLowerCase(), + listHeadsByBranch(input).pipe( + Effect.flatMap( + ( + heads, + ): Effect.Effect< + GitHubPullRequestHead, + GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError + > => { + const exactHeads = heads.filter( + (pullRequest) => + pullRequest.headSha === input.headSha && + pullRequest.isCrossRepository === true && + pullRequest.headRepositoryOwner?.toLowerCase() === + input.headRepositoryOwner.toLowerCase(), + ); + if (heads.length > workflowApprovalLimit) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "head-list-truncated", + observedCount: heads.length, + limit: workflowApprovalLimit, + }), ); - if (decoded.success.length > workflowApprovalLimit) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "head-list-truncated", - observedCount: decoded.success.length, - limit: workflowApprovalLimit, - }), - ); - } - if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { - return Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "head-not-unique", - observedCount: exactHeads.length, - limit: workflowApprovalLimit, - }), - ); - } - return Effect.succeed(exactHeads[0]); - }, - ), + } + if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { + return Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "head-not-unique", + observedCount: exactHeads.length, + limit: workflowApprovalLimit, + }), + ); + } + return Effect.succeed(exactHeads[0]); + }, ), - github - .execute({ - cwd: input.cwd, - args: [ - "run", - "list", - ...repositoryArgs(input), - "--commit", - input.headSha, - "--branch", - input.headBranch, - "--event", - "pull_request", - "--status", - "action_required", - "--limit", - workflowApprovalProbeLimit, - "--json", - "databaseId,workflowName,url", - ], - }) - .pipe( - Effect.flatMap( - ( - result, - ): Effect.Effect< - ReadonlyArray, - GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError - > => { - const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure)); - } - return decoded.success.length > workflowApprovalLimit - ? Effect.fail( - new GitHubWorkflowApprovalRefusedError({ - command: "gh", - cwd: input.cwd, - number: input.number, - reason: "run-list-truncated", - observedCount: decoded.success.length, - limit: workflowApprovalLimit, - }), - ) - : Effect.succeed(decoded.success); - }, - ), + ), + listActionRequiredRuns(input).pipe( + Effect.flatMap((runs) => + runs.length > workflowApprovalLimit + ? Effect.fail( + new GitHubWorkflowApprovalRefusedError({ + command: "gh", + cwd: input.cwd, + number: input.number, + reason: "run-list-truncated", + observedCount: runs.length, + limit: workflowApprovalLimit, + }), + ) + : Effect.succeed(runs), ), + ), ], { concurrency: 2 }, ).pipe(Effect.map(([, runs]) => runs)); - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - const viewPullRequestSummary = (input: PullRequestSummaryRead) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ); + /** One pull request's summary, through the same aliased query the batch uses. */ + const viewPullRequestSummary = (input: PullRequestSummaryRead) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestSummary", + variables: { owner, name, number: input.number }, + query: pullRequestSummaryGraphQlQuery(input.host === "github.com"), + decode: decodePullRequestSummariesJson, + }).pipe( + Effect.flatMap((summaries) => { + const summary = summaries.get(0); + return summary === undefined + ? Effect.fail( + readError( + input.cwd, + "getPullRequestSummary", + new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`), + ), + ) + : Effect.succeed(summary); + }), + ); + }; /** * Summaries asked for together, on one host under one credential, share aliased GraphQL reads @@ -1876,7 +1777,7 @@ export const make = Effect.gen(function* () { key: ({ request, context }) => JSON.stringify([ request.host.toLowerCase(), - Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? null, Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), ]), @@ -1956,7 +1857,7 @@ export const make = Effect.gen(function* () { key: ({ request, context }) => JSON.stringify([ request.host.toLowerCase(), - Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? null, Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), ]), @@ -2010,70 +1911,88 @@ export const make = Effect.gen(function* () { listPullRequests: (input) => { const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); + const { owner, name } = parseRepositorySelector(input.repository); + const query = searchQuery({ ...input, repositories: [input.repository] }); + if (query === null) { + return Effect.fail( + new GitHubRepositorySelectorError({ + command: "gh", + cwd: input.cwd, + operation: "listPullRequests", + }), + ); + } + /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */ + const collect = ( + rows: number, + page: ( + after: string | null, + rows: number, + ) => Effect.Effect, + ) => + Effect.gen(function* () { + const items: GitHubPullRequestListItem[] = []; + let rawCount = 0; + let after: string | null = null; + do { + const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); + items.push(...read.items); + rawCount += read.rawCount; + after = read.endCursor; + } while (after !== null && rawCount < rows); + return { items, rawCount }; + }); const read = ( continues: boolean, requestedRows = input.limit + 1, ): Effect.Effect => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - ...repositoryArgs(input), - ...involvementArgs({ ...input, sorted: continues }), - "--state", - input.state, - "--limit", - // One extra row reveals that the repository has more than the page shows. - String(requestedRows), - "--json", - PULL_REQUEST_LIST_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed({ items: [], truncated: false, continues }); - } - const decoded = decodePullRequestListJson(raw); - if (Result.isSuccess(decoded)) { - const items = continues - ? decoded.success.items - : decoded.success.items.filter((item) => matchesUnsortedListing(item, input)); - if ( - !continues && - items.length < input.limit && - decoded.success.rawCount >= requestedRows && - requestedRows < fallbackMaxRows - ) { - const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); - if (nextRows > requestedRows) return read(false, nextRows); - } - return Effect.succeed({ - items: items.slice(0, input.limit), - // One row over the page size is the probe for a next page, and it is - // counted before decoding: a skipped malformed row must not end paging. - truncated: continues - ? decoded.success.rawCount > input.limit - : items.length > input.limit || decoded.success.rawCount >= requestedRows, - continues, - }); - } - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "listPullRequests", - cause: decoded.failure, - }), - ); - }), - ); + collect(requestedRows, (after, rows) => + continues + ? graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequests", + // The reader's own words are in the query. + variables: { q: query, after }, + query: pullRequestSearchGraphQlQuery(rows, false, true), + decode: decodePullRequestSearchJson, + }) + : graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequests", + variables: { owner, name, states: LIST_STATES[input.state], after }, + query: pullRequestListGraphQlQuery(rows), + decode: decodePullRequestListJson, + }), + ).pipe( + Effect.flatMap(({ items: rawItems, rawCount }) => { + const items = continues + ? rawItems + : rawItems.filter((item) => matchesUnsortedListing(item, input)); + if ( + !continues && + items.length < input.limit && + rawCount >= requestedRows && + requestedRows < fallbackMaxRows + ) { + const nextRows = Math.min(requestedRows * 2, fallbackMaxRows); + if (nextRows > requestedRows) return read(false, nextRows); + } + return Effect.succeed({ + items: items.slice(0, input.limit), + // One row over the page size is the probe for a next page, and it is + // counted before decoding: a skipped malformed row must not end paging. + truncated: continues + ? rawCount > input.limit + : items.length > input.limit || rawCount >= requestedRows, + continues, + }); + }), + ); // GitHub does not index every repository for search, and one it will not search answers - // with no rows rather than with an error — so an empty listing is read again the way `gh` - // lists without one. Those rows come back newest-created first, an order no `updated:` + // with no rows rather than with an error — so an empty listing is read again from the + // repository's own list. Those rows come back newest-created first, an order no `updated:` // qualifier can carry on from, so that page says it cannot be continued and the reader // reaches the rest of it by asking for a larger page, as every listing used to. // @@ -2157,8 +2076,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "searchPullRequests", - // The reader's own words are in the query, so it travels over stdin rather than in argv. - privateVariables: { q: query }, + variables: { q: query, after: null }, query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), decode: decodePullRequestSearchJson, }).pipe( @@ -2217,11 +2135,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "getPullRequestPreview", - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ], + variables: { owner, name, number: input.number }, query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, decode: decodePullRequestPreviewJson, }); @@ -2230,65 +2144,30 @@ export const make = Effect.gen(function* () { getPullRequestStack: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return github - .execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - `repos/${owner}/${name}/stacks?pull_request=${input.number}`, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestStacksJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestStack", - cause: decoded.failure, - }), - ); - }), - // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. - Effect.flatMap((stack) => { - if (!input.includeDetails || stack === null) return Effect.succeed(stack); - return github - .execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - `repos/${owner}/${name}/stacks/${stack.number}`, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestStack", - cause: decoded.failure, - }), - ); - }), - ); - }), - // Hosts without the stacks preview return 404. Other failures must preserve the - // previously synced stack and let the caller retry. - Effect.catchTags({ - GitHubPullRequestNotFoundError: () => Effect.succeed(null), - }), - ); + return restRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestStack", + path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + decode: decodePullRequestStacksJson, + }).pipe( + // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return restRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestStack", + path: `repos/${owner}/${name}/stacks/${stack.number}`, + decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), + }); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubApiNotFoundError: () => Effect.succeed(null), + }), + ); }, getPullRequestBaseComparison: (input) => { @@ -2298,45 +2177,59 @@ export const make = Effect.gen(function* () { host: input.host, operation: "getPullRequestBaseComparison", ...(input.allowReserve === true ? { allowReserve: true } : {}), - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ["-f", `headRef=${input.headRef}`], - ], + variables: { owner, name, number: input.number, headRef: input.headRef }, query: BASE_COMPARISON_GRAPHQL_QUERY, decode: decodeBaseComparisonJson, }); }, getPullRequestActivity: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_ACTIVITY_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestActivityJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestActivity", - cause: decoded.failure, - }), - ); - }), - ), + Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + let author: PullRequestActor | null = null; + let commits: ReadonlyArray = []; + const remarks: PullRequestComment[] = []; + let commentsAfter: string | null = null; + let reviewsAfter: string | null = null; + let withComments = true; + let withReviews = true; + // Both remark lists page on their own; a page asks only for the ones with more to give. + for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { + const read: GitHubPullRequestActivityPage = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestActivity", + variables: { + owner, + name, + number: input.number, + head: page === 0, + withComments, + commentsAfter, + withReviews, + reviewsAfter, + }, + query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, + decode: decodePullRequestActivityJson, + }); + if (page === 0) { + author = read.author ?? null; + commits = read.commits ?? []; + } + remarks.push(...read.remarks); + commentsAfter = read.nextCommentsCursor; + reviewsAfter = read.nextReviewsCursor; + withComments = withComments && commentsAfter !== null; + withReviews = withReviews && reviewsAfter !== null; + } + return { + author, + comments: remarks.toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commits, + } satisfies GitHubPullRequestActivity; + }), getPullRequestDiff: (input) => { const filesPage = (page: number) => @@ -2363,31 +2256,34 @@ export const make = Effect.gen(function* () { if (input.commit !== undefined) { return filesPage(1); } - return github - .execute({ - cwd: input.cwd, - args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"], - maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, - timeoutMs: DIFF_TIMEOUT_MS, + const { owner, name } = parseRepositorySelector(input.repository); + return api + .rest({ + host: input.host, + operation: "getPullRequestDiff", + path: `repos/${owner}/${name}/pulls/${input.number}`, + accept: "application/vnd.github.diff", + maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, + timeout: DIFF_TIMEOUT, }) .pipe( - Effect.flatMap((result) => + Effect.flatMap((response) => // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor // something the reader can carry on from. The files API can serve the same change a // whole number of files at a time, so an oversized patch takes that road as well. - result.stdoutTruncated + response.truncated ? filesPage(1) : // One read served the whole patch, so there is no next slice to ask for. - Effect.succeed({ patch: result.stdout, truncated: false, nextCursor: null }), + Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), ), // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read // from the files API instead, a page per call. Only once the direct read has failed: a // pull request GitHub will serve a diff for must not pay for a second request. A // fallback that fails too reports the original refusal, which is the one that explains - // the page. Narrowed to a command that ran and was refused: a missing `gh` or a - // signed-out one fails the same way for every request. + // the page. Narrowed to a request GitHub answered and refused: a missing credential or + // a rate limit fails the same way for every request. Effect.catchTags({ - GitHubCliCommandError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), + GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), }), ); }, @@ -2400,13 +2296,13 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "getReviewThreadComments", - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ["-f", `threadId=${input.threadId}`], - cursorVariable(input.cursor), - ], + variables: { + owner, + name, + number: input.number, + threadId: input.threadId, + cursor: input.cursor, + }, query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, decode: decodeReviewThreadCommentsJson, }).pipe( @@ -2434,12 +2330,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "listReviewThreadComments", - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - cursorVariable(cursor), - ], + variables: { owner, name, number: input.number, cursor }, query: REVIEW_THREADS_GRAPHQL_QUERY, decode: decodeReviewThreadsJson, }); @@ -2496,12 +2387,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "listReviewThreadComments", - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ["-f", `cursor=${dismissalCursor}`], - ], + variables: { owner, name, number: input.number, cursor: dismissalCursor }, query: REVIEW_DISMISSALS_GRAPHQL_QUERY, decode: decodeReviewDismissalsJson, }); @@ -2547,7 +2433,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "listActorAvatars", - variables: input.ids.map((id) => ["-f", `ids[]=${id}`]), + variables: { ids: input.ids }, query: ACTOR_AVATARS_GRAPHQL_QUERY, decode: decodeActorAvatarsJson, }); @@ -2560,11 +2446,7 @@ export const make = Effect.gen(function* () { host: input.host, operation: "getViewerAccess", ...(input.allowReserve === true ? { allowReserve: true } : {}), - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ], + variables: { owner, name, number: input.number }, query: VIEWER_PERMISSIONS_GRAPHQL_QUERY, decode: decodeViewerPermissionsJson, }); @@ -2577,11 +2459,7 @@ export const make = Effect.gen(function* () { host: input.host, operation: "listReviewerCandidates", allowReserve: true, - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ], + variables: { owner, name, number: input.number }, query: REVIEWER_CANDIDATES_GRAPHQL_QUERY, decode: decodeReviewerCandidatesJson, }); @@ -2589,24 +2467,15 @@ export const make = Effect.gen(function* () { setReviewerRequest: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return github - .execute({ - cwd: input.cwd, - // Posting to a login GitHub has already been asked about is what a re-request is, so - // there is nothing to say here about somebody who has reviewed once already. The body - // travels over stdin for the reason every other one does: argv is visible in process - // listings and echoed back inside process-runner failure messages. - args: [ - "api", - "--method", - input.requested ? "POST" : "DELETE", - "--hostname", - input.host, - `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, - "--input", - "-", - ], - stdin: buildReviewerRequestJson(input.reviewers), + // Posting to a login GitHub has already been asked about is what a re-request is, so + // there is nothing to say here about somebody who has reviewed once already. + return api + .rest({ + host: input.host, + operation: "setReviewerRequest", + method: input.requested ? "POST" : "DELETE", + path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`, + body: buildReviewerRequest(input.reviewers), }) .pipe(Effect.asVoid); }, @@ -2618,11 +2487,7 @@ export const make = Effect.gen(function* () { host: input.host, operation: "listLabelCandidates", allowReserve: true, - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ], + variables: { owner, name, number: input.number }, query: LABEL_CANDIDATES_GRAPHQL_QUERY, decode: decodeLabelCandidatesJson, }); @@ -2635,27 +2500,24 @@ export const make = Effect.gen(function* () { // path. The name goes into the path encoded, because a label may carry a space or a slash. const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; if (input.applied) { - return github - .execute({ - cwd: input.cwd, - args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"], - stdin: buildLabelRequestJson(input.labels), + return api + .rest({ + host: input.host, + operation: "setLabels", + method: "POST", + path: issue, + body: buildLabelRequest(input.labels), }) .pipe(Effect.asVoid); } return Effect.forEach( input.labels, (label) => - github.execute({ - cwd: input.cwd, - args: [ - "api", - "--method", - "DELETE", - "--hostname", - input.host, - `${issue}/${encodeURIComponent(label)}`, - ], + api.rest({ + host: input.host, + operation: "setLabels", + method: "DELETE", + path: `${issue}/${encodeURIComponent(label)}`, }), { concurrency: 1, discard: true }, ); @@ -2664,14 +2526,14 @@ export const make = Effect.gen(function* () { runPullRequestAction: (input) => { if (input.stackNumber !== undefined) return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( - Effect.provideService(GitHubCli.GitHubCli, github), + Effect.provideService(GitHubApi.GitHubApi, api), ); if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( Effect.flatMap((pullRequestId) => graphql({ - cwd: input.cwd, host: input.host, + operation: "revertPullRequest", query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION, variables: { pullRequestId }, }), @@ -2742,18 +2604,12 @@ export const make = Effect.gen(function* () { }), Effect.flatMap((currentRuns) => currentRuns.some((current) => current.id === run.id) - ? github - .execute({ - cwd: input.cwd, - args: [ - "api", - "--method", - "POST", - "--hostname", - input.host, - `repos/${owner}/${name}/actions/runs/${run.id}/approve`, - "--silent", - ], + ? api + .rest({ + host: input.host, + operation: "approveWorkflowRun", + method: "POST", + path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`, }) .pipe(Effect.asVoid) : Effect.void, @@ -2766,32 +2622,67 @@ export const make = Effect.gen(function* () { }), ); } - const [subcommand, ...flags] = actionArgs( - input.action, - input.mergeMethod, - input.updateMethod, - ); + const action = input.action; + if (action in SIMPLE_ACTION_MUTATIONS) { + return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + host: input.host, + operation: "runPullRequestAction", + query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS], + variables: { pullRequestId }, + }), + ), + ); + } return Effect.gen(function* () { + const { owner, name } = parseRepositorySelector(input.repository); + // Read fresh rather than from the node id cache: merging and updating act on the head + // as it stands now, and the merge queue decides which mutation a merge is. + const state = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "runPullRequestAction", + allowReserve: true, + query: ACTION_STATE_GRAPHQL_QUERY, + variables: { + owner, + name, + number: input.number, + headRef: `refs/pull/${input.number}/head`, + }, + decode: decodeActionState, + }); + if (action === "update-branch") { + // Already current is done, the way `gh pr update-branch` reports it. + if (state.baseRef?.compare?.behindBy === 0) return; + // GitHub updates with a merge commit unless asked to rebase, which is its own default. + return yield* graphql({ + host: input.host, + operation: "runPullRequestAction", + query: UPDATE_BRANCH_GRAPHQL_MUTATION, + variables: { + pullRequestId: state.id, + expectedHeadOid: state.headRefOid, + updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE", + }, + }); + } let body: string | undefined; let expectedHead: string | undefined; - if ( - input.removeAgentCreditsOnMerge === true && - (input.action === "merge" || input.action === "enable-auto-merge") && - input.mergeMethod !== "rebase" - ) { - const { owner, name } = parseRepositorySelector(input.repository); + if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") { const message = yield* graphqlRead({ cwd: input.cwd, host: input.host, operation: "runPullRequestAction", allowReserve: true, query: MERGE_MESSAGE_GRAPHQL_QUERY, - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ["-f", `method=${input.mergeMethod === "squash" ? "SQUASH" : "MERGE"}`], - ], + variables: { + owner, + name, + number: input.number, + method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE", + }, decode: decodeMergeMessage, }); // GitHub's merge queue chooses its own message and ignores custom text. @@ -2803,59 +2694,53 @@ export const make = Effect.gen(function* () { } } } - yield* github.execute({ - cwd: input.cwd, - args: [ - "pr", - subcommand!, - String(input.number), - ...repositoryArgs(input), - ...flags, - ...(expectedHead === undefined ? [] : ["--match-head-commit", expectedHead]), - ...(body === undefined ? [] : ["--body-file", "-"]), - ], - ...(body === undefined ? {} : { stdin: body }), + // A merge queue takes a pull request through auto-merge rather than a direct merge, and + // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. + // GitHub stores the strategy with a standing instruction rather than choosing one at + // merge time, so arming still names it. + const auto = + state.isMergeQueueEnabled === true || + (action === "enable-auto-merge" && + !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); + yield* graphql({ + host: input.host, + operation: "runPullRequestAction", + query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION, + variables: { + input: { + pullRequestId: state.id, + mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], + ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), + ...(body === undefined ? {} : { commitBody: body }), + }, + }, }); }); }, commentOnPullRequest: (input) => - github - .execute({ - cwd: input.cwd, - // The body travels over stdin: argv is visible in process listings and is echoed - // back inside process-runner failure messages. - args: [ - "pr", - "comment", - String(input.number), - ...repositoryArgs(input), - "--body-file", - "-", - ], - stdin: input.body, - }) - .pipe(Effect.asVoid), + pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe( + Effect.flatMap((subjectId) => + graphql({ + host: input.host, + operation: "commentOnPullRequest", + query: ADD_COMMENT_GRAPHQL_MUTATION, + variables: { subjectId, body: input.body }, + }), + ), + ), submitReview: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return github - .execute({ - cwd: input.cwd, - // The whole review is one request, so nothing is visible to anyone else until the - // verdict is sent. The payload travels over stdin for the same reason a comment - // body does: argv is visible in process listings and echoed back in failures. - args: [ - "api", - "--method", - "POST", - "--hostname", - input.host, - `repos/${owner}/${name}/pulls/${input.number}/reviews`, - "--input", - "-", - ], - stdin: buildReviewSubmissionJson({ + // The whole review is one request, so nothing is visible to anyone else until the verdict + // is sent. + return api + .rest({ + host: input.host, + operation: "submitReview", + method: "POST", + path: `repos/${owner}/${name}/pulls/${input.number}/reviews`, + body: buildReviewSubmission({ verdict: input.verdict, body: input.body, comments: input.comments, @@ -2866,8 +2751,8 @@ export const make = Effect.gen(function* () { replyToReviewThread: (input) => graphql({ - cwd: input.cwd, host: input.host, + operation: "replyToReviewThread", query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, variables: { threadId: input.threadId, body: input.body }, }), @@ -2883,14 +2768,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "getPullRequestFilesViewed", - variables: [ - ["-f", `owner=${owner}`], - ["-f", `name=${name}`], - ["-F", `number=${input.number}`], - ...(after === null - ? [] - : ([["-f", `after=${after}`]] as ReadonlyArray)), - ], + variables: { owner, name, number: input.number, after }, query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, decode: decodePullRequestFilesViewedJson, }).pipe( @@ -2913,8 +2791,8 @@ export const make = Effect.gen(function* () { return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( Effect.flatMap((pullRequestId) => graphql({ - cwd: input.cwd, host: input.host, + operation: "setPullRequestFilesViewed", query: mutation.query, variables: { pullRequestId, ...mutation.variables }, }), @@ -2924,8 +2802,8 @@ export const make = Effect.gen(function* () { setReviewThreadResolution: (input) => graphql({ - cwd: input.cwd, host: input.host, + operation: "setReviewThreadResolution", query: input.resolved ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, @@ -2957,8 +2835,8 @@ export const make = Effect.gen(function* () { return subjectId.pipe( Effect.flatMap((subjectId) => graphql({ - cwd: input.cwd, host: input.host, + operation: "setReaction", query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, variables: { subjectId, content: gitHubReactionContent(input.content) }, }), @@ -2970,8 +2848,8 @@ export const make = Effect.gen(function* () { pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( Effect.flatMap((pullRequestId) => graphql({ - cwd: input.cwd, host: input.host, + operation: "updatePullRequest", query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, // A field the caller did not name is left out of the request entirely, so GitHub // keeps the words that are there rather than being asked for an empty one. @@ -3006,8 +2884,8 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((commentId) => graphql({ - cwd: input.cwd, host: input.host, + operation: "updateComment", query: input.kind === "issue-comment" ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 9c9e3bb47648..6905b3826880 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -6,7 +6,7 @@ import * as Result from "effect/Result"; import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -331,7 +331,7 @@ describe("gitHubViewerPermissions", () => { number: 7, }) .pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, { + Effect.provideService(GitHubApi.PinnedGitHubCredential, { host: "github.com", token: Redacted.make("credential"), credentialFingerprint: fingerprint, @@ -378,7 +378,7 @@ describe("gitHubViewerPermissions", () => { commits: [], }).pipe( Effect.flatMap((detail) => - GitHubCli.PinnedGitHubCredential.pipe( + GitHubApi.PinnedGitHubCredential.pipe( Effect.map((credential) => ({ ...detail, viewerAccess: { @@ -672,10 +672,9 @@ it.effect("propagates workflow discovery rate limits", () => getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), listWorkflowRunsRequiringApproval: () => Effect.fail( - new GitHubCli.GitHubCliRateLimitError({ - command: "gh", - cwd: "/w", - cause: new Error("rate limited"), + new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "listWorkflowRunsRequiringApproval", }), ), getViewerAccess: () => diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 567dd2925f5c..940b04715c84 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -101,22 +101,28 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest }; } -/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ export function gitHubProviderFailure( error: GitHubPullRequestCli.GitHubPullRequestCliError, ): PullRequestProviderFailure { - if (error._tag === "GitHubCliUnavailableError") return { reason: "missing-tool" }; - if (error._tag === "GitHubCliAuthenticationError") return { reason: "unauthenticated" }; - if (error._tag === "GitHubCliRateLimitError") - return { - reason: "rate-limited", - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }; - if (error._tag === "SourceControlRateLimitPausedError") { - return { reason: "rate-limited", retryAt: error.retryAt }; + switch (error._tag) { + case "GitHubCliMissingError": + return { reason: "missing-tool" }; + case "GitHubNotSignedInError": + case "GitHubApiAuthenticationError": + return { reason: "unauthenticated" }; + case "GitHubApiRateLimitError": + return { + reason: "rate-limited", + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }; + case "SourceControlRateLimitPausedError": + return { reason: "rate-limited", retryAt: error.retryAt }; + case "GitHubApiNotFoundError": + return { reason: "not-found" }; + default: + return { reason: "failed" }; } - if (error._tag === "GitHubPullRequestNotFoundError") return { reason: "not-found" }; - return { reason: "failed" }; } /** @@ -196,7 +202,7 @@ export const make = Effect.gen(function* () { provider: "github", operation, ...gitHubProviderFailure(error), - detail: error.detail, + detail: error.message, cause: error, }); @@ -224,7 +230,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.matchEffect({ onFailure: (error) => - error._tag === "GitHubCliRateLimitError" || + error._tag === "GitHubApiRateLimitError" || error._tag === "SourceControlRateLimitPausedError" ? Effect.fail(error) : Effect.succeed({ diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 5f837acc82ac..873cc32a2c7f 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -2,7 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import { azureDevOpsProviderFailure } from "./AzureDevOpsPullRequestProvider.ts"; @@ -15,7 +15,7 @@ const cause = new Error("redacted provider failure"); it("classifies rate limits from every pull-request provider", () => { assert.deepStrictEqual( gitHubProviderFailure( - new GitHubCli.GitHubCliRateLimitError({ command: "gh", cwd: "/repo", cause }), + new GitHubApi.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }), ), { reason: "rate-limited" }, ); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 9e8727ed7a77..60de91d0e99a 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -5,8 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; @@ -60,8 +62,14 @@ export const make = Effect.map( export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( GitHubPullRequestCli.layer.pipe( - Layer.provide(GitHubCli.layer), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide( + GitHubApi.layer.pipe( + Layer.provide(GitHubCredentials.layer), + // The same layers GitHubCli merges, so both share one budget and one pause per host. + Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(SourceControlRateLimit.layer), + ), + ), ), ), Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.test.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.test.ts index 7ef2acebad94..79048b1d7453 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.test.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.test.ts @@ -4,11 +4,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Redacted from "effect/Redacted"; -import { ChildProcessSpawner } from "effect/process"; import type { PullRequestCheck } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import { makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -27,31 +26,46 @@ it.effect( let changed = ""; let reads = 0; const requests: string[] = []; + const known: Array = []; const revalidate = yield* makeChecksRevalidator.pipe( Effect.provide( - Layer.mock(GitHubCli.GitHubCli)({ - execute: ({ args }) => + Layer.mock(GitHubApi.GitHubApi)({ + rest: ({ path, ifNoneMatch }) => Effect.sync(() => { - const endpoint = args[1]!; - requests.push(endpoint); - const head = endpoint.endsWith("/pulls/1"); + requests.push(path); + const head = path.endsWith("/pulls/1"); const modified = - !args.includes("-H") || (endpoint.includes(changed) && changed !== ""); - const next = endpoint.includes("check-runs") && endpoint.endsWith("page=1"); + ifNoneMatch === undefined || (path.includes(changed) && changed !== ""); + const next = path.includes("check-runs") && path.endsWith("page=1"); + const runs = path.includes("/actions/runs"); return { - exitCode: ChildProcessSpawner.ExitCode(modified ? 0 : 1), - stdout: modified - ? `HTTP/2.0 200 OK\r\nEtag: "${sha}-${changed}"\r\n${next ? 'Link: ; rel="next"\r\n' : ""}\r\n${head ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 2 }) : ""}` - : "HTTP/2.0 304 Not Modified\r\n\r\n", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, + status: modified ? 200 : 304, + headers: modified + ? { + etag: `"${sha}-${changed}"`, + ...(next ? { link: '; rel="next"' } : {}), + } + : {}, + body: !modified + ? "" + : head + ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } }) + : runs + ? encodeJson({ + workflow_runs: [ + { id: 9, status: "completed", conclusion: "action_required" }, + ], + }) + : "{}", + truncated: false, + invalidUtf8: false, }; }), }), ), ); - const read = Effect.sync(() => { + const read = Effect.gen(function* () { + known.push(yield* KnownWorkflowRuns); reads++; return { state: "open" as const, @@ -64,10 +78,15 @@ it.effect( }); const poll = (identity = credential) => revalidate(reference, read).pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, identity), + Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), ); yield* poll(); expect(reads).toBe(1); + // The fork's runs were just confirmed, so the read is handed them instead of listing them. + expect(known[0]).toEqual({ + headSha: sha, + runs: [{ id: 9, status: "completed", conclusion: "action_required" }], + }); requests.length = 0; yield* poll(); expect(reads).toBe(1); @@ -113,27 +132,32 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou let reads = 0; const revalidate = yield* makeChecksRevalidator.pipe( Effect.provide( - Layer.mock(GitHubCli.GitHubCli)({ - execute: ({ args }) => + Layer.mock(GitHubApi.GitHubApi)({ + rest: ({ path, ifNoneMatch }) => unavailable ? Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/repo", - cause: undefined, - httpStatus: 502, + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "revalidateChecks", + status: 502, }), ) - : Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: - args.includes("-H") && etags - ? "HTTP/2.0 304 Not Modified\n\n" - : `HTTP/2.0 200 OK\n${etags ? 'Etag: "one"\n' : ""}\n${args[1]!.endsWith("/pulls/1") ? encodeJson({ head: { sha }, base: { repo: { id: 1 } }, headRepositoryId: 1 }) : ""}`, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), + : Effect.succeed( + ifNoneMatch !== undefined && etags + ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false } + : { + status: 200, + headers: etags ? { etag: '"one"' } : {}, + body: path.endsWith("/pulls/1") + ? encodeJson({ + head: { sha, repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }) + : "{}", + truncated: false, + invalidUtf8: false, + }, + ), }), ), ); @@ -141,7 +165,11 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou reads++; return fail ? Effect.fail( - new GitHubCli.GitHubCliCommandError({ command: "gh", cwd: "/repo", cause: undefined }), + new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "read", + status: 500, + }), ) : Effect.succeed({ state: "open" as const, @@ -152,7 +180,7 @@ it.effect("does not retain failed or incomplete reads, and supports hosts withou }); const poll = () => revalidate(reference, read).pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, credential), + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), ); yield* poll().pipe(Effect.flip); fail = false; @@ -190,15 +218,13 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor let reads = 0; const revalidate = yield* makeChecksRevalidator.pipe( Effect.provide( - Layer.mock(GitHubCli.GitHubCli)({ - execute: () => { + Layer.mock(GitHubApi.GitHubApi)({ + rest: () => { probes++; return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/repo", - cause: undefined, - httpStatus: 404, + new GitHubApi.GitHubApiNotFoundError({ + host: "github.com", + operation: "revalidateChecks", }), ); }, @@ -211,7 +237,7 @@ it.effect("falls back when REST checks are unavailable without retrying unsuppor }); for (let tick = 0; tick < 2; tick++) yield* revalidate(reference, read).pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, credential), + Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), ); expect(probes).toBe(1); expect(reads).toBe(2); diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 04d2f8706c52..8172bf544074 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -1,32 +1,63 @@ import * as Clock from "effect/Clock"; import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; -const decodeHeadSchema = Schema.Struct({ - head: Schema.Struct({ sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)) }), +const HeadSchema = Schema.Struct({ + head: Schema.Struct({ + sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)), + // Null once the fork a pull request came from has been deleted. + repo: Schema.NullOr(Schema.Struct({ id: PositiveInt })), + }), base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }), - headRepositoryId: Schema.NullOr(PositiveInt), }); -const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(decodeHeadSchema)); +const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema)); -type Validator = { etag: string | undefined; next: boolean }; +const WorkflowRunSchema = Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + html_url: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + head_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeWorkflowRuns = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })), +); + +export type KnownWorkflowRun = typeof WorkflowRunSchema.Type; + +/** + * Every `pull_request` workflow run of one head, as the revalidator just confirmed them against + * GitHub. A read running under it filters these rather than asking for the runs again. + */ +export const KnownWorkflowRuns = Context.Reference<{ + readonly headSha: string; + readonly runs: ReadonlyArray; +} | null>("t3/pullRequest/KnownWorkflowRuns", { defaultValue: () => null }); + +type Validator = { etag: string | undefined; next: boolean; body: string }; + +/** A host that answers these with one of them has no conditional REST for it at all. */ +const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); export const makeChecksRevalidator = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; + const api = yield* GitHubApi.GitHubApi; const entries = yield* Cache.makeWith( (_key: string) => Effect.sync(() => ({ gate: Semaphore.makeUnsafe(1), validators: new Map(), - head: null as Schema.Schema.Type | null, + head: null as typeof HeadSchema.Type | null, value: null as PullRequestChecks | null, readAt: 0, supported: true, @@ -43,71 +74,64 @@ export const makeChecksRevalidator = Effect.gen(function* () { >, ) => Effect.gen(function* () { - const credential = yield* GitHubCli.PinnedGitHubCredential; + const credential = yield* GitHubApi.PinnedGitHubCredential; if (credential === null) return yield* read; const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; const entry = yield* Cache.get(entries, key); return yield* entry.gate.withPermit( Effect.gen(function* () { if (!entry.supported) return yield* read; - const fail = () => - new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: input.cwd, - cause: new Error("GitHub returned an invalid conditional checks response."), + const fail = (status: number) => + new GitHubApi.GitHubApiResponseError({ + host: input.host, + operation: "revalidateChecks", + status, }); + /** Whether the endpoint has a next page, or null where the host cannot say. */ const get = (endpoint: string, head = false) => Effect.gen(function* () { const previous = entry.validators.get(endpoint); - const result = yield* github - .execute({ - cwd: input.cwd, - args: [ - "api", - endpoint, - "--hostname", - input.host, - "--include", - ...(head - ? [ - "--jq", - "{head:{sha:.head.sha},base:{repo:{id:.base.repo.id}},headRepositoryId:.head.repo.id}", - ] - : ["--silent"]), - ...(previous?.etag ? ["-H", `If-None-Match: ${previous.etag}`] : []), - ], - acceptNotModified: true, + const response = yield* api + .rest({ + host: input.host, + operation: "revalidateChecks", + path: endpoint, + ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}), }) .pipe( Effect.catchTags({ - GitHubCliCommandError: (error) => + GitHubApiNotFoundError: () => Effect.sync(() => { - entry.supported = ![404, 405, 501].includes(error.httpStatus ?? 0); + entry.supported = false; + return null; + }), + GitHubApiResponseError: (error) => + Effect.sync(() => { + entry.supported = !UNSUPPORTED_STATUSES.has(error.status); return null; }), }), ); - if (result === null) { + if (response === null) { entry.value = null; return null; } - if (result.stdoutTruncated || result.stdoutInvalidUtf8) return yield* fail(); - const split = result.stdout.search(/\r?\n\r?\n/); - const headers = split < 0 ? result.stdout : result.stdout.slice(0, split); - const status = /^HTTP\/\S+ (\d+)/.exec(headers)?.[1]; - if (status === "304" && previous) return previous.next; - if (status !== "200") return yield* fail(); + if (response.status === 304 && previous) return previous.next; + if (response.status !== 200 || response.truncated) { + return yield* fail(response.status); + } entry.value = null; if (head) { - const decoded = yield* decodeHead(result.stdout.slice(split).trim()).pipe( - Effect.mapError(fail), + const decoded = yield* decodeHead(response.body).pipe( + Effect.mapError(() => fail(response.status)), ); if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); entry.head = decoded; } const validator = { - etag: /^etag:\s*(.+)$/im.exec(headers)?.[1]?.trim(), - next: /^link:.*rel="next"/im.test(headers), + etag: response.headers["etag"]?.trim(), + next: /rel="next"/.test(response.headers["link"] ?? ""), + body: response.body, }; entry.validators.set(endpoint, validator); return validator.next; @@ -115,27 +139,44 @@ export const makeChecksRevalidator = Effect.gen(function* () { const root = `repos/${input.repository}`; if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read; const head = entry.head; - if (head === null) return yield* fail(); + if (head === null) return yield* fail(200); + const runsEndpoint = + head.head.repo?.id !== head.base.repo.id + ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request` + : null; const endpoints = [ `${root}/commits/${head.head.sha}/check-runs?filter=all`, `${root}/commits/${head.head.sha}/status`, - ...(head.headRepositoryId !== head.base.repo.id - ? [`${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`] - : []), + ...(runsEndpoint === null ? [] : [runsEndpoint]), ]; + const runPages: Array = []; for (const endpoint of endpoints) { for (let page = 1; ; page++) { - if (page > 100) return yield* fail(); - const next = yield* get( - `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`, - ); + if (page > 100) return yield* fail(200); + const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`; + const next = yield* get(paged); if (next === null) return yield* read; + if (endpoint === runsEndpoint) runPages.push(paged); if (!next) break; } } const now = yield* Clock.currentTimeMillis; if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; - const fresh = yield* read; + // Every page of the head's runs was just confirmed current, so the read filters them + // rather than listing the runs a second time. + const pages = runPages.map((endpoint) => + decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""), + ); + const known = + runsEndpoint !== null && pages.every(Option.isSome) + ? { + headSha: head.head.sha, + runs: pages.flatMap((page) => + Option.isSome(page) ? page.value.workflow_runs : [], + ), + } + : null; + const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known)); const value = { state: fresh.state, checks: fresh.checks, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index b0225545c1f4..2e008fe4c636 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -6,10 +6,10 @@ import { buildPullRequestWatchFingerprintsGraphQlQuery, decodePullRequestSummariesJson, decodePullRequestWatchFingerprintsJson, - buildReviewSubmissionJson, + buildReviewSubmission, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, - buildReviewerRequestJson, + buildReviewerRequest, buildSetFilesViewedGraphQlMutation, decodeBaseComparisonJson, decodePullRequestActivityJson, @@ -25,26 +25,50 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, decodeViewerPermissionsJson, - decodeWorkflowRunApprovalsJson, + decodeWorkflowRunsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, pullRequestCoreGraphQlQuery, pullRequestSearchGraphQlQuery, } from "./gitHubPullRequestJson.ts"; +/** + * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: + * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. + */ function listJson(entries: ReadonlyArray>): string { - return JSON.stringify( - entries.map((entry) => ({ - number: 1, - title: "Add the pull requests page", - url: "https://github.com/pingdotgg/t3code/pull/1", - headRefName: "feat/page", - baseRefName: "main", - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - ...entry, - })), - ); + return JSON.stringify({ + data: { + repository: { + pullRequests: { + pageInfo: { hasNextPage: false }, + nodes: entries.map(({ reviewRequests, latestReviews, checks, ...entry }) => ({ + number: 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + ...(reviewRequests === undefined + ? {} + : { + reviewRequests: { + nodes: (reviewRequests as ReadonlyArray).map((requestedReviewer) => ({ + requestedReviewer, + })), + }, + }), + ...(latestReviews === undefined ? {} : { latestReviews: { nodes: latestReviews } }), + ...(checks === undefined + ? {} + : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), + ...entry, + })), + }, + }, + }, + }); } function expectSuccess(result: Result.Result): A { @@ -136,38 +160,15 @@ describe("pull request list decoding", () => { ]); }); - it("rolls the head commit's checks up to the one word a row has space for", () => { + it("rolls the head commit's rollup up to the one word a row has space for", () => { const batch = expectSuccess( decodePullRequestListJson( listJson([ - // A failure outranks a run still going, and a completed run has to be read through its - // conclusion rather than its status. - { - statusCheckRollup: [ - { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, - { name: "build", status: "IN_PROGRESS" }, - { name: "test", status: "COMPLETED", conclusion: "FAILURE" }, - ], - }, - { - statusCheckRollup: [ - { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, - { name: "build", status: "QUEUED" }, - ], - }, - { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] }, - // A commit status reports one `state` and no `status` at all. - { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, - // Neither a pass, a failure nor a wait is no verdict rather than a green tick. - { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, - // Cancelled reads as failing here and in the detail header, so the two never flap. - { - statusCheckRollup: [ - { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, - { name: "test", status: "COMPLETED", conclusion: "CANCELLED" }, - ], - }, - { statusCheckRollup: [] }, + { checks: "FAILURE" }, + { checks: "PENDING" }, + { checks: "SUCCESS" }, + { checks: "ERROR" }, + { checks: "EXPECTED" }, {}, ]), ), @@ -177,16 +178,15 @@ describe("pull request list decoding", () => { "pending", "passing", "failing", - null, - "failing", - null, + "pending", null, ]); }); it("skips malformed entries but still counts them, so paging does not stop early", () => { - const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; - const batch = expectSuccess(decodePullRequestListJson(raw)); + const batch = expectSuccess( + decodePullRequestListJson(listJson([{}, { number: "not-a-number" }])), + ); expect(batch.items).toHaveLength(1); expect(batch.rawCount).toBe(2); }); @@ -279,6 +279,43 @@ describe("pull request detail decoding", () => { ], }); + /** The same conversation as the GraphQL activity query answers it. */ + const activityJson = (raw: Record) => + JSON.stringify({ + data: { + repository: { + pullRequest: { + comments: { nodes: raw["comments"] ?? [] }, + reviews: { nodes: raw["reviews"] ?? [] }, + commits: { + nodes: ((raw["commits"] ?? []) as ReadonlyArray>).map( + ({ authors, ...commit }) => ({ + commit: { + ...commit, + authors: { + nodes: ((authors ?? []) as ReadonlyArray>).map( + ({ login, ...author }) => ({ + ...author, + ...(login === undefined ? {} : { user: { login } }), + }), + ), + }, + }, + }), + ), + }, + }, + }, + }, + }); + const activity = (raw: Record) => + Result.map(decodePullRequestActivityJson(activityJson(raw)), (page) => ({ + comments: page.remarks.toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commits: page.commits ?? [], + })); + it("maps check-run status and commit-status state onto one vocabulary", () => { const detail = expectSuccess(decodePullRequestDetailJson(detailJson)); expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ @@ -333,13 +370,15 @@ describe("pull request detail decoding", () => { it("decodes workflow runs that can be approved", () => { expect( expectSuccess( - decodeWorkflowRunApprovalsJson( - JSON.stringify([ - { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" }, - { databaseId: 11, workflowName: null, url: null }, - ]), + decodeWorkflowRunsJson( + JSON.stringify({ + workflow_runs: [ + { id: 10, name: "contributor tests", html_url: "https://example.com/10" }, + { id: 11, name: null, html_url: null }, + ], + }), ), - ), + ).runs, ).toEqual([ { id: 10, name: "contributor tests", url: "https://example.com/10" }, { id: 11, name: "Workflow run 11", url: null }, @@ -398,14 +437,14 @@ describe("pull request detail decoding", () => { }); it("merges reviews with comments in time order and keeps a bodyless approval", () => { - const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); + const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); // r2 approved without writing anything, which is still the event worth seeing. expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); }); it("keeps every attributed commit author, including an unlinked signature", () => { - const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); + const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); expect(detail.commits[0]?.authors).toEqual([ { login: "octocat", name: "Octo Cat", avatarUrl: null }, { login: "Pair Author", name: "Pair Author", avatarUrl: null }, @@ -415,22 +454,20 @@ describe("pull request detail decoding", () => { it("drops the bodyless review GitHub opens to hold line comments", () => { const raw = JSON.parse(detailJson) as Record; const detail = expectSuccess( - decodePullRequestActivityJson( - JSON.stringify({ - ...raw, - reviews: [ - // What a reviewer leaving inline comments produces: a container with a state but - // nothing to read. Its comments come from the review threads instead. - { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, - { - id: "r5", - body: "Looks good.", - state: "COMMENTED", - submittedAt: "2026-07-08T00:00:00Z", - }, - ], - }), - ), + activity({ + ...raw, + reviews: [ + // What a reviewer leaving inline comments produces: a container with a state but + // nothing to read. Its comments come from the review threads instead. + { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, + { + id: "r5", + body: "Looks good.", + state: "COMMENTED", + submittedAt: "2026-07-08T00:00:00Z", + }, + ], + }), ); expect(detail.comments.map((comment) => comment.id)).toEqual(["c1", "r5"]); @@ -441,12 +478,10 @@ describe("pull request detail decoding", () => { (state) => { const raw = JSON.parse(detailJson) as Record; const detail = expectSuccess( - decodePullRequestActivityJson( - JSON.stringify({ - ...raw, - reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], - }), - ), + activity({ + ...raw, + reviews: [{ id: "r6", body: "", state, submittedAt: "2026-07-07T00:00:00Z" }], + }), ); expect(detail.comments.map((comment) => comment.id)).toContain("r6"); @@ -456,12 +491,10 @@ describe("pull request detail decoding", () => { it("drops a review that carries neither a body nor a state", () => { const raw = JSON.parse(detailJson) as Record; const detail = expectSuccess( - decodePullRequestActivityJson( - JSON.stringify({ - ...raw, - reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], - }), - ), + activity({ + ...raw, + reviews: [{ id: "r3", body: " ", submittedAt: "2026-07-07T00:00:00Z" }], + }), ); expect(detail.comments.map((comment) => comment.id)).toEqual(["c1"]); }); @@ -1432,18 +1465,16 @@ describe("reviewer candidate decoding", () => { describe("reviewer request payload", () => { it("sends people and teams in the two lists GitHub keeps them in", () => { expect( - JSON.parse( - buildReviewerRequestJson([ - { id: "octocat", kind: "user" }, - { id: "reviewers", kind: "team" }, - { id: "hubot", kind: "user" }, - ]), - ), + buildReviewerRequest([ + { id: "octocat", kind: "user" }, + { id: "reviewers", kind: "team" }, + { id: "hubot", kind: "user" }, + ]), ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); }); it("sends both lists even where one of them is empty, which is what GitHub reads", () => { - expect(JSON.parse(buildReviewerRequestJson([{ id: "octocat", kind: "user" }]))).toEqual({ + expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ reviewers: ["octocat"], team_reviewers: [], }); @@ -1452,24 +1483,22 @@ describe("reviewer request payload", () => { describe("review submission payload", () => { it("sends the verdict, the summary and every line comment in one body", () => { - const payload = JSON.parse( - buildReviewSubmissionJson({ - verdict: "request-changes", - body: "Two things.", - comments: [ - { - path: "src/a.ts", - position: { kind: "added", newLine: 12 }, - body: "rename this", - }, - { - path: "src/b.ts", - position: { kind: "deleted", oldLine: 3 }, - body: "why remove?", - }, - ], - }), - ) as Record; + const payload = buildReviewSubmission({ + verdict: "request-changes", + body: "Two things.", + comments: [ + { + path: "src/a.ts", + position: { kind: "added", newLine: 12 }, + body: "rename this", + }, + { + path: "src/b.ts", + position: { kind: "deleted", oldLine: 3 }, + body: "why remove?", + }, + ], + }); expect(payload).toEqual({ event: "REQUEST_CHANGES", body: "Two things.", @@ -1481,9 +1510,11 @@ describe("review submission payload", () => { }); it("sends an approval with no words and no comments", () => { - expect( - JSON.parse(buildReviewSubmissionJson({ verdict: "approve", body: "", comments: [] })), - ).toEqual({ event: "APPROVE", body: "", comments: [] }); + expect(buildReviewSubmission({ verdict: "approve", body: "", comments: [] })).toEqual({ + event: "APPROVE", + body: "", + comments: [], + }); }); }); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 0a2f5fe4a604..1409e92a1fad 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -155,6 +155,9 @@ const RawSearchItemSchema = Schema.Struct({ latestReviews: Schema.optional( Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), ), + /** Asked for by the per-repository listing, and left out of the cross-repository search. */ + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -167,7 +170,14 @@ const RawSearchItemSchema = Schema.Struct({ Schema.Array( Schema.NullOr( Schema.Struct({ - requestedReviewer: Schema.optional(Schema.NullOr(RawActorSchema)), + requestedReviewer: Schema.optional( + Schema.NullOr( + Schema.Struct({ + ...RawActorSchema.fields, + slug: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), }), ), ), @@ -215,14 +225,27 @@ const RawSearchItemSchema = Schema.Struct({ ), }); +const RawRowConnectionSchema = Schema.Struct({ + pageInfo: Schema.optional( + Schema.NullOr( + Schema.Struct({ + hasNextPage: Schema.Boolean, + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped + // rather than blanking every repository at once. + nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), +}); + const RawSearchSchema = Schema.Struct({ + data: Schema.Struct({ search: RawRowConnectionSchema }), +}); + +const RawRepositoryPullRequestsSchema = Schema.Struct({ data: Schema.Struct({ - search: Schema.Struct({ - pageInfo: Schema.optional(Schema.NullOr(Schema.Struct({ hasNextPage: Schema.Boolean }))), - // Row by row, like the listing's own: a node that is not a pull request — or one field - // GitHub changes — is skipped rather than blanking every repository at once. - nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))), - }), + repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })), }), }); @@ -414,31 +437,103 @@ const RawDetailSchema = Schema.Struct({ ), }); -const RawWorkflowRunApprovalSchema = Schema.Struct({ - databaseId: Schema.Int, - workflowName: Schema.optional(Schema.NullOr(Schema.String)), - url: Schema.optional(Schema.NullOr(Schema.String)), +/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ +const RawWorkflowRunsSchema = Schema.Struct({ + workflow_runs: Schema.Array( + Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + html_url: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), }); -const RawPullRequestHeadSchema = Schema.Struct({ - number: Schema.Int, - headRefOid: Schema.String, - isCrossRepository: Schema.optional(Schema.Boolean), - headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), +const RawPullRequestHeadsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequests: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchemaFields()), + nodes: Schema.Array( + Schema.Struct({ + number: Schema.Int, + headRefOid: Schema.String, + isCrossRepository: Schema.optional(Schema.Boolean), + headRepositoryOwner: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.String })), + ), + }), + ), + }), + }), + ), + }), }); +const RawActivityConnection = (node: S) => + Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchemaFields()), + nodes: Schema.Array(node), + }), + ), + ); + const RawActivitySchema = Schema.Struct({ - author: Schema.optional(Schema.NullOr(RawActorSchema)), - comments: Schema.optional(Schema.Array(RawCommentSchema)), - reviews: Schema.optional(Schema.Array(RawReviewSchema)), - commits: Schema.optional(Schema.Array(RawCommitSchema)), + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + comments: RawActivityConnection(RawCommentSchema), + reviews: RawActivityConnection(RawReviewSchema), + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + oid: Schema.String, + messageHeadline: Schema.optional(Schema.String), + committedDate: Schema.String, + authors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + email: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + }), + ), + ), + }), + }), + ), + }), + ), + ), + }), + }), + }), }); /** Where a connection carries on from, which is what every paged read below follows. */ -const RawPageInfoSchema = Schema.Struct({ - hasNextPage: Schema.optional(Schema.Boolean), - endCursor: Schema.optional(Schema.NullOr(Schema.String)), -}); +function RawPageInfoSchemaFields() { + return Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }); +} +const RawPageInfoSchema = RawPageInfoSchemaFields(); /** * What GitHub says the viewer may do with a pull request. Both are optional so that an install @@ -707,19 +802,25 @@ export function decodeActorAvatarsJson( return Result.succeed(avatarsByLogin); } -export const PULL_REQUEST_LIST_JSON_FIELDS = - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,latestReviews,labels,statusCheckRollup"; - -export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; - /** * Pull refs let the comparison share the detail read without first resolving a fork branch. * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and * an unknown field fails the whole read. */ -export const pullRequestCoreGraphQlQuery = (host: string) => { +function checkContextNodesSelection(host: string): string { const required = host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; + return `nodes { + __typename + ... on StatusContext { context state targetUrl createdAt description${required} } + ... on CheckRun { + name status conclusion startedAt completedAt detailsUrl${required} + checkSuite { workflowRun { workflow { name } } } + } + }`; +} + +export const pullRequestCoreGraphQlQuery = (host: string) => { return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { repository(owner: $owner, name: $name) { mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission @@ -738,14 +839,7 @@ export const pullRequestCoreGraphQlQuery = (host: string) => { labels(first: 100) { nodes { name color } } commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { - nodes { - __typename - ... on StatusContext { context state targetUrl createdAt description${required} } - ... on CheckRun { - name status conclusion startedAt completedAt detailsUrl${required} - checkSuite { workflowRun { workflow { name } } } - } - } + ${checkContextNodesSelection(host)} pageInfo { hasNextPage } } } } } } @@ -791,7 +885,29 @@ export function decodePullRequestPreviewJson( })); } -export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; +/** + * The conversation a pull request carries outside its review threads: who opened it, its issue + * comments, its reviews and its newest commits. The two remark connections page independently, so + * each is switched on only while it has more to give; the first read asks for everything. + */ +export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + author @include(if: $head) { __typename login avatarUrl ... on User { name } } + commits(last: 100) @include(if: $head) { + nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } } + } + comments(first: 100, after: $commentsAfter) @include(if: $withComments) { + pageInfo { hasNextPage endCursor } + nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } + } + reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) { + pageInfo { hasNextPage endCursor } + nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } } + } + } + } +}`; /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ const GRAPHQL_PAGE_SIZE = 100; @@ -818,13 +934,45 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; * than twenty labels shows twenty, and one that has asked more than twenty people for a review * is already past what a row can say. */ -export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { - return `query($q: String!) { - search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { - pageInfo { hasNextPage } +export function pullRequestSearchGraphQlQuery( + rows: number, + includeStacks = false, + includeStats = false, +): string { + return `query($q: String!, $after: String) { + search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) { + pageInfo { hasNextPage endCursor } nodes { ... on PullRequest { - ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} + ${pullRequestRowSelection(includeStacks, includeStats)} + } + } + } +}`; +} + +/** + * A repository's pull requests without search, newest created first: the order `gh pr list` lists + * in when it does not search, for a repository GitHub's search index does not cover. + */ +export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string { + return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) { + pageInfo { hasNextPage endCursor } + nodes { ${pullRequestRowSelection(includeStacks, true)} } + } + } +}`; +} + +function pageRows(rows: number): number { + return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); +} + +/** One listing row, the same whether it came from a search or from a repository's own list. */ +function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string { + return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} number title url @@ -836,17 +984,14 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals mergeable reviewDecision latestReviews(first: 20) { nodes { state author { login } } } + ${includeStats ? "additions deletions" : ""} createdAt updatedAt mergedAt repository { nameWithOwner } - reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } + reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } } labels(first: 20) { nodes { name color } } - commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } - } - } - } -}`; + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`; } /** @@ -1094,24 +1239,6 @@ export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, } }`; -/** - * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document - * rather than as `-f name=value` flags, so a reader's own words never reach argv. - */ -const GraphQlRequestSchema = Schema.Struct({ - query: Schema.String, - variables: Schema.Record(Schema.String, Schema.String), -}); - -const encodeGraphQlRequest = Schema.encodeSync(Schema.fromJsonString(GraphQlRequestSchema)); - -export function encodeGraphQlRequestJson(input: { - readonly query: string; - readonly variables: Readonly>; -}): string { - return encodeGraphQlRequest({ query: input.query, variables: { ...input.variables } }); -} - /** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ const ReviewSubmissionSchema = Schema.Struct({ event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), @@ -1126,8 +1253,6 @@ const ReviewSubmissionSchema = Schema.Struct({ ), }); -const encodeReviewSubmission = Schema.encodeSync(Schema.fromJsonString(ReviewSubmissionSchema)); - const REVIEW_EVENTS: Record = { comment: "COMMENT", approve: "APPROVE", @@ -1166,12 +1291,12 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): { } /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ -export function buildReviewSubmissionJson(input: { +export function buildReviewSubmission(input: { readonly verdict: PullRequestReviewVerdict; readonly body: string; readonly comments: ReadonlyArray; -}): string { - return encodeReviewSubmission({ +}): typeof ReviewSubmissionSchema.Type { + return { event: REVIEW_EVENTS[input.verdict], body: input.body, comments: input.comments.map((comment) => ({ @@ -1179,7 +1304,7 @@ export function buildReviewSubmissionJson(input: { ...gitHubReviewPosition(comment.position), body: comment.body, })), - }); + }; } export interface GitHubPullRequestListItem { @@ -1644,23 +1769,15 @@ function toDetail(raw: Schema.Schema.Type): GitHubPullRe }; } -function toActivity(raw: Schema.Schema.Type): GitHubPullRequestActivity { - return { - author: toActor(raw.author), - comments: toComments(raw), - commits: toCommits(raw.commits), - }; -} - const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeListEntry = Schema.decodeUnknownExit(RawListItemSchema); const decodeSearch = decodeJsonResult(RawSearchSchema); const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); const decodeStats = decodeJsonResult(RawStatsSchema); const decodeDetail = decodeJsonResult(RawDetailSchema); -const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema)); -const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); +const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); +const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema); const decodeActivity = decodeJsonResult(RawActivitySchema); +const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema); const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema); const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema); @@ -1669,27 +1786,78 @@ type DecodeFailure = Cause.Cause; export interface GitHubPullRequestListBatch { readonly items: ReadonlyArray; - /** Rows gh returned, counted before decoding, so a skipped row cannot hide a next page. */ + /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number; + /** Where the next page starts, or null once GitHub has handed over every row. */ + readonly endCursor: string | null; } -/** Malformed entries are skipped rather than failing the batch: one unexpected pull request - * must not blank the whole list. */ +/** + * One page of a repository's own pull request list. Malformed rows are skipped rather than + * failing the page: one unexpected pull request must not blank the whole list. A repository the + * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too. + */ export function decodePullRequestListJson( raw: string, ): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } + const decoded = decodeRepositoryPullRequests(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const connection = decoded.success.data.repository?.pullRequests; + const nodes = connection?.nodes ?? []; const items: GitHubPullRequestListItem[] = []; - for (const entry of decoded.success) { - const item = decodeListEntry(entry); - if (Exit.isSuccess(item)) { - items.push(toListItem(item.value)); - } + for (const entry of nodes) { + const row = toRow(entry); + if (row !== null) items.push(row.item); } - return Result.succeed({ items, rawCount: decoded.success.length }); + return Result.succeed({ + items, + rawCount: nodes.length, + endCursor: + connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null, + }); +} + +/** + * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as + * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull + * request, or one whose fields no longer decode. + */ +function toRow( + entry: unknown, +): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { + const decodedNode = decodeSearchItem(entry); + if (!Exit.isSuccess(decodedNode)) return null; + const node = decodedNode.value; + const stack = toStackMembership(node); + const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap( + (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => { + const reviewer = request?.requestedReviewer; + const login = trimmed(reviewer?.login); + if (login !== null) return [{ login }]; + const slug = trimmed(reviewer?.slug); + return slug === null ? [] : [{ slug }]; + }, + ); + return { + item: { + ...toListItem({ + ...node, + latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => + review === null ? [] : [review], + ), + reviewRequests, + labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), + // The rollup arrives as one enum. Dressed as a single check here so it is read the same + // way the detail's checks are. + statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + }), + ...(stack === undefined ? {} : { stack }), + }, + repository: trimmed(node.repository?.nameWithOwner), + }; } export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { @@ -1703,12 +1871,12 @@ export interface GitHubPullRequestSearchBatch { readonly rawCount: number; /** More rows than this slice asked for, which is truncation for every repository in it. */ readonly hasNextPage: boolean; + /** Where the next page of the same search starts. */ + readonly endCursor: string | null; } /** - * A search answers with the same pull request the listing does, one connection deeper: reviewers - * and labels arrive as connections, and the row names the repository it came from. Flattened to - * the shape `gh pr list --json` hands over so both reads decode into one type. + * A search answers with the same row a repository's list does, each naming its repository. * * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing * row is — `is:pr` already excludes them, and one surprise must not blank a whole host. @@ -1720,41 +1888,19 @@ export function decodePullRequestSearchJson( if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } - const nodes = decoded.success.data.search.nodes ?? []; + const search = decoded.success.data.search; + const nodes = search.nodes ?? []; const items: GitHubPullRequestSearchItem[] = []; for (const entry of nodes) { - const decodedNode = decodeSearchItem(entry); - if (!Exit.isSuccess(decodedNode)) continue; - const node = decodedNode.value; - const repository = trimmed(node.repository?.nameWithOwner); - if (repository === null) continue; - const stack = toStackMembership(node); - items.push({ - ...toListItem({ - ...node, - latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => - review === null ? [] : [review], - ), - reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { - const login = trimmed(request?.requestedReviewer?.login); - return login === null ? [] : [{ login }]; - }), - labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), - // The search asks for the verdict rather than the checks behind it, so it arrives as one - // enum. Dressed as a single check here so the rollup is read the same way on both paths. - statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { - const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); - return state === null ? [] : [{ state }]; - }), - }), - ...(stack === undefined ? {} : { stack }), - repository, - }); + const row = toRow(entry); + if (row === null || row.repository === null) continue; + items.push({ ...row.item, repository: row.repository }); } return Result.succeed({ items, rawCount: nodes.length, - hasNextPage: decoded.success.data.search.pageInfo?.hasNextPage ?? false, + hasNextPage: search.pageInfo?.hasNextPage ?? false, + endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null, }); } @@ -1900,6 +2046,13 @@ export function buildPullRequestSummariesGraphQlQuery( return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; } +/** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ +export function pullRequestSummaryGraphQlQuery(includeStacks = false): string { + return `query($owner: String!, $name: String!, $number: Int!) { + s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } } +}`; +} + const RawSummarySchema = Schema.Struct({ ...RawSearchItemSchema.fields, changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), @@ -2147,11 +2300,7 @@ export function decodePullRequestCoreJson( requestedReviewer === null ? [] : [requestedReviewer], ), labels: pr.labels.nodes, - statusCheckRollup: - contexts?.nodes.map((check) => ({ - ...check, - workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, - })) ?? [], + statusCheckRollup: toCheckContexts(contexts?.nodes ?? []), }), viewerAccess: { canWrite: toCanWrite(repository.viewerPermission), @@ -2183,27 +2332,50 @@ export function decodePullRequestDetailJson( : Result.fail(decoded.failure); } -export function decodeWorkflowRunApprovalsJson( +export interface GitHubWorkflowRunPage { + readonly runs: ReadonlyArray; + /** Runs on the page, counted before decoding, which is what decides whether to page on. */ + readonly rawCount: number; +} + +/** One page of `actions/runs`. */ +export function decodeWorkflowRunsJson( raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeWorkflowRunApprovals(raw); +): Result.Result { + const decoded = decodeWorkflowRuns(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - return Result.succeed( - decoded.success.map((run) => ({ - id: run.databaseId, - name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`, - url: trimmed(run.url), + return Result.succeed({ + runs: decoded.success.workflow_runs.map((run) => ({ + id: run.id, + name: trimmed(run.name) ?? `Workflow run ${run.id}`, + url: trimmed(run.html_url), })), - ); + rawCount: decoded.success.workflow_runs.length, + }); } -export function decodePullRequestHeadsJson( - raw: string, -): Result.Result, DecodeFailure> { +/** Open pull requests whose head branch has one name, from whichever repository it lives in. */ +export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) { + pageInfo { hasNextPage endCursor } + nodes { number headRefOid isCrossRepository headRepositoryOwner { login } } + } + } +}`; + +export function decodePullRequestHeadsJson(raw: string): Result.Result< + { + readonly heads: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { const decoded = decodePullRequestHeads(raw); if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - return Result.succeed( - decoded.success.map((pullRequest) => ({ + const connection = decoded.success.data.repository?.pullRequests; + return Result.succeed({ + heads: (connection?.nodes ?? []).map((pullRequest) => ({ number: pullRequest.number, headSha: pullRequest.headRefOid, ...(typeof pullRequest.isCrossRepository === "boolean" @@ -2211,16 +2383,145 @@ export function decodePullRequestHeadsJson( : {}), headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), })), - ); + nextCursor: nextCursorOf(connection?.pageInfo), + }); +} + +export interface GitHubPullRequestActivityPage { + /** Only on the first page, which is the one that asks for them. */ + readonly author?: PullRequestActor | null; + readonly commits?: ReadonlyArray; + /** Issue comments and reviews, each list unsorted. */ + readonly remarks: ReadonlyArray; + readonly nextCommentsCursor: string | null; + readonly nextReviewsCursor: string | null; } +/** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */ export function decodePullRequestActivityJson( raw: string, -): Result.Result { +): Result.Result { const decoded = decodeActivity(raw); - return Result.isSuccess(decoded) - ? Result.succeed(toActivity(decoded.success)) - : Result.fail(decoded.failure); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const pr = decoded.success.data.repository.pullRequest; + return Result.succeed({ + ...(pr.author === undefined ? {} : { author: toActor(pr.author) }), + ...(pr.commits === undefined + ? {} + : { + commits: toCommits( + (pr.commits?.nodes ?? []).map(({ commit }) => ({ + ...commit, + authors: (commit.authors?.nodes ?? []).map((author) => ({ + ...author, + login: author.user?.login ?? null, + })), + })), + ), + }), + remarks: toComments({ + comments: pr.comments?.nodes ?? [], + reviews: pr.reviews?.nodes ?? [], + }), + nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo), + nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo), + }); +} + +/** Every check context of the head commit, a page at a time, for a rollup past one page. */ +export const pullRequestCheckContextsGraphQlQuery = (host: string) => + `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + headRefOid + commits(last: 1) { + nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) { + ${checkContextNodesSelection(host)} + pageInfo { hasNextPage endCursor } + } } } } + } + } + } +}`; + +const RawCheckContextNodeSchema = Schema.Struct({ + ...RawCheckSchema.fields, + checkSuite: Schema.optional( + Schema.NullOr( + Schema.Struct({ + workflowRun: Schema.NullOr( + Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }), + ), + }), + ), + ), +}); + +const decodeCheckContexts = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + headRefOid: Schema.String, + commits: Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + commit: Schema.Struct({ + statusCheckRollup: Schema.NullOr( + Schema.Struct({ + contexts: Schema.Struct({ + nodes: Schema.Array(RawCheckContextNodeSchema), + pageInfo: RawPageInfoSchema, + }), + }), + ), + }), + }), + ), + }), + }), + }), + }), + }), +); + +/** A check context as the rollup reports it; opaque outside this module. */ +export type GitHubCheckContext = Schema.Schema.Type; + +function toCheckContexts( + nodes: ReadonlyArray>, +): ReadonlyArray { + return nodes.map((check) => ({ + ...check, + workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null, + })); +} + +export function decodePullRequestCheckContextsJson(raw: string): Result.Result< + { + readonly headSha: string; + readonly contexts: ReadonlyArray; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeCheckContexts(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const pr = decoded.success.data.repository.pullRequest; + const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts; + return Result.succeed({ + headSha: pr.headRefOid, + contexts: toCheckContexts(contexts?.nodes ?? []), + nextCursor: nextCursorOf(contexts?.pageInfo), + }); +} + +/** The checks and their one-word verdict, from every context of the head commit. */ +export function pullRequestChecksFromContexts(contexts: ReadonlyArray): { + readonly checks: ReadonlyArray; + readonly checksState: PullRequestChecksState | null; +} { + return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) }; } export interface GitHubReviewThreadComments { @@ -2778,17 +3079,15 @@ const ReviewerRequestSchema = Schema.Struct({ team_reviewers: Schema.Array(Schema.String), }); -const encodeReviewerRequest = Schema.encodeSync(Schema.fromJsonString(ReviewerRequestSchema)); - -export function buildReviewerRequestJson( +export function buildReviewerRequest( reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>, -): string { - return encodeReviewerRequest({ +): typeof ReviewerRequestSchema.Type { + return { reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])), team_reviewers: reviewers.flatMap((reviewer) => reviewer.kind === "team" ? [reviewer.id] : [], ), - }); + }; } export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { @@ -2876,12 +3175,10 @@ export function decodeLabelCandidatesJson( } /** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ -const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) }); - -const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); - -export function buildLabelRequestJson(labels: ReadonlyArray): string { - return encodeLabelRequest({ labels }); +export function buildLabelRequest(labels: ReadonlyArray): { + readonly labels: ReadonlyArray; +} { + return { labels }; } /** @@ -2965,14 +3262,28 @@ export interface GitHubPullRequestFilesPatch { export function decodePullRequestFilesJson( raw: string, ): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } + return Result.map(decodeUnknownList(raw), toFilesPatch); +} + +/** + * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped + * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. + */ +export function decodeCommitFilesJson( + raw: string, +): Result.Result { + return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? [])); +} + +const decodeCommitFiles = decodeJsonResult( + Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }), +); + +function toFilesPatch(entries: ReadonlyArray): GitHubPullRequestFilesPatch { const sections: string[] = []; const omittedFileStats: PullRequestOmittedFileStat[] = []; let truncated = false; - for (const entry of decoded.success) { + for (const entry of entries) { const file = decodeFileEntry(entry); if (Exit.isFailure(file)) continue; const value = file.value; @@ -3009,12 +3320,12 @@ export function decodePullRequestFilesJson( ].join("\n"); sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`); } - return Result.succeed({ + return { patch: sections.join(""), truncated, - rawCount: decoded.success.length, + rawCount: entries.length, omittedFileStats, - }); + }; } /** diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts index 61ad7da3c7b0..af6794616f7c 100644 --- a/apps/server/src/pullRequest/githubStackActions.test.ts +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -2,15 +2,62 @@ import { expect, it } from "@effect/vitest"; import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; -const runGitHubStackAction = ( - execute: GitHubCli.GitHubCli["Service"]["execute"], - input: Parameters[0], -) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); +/** + * One request as the fake saw it, flattened to words: the path or document, then each variable + * or body field as `name=value`, so an assertion reads as "this request carried that value". + */ +type Call = ReadonlyArray; +type Send = ( + call: Call, + kind: "graphql" | "rest", +) => Effect.Effect; + +const runGitHubStackAction = (send: Send, input: Parameters[0]) => + runStackAction(input).pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + graphql: (request) => { + // GitHub refuses a document that declares a variable it never uses. + const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); + const unused = declared.filter( + (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), + ); + if (unused.length > 0) { + return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); + } + return send(words(request.query, request.variables), "graphql"); + }, + rest: (request) => + send(words(request.path, request.body), "rest").pipe( + Effect.map((body) => ({ + status: 200, + headers: {}, + body, + truncated: false, + invalidUtf8: false, + })), + ), + }), + ), + ); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function words(head: string, fields: unknown): Call { + return [ + head, + ...Object.entries((fields ?? {}) as Record).map( + ([key, value]) => `${key}=${typeof value === "string" ? value : encodeJson(value)}`, + ), + ]; +} + +const isMutation = (call: Call) => call[0]!.startsWith("mutation"); const stack = [ { @@ -71,21 +118,13 @@ const rebased = { const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; function fake(responses: readonly unknown[]) { - const calls: ReadonlyArray[] = []; - const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => + const calls: Call[] = []; + const execute: Send = (call) => Effect.sync(() => { - calls.push(request.args); + calls.push(call); const value = responses[calls.length - 1]; if (value === undefined) throw new Error("Unexpected GitHub request"); - return { - exitCode: ChildProcessSpawner.ExitCode(0), - // @effect-diagnostics-next-line preferSchemaOverJson:off - stdout: JSON.stringify(value), - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - }; + return encodeJson(value); }); return { execute, calls }; } @@ -215,29 +254,25 @@ it.effect("rebases unmerged layers bottom to top without local git commands", () Effect.gen(function* () { const api = fake([stack, access, ...rebaseResponses]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - const mutations = api.calls.filter((args) => - args.some((arg) => arg.startsWith("query=mutation")), - ); + const mutations = api.calls.filter(isMutation); expect(mutations).toHaveLength(2); expect(mutations[0]).toContain("id=PR_2"); expect(mutations[0]).toContain("sha=bbb"); expect(mutations[1]).toContain("id=PR_3"); expect(mutations[1]).toContain("sha=ccc"); - expect(api.calls.every((args) => args[0] === "api")).toBe(true); }), ); it.effect("does not update later layers after a rebase failure", () => Effect.gen(function* () { const api = fake([stack, access, branch(2, "bbb")]); - const execute: typeof api.execute = (request) => - !request.args.some((arg) => arg.startsWith("query=mutation")) - ? api.execute(request) + const execute: Send = (call, kind) => + !isMutation(call) + ? api.execute(call, kind) : Effect.fail( - new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", - cwd: "/repo", - cause: new Error("denied"), + new GitHubApi.GitHubApiAuthenticationError({ + host: "github.com", + operation: "runGitHubStackAction", }), ); const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( @@ -272,7 +307,6 @@ it.effect("refuses the entire rebase before mutation when a later fork denies wr failure: { _tag: "GitHubStackPermissionError" }, }); expect(api.calls).toHaveLength(2); - expect(api.calls.every((args) => args[0] === "api")).toBe(true); }), ); @@ -333,9 +367,7 @@ it.effect("skips current layers without submitting a rebase mutation", () => Effect.gen(function* () { const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( - false, - ); + expect(api.calls.some((args) => isMutation(args))).toBe(false); }), ); @@ -380,9 +412,7 @@ it.effect("reports partial progress when a later head changes during the rebase" if (result._tag === "Failure") { expect(result.failure.message).toContain("Earlier updates remain on GitHub"); } - expect( - api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), - ).toHaveLength(1); + expect(api.calls.filter((args) => isMutation(args))).toHaveLength(1); }), ); @@ -403,11 +433,7 @@ it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", _tag: "Failure", failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, }); - expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( - true, - ); - expect( - api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), - ).toHaveLength(rebasedParent ? 1 : 0); + expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true); + expect(api.calls.filter((args) => isMutation(args))).toHaveLength(rebasedParent ? 1 : 0); }), ); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts index 6e72836e6e40..0ddb57a274bc 100644 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -8,7 +8,7 @@ import * as Clock from "effect/Clock"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; const stackErrorIdentity = { @@ -21,10 +21,6 @@ export class GitHubStackChangedError extends Schema.TaggedError 0 ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` @@ -36,10 +32,6 @@ export class GitHubStackUnsupportedError extends Schema.TaggedError - `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, - ) - .join(" ")}}}`, - ], + const permissions = yield* api.graphql({ + host: input.host, + operation: "runGitHubStackAction", + allowReserve: true, + variables: { owner, name }, + query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open + .map( + (layer) => + `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, + ) + .join(" ")}}}`, }); - const access = yield* decodeBranchAccess(permissions.stdout).pipe( + const access = yield* decodeBranchAccess(permissions).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), ); // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent @@ -282,35 +245,27 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* const processed: Array<{ id: string; number: number; headSha: string }> = []; for (const [index, layer] of open.entries()) { yield* Effect.gen(function* () { - const read = yield* github.execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - "graphql", - "-f", - `owner=${owner}`, - "-f", - `name=${name}`, - "-F", - `number=${layer.number}`, - "-f", - `sha=${layer.headSha}`, - "-f", - `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ - processed.length === 0 - ? "" - : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` - } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, - ], + const read = yield* api.graphql({ + host: input.host, + operation: "runGitHubStackAction", + allowReserve: true, + variables: { + owner, + name, + number: layer.number, + sha: layer.headSha, + ids: processed.map((head) => head.id), + }, + // GitHub rejects a declared variable the document never uses, so `$ids` is always + // selected; an empty list asks for nothing. + query: `query($owner:String!,$name:String!,$number:Int!,$sha:String!,$ids:[ID!]!){processed:nodes(ids:$ids){... on PullRequest{headRefOid}} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, }); const { data: { processed: observed, repository: { pullRequest: pr }, }, - } = yield* decodeRebaseBranch(read.stdout); + } = yield* decodeRebaseBranch(read); // A push to an earlier layer must not silently become the next layer's new base. const changed = processed.find( (head, index) => observed?.[index]?.headRefOid !== head.headSha, @@ -332,22 +287,14 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* return; } // Pass the reviewed revision to GitHub, including when a push races this read. - const updated = yield* github.execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - "graphql", - "-f", - `id=${pr.id}`, - "-f", - `sha=${layer.headSha}`, - "-f", - "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", - ], + const updated = yield* api.graphql({ + host: input.host, + operation: "runGitHubStackAction", + variables: { id: pr.id, sha: layer.headSha }, + query: + "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", }); - const response = yield* decodeRebaseResponse(updated.stdout); + const response = yield* decodeRebaseResponse(updated); processed.push({ id: pr.id, number: layer.number, @@ -374,24 +321,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* decodeMergeResponse(raw).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), ); - const request = yield* github.execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - "--method", - "PUT", - `${endpoint}/pulls/${input.number}/merge-async`, - "-f", - `merge_method=${input.mergeMethod ?? "merge"}`, - "-f", - "merge_action=default", - "-f", - `sha=${target.headSha}`, - ], + const request = yield* api.rest({ + host: input.host, + operation: "runGitHubStackAction", + method: "PUT", + path: `${endpoint}/pulls/${input.number}/merge-async`, + body: { + merge_method: input.mergeMethod ?? "merge", + merge_action: "default", + sha: target.headSha, + }, }); - let result = yield* decode(request.stdout); + let result = yield* decode(request.body); const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; for ( let attempt = 0; @@ -401,16 +342,12 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* const uuid = result.details.uuid; if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); - const poll = yield* github.execute({ - cwd: input.cwd, - args: [ - "api", - "--hostname", - input.host, - `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, - ], + const poll = yield* api.rest({ + host: input.host, + operation: "runGitHubStackAction", + path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, }); - result = yield* decode(poll.stdout); + result = yield* decode(poll.body); } if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); if (result.status === "failed") diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 7b1a9f316a9c..a6cc60b50aa8 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -117,6 +117,8 @@ export interface GitHubRestResponse { readonly headers: Readonly>; readonly body: string; readonly truncated: boolean; + /** The body was not valid UTF-8, which a raw file read takes to mean binary. */ + readonly invalidUtf8: boolean; } export interface GitHubRestInput { @@ -131,6 +133,8 @@ export interface GitHubRestInput { /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ readonly ifNoneMatch?: string; readonly maxResponseBytes?: number; + /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ + readonly timeout?: Duration.Input; /** Overrides `AllowGitHubReserve` for this one request. */ readonly allowReserve?: boolean; } @@ -381,11 +385,11 @@ export const make = Effect.gen(function* () { readonly operation: string; readonly request: HttpClientRequest.HttpClientRequest; readonly maxResponseBytes: number; + readonly timeout?: Duration.Input | undefined; readonly allowReserve: boolean; readonly acceptNotModified: boolean; /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ readonly graphql?: boolean; - readonly timeout?: Duration.Duration | undefined; }) { const host = normalizeHost(input.host); // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. @@ -435,7 +439,7 @@ export const make = Effect.gen(function* () { // 204, 304 and many refusals carry no body at all, which is an empty answer. Effect.catchIf( (error) => error.reason._tag === "EmptyBodyError", - () => Effect.succeed({ text: "", truncated: false }), + () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), ), ); return { response, collected }; @@ -468,6 +472,7 @@ export const make = Effect.gen(function* () { headers, body: collected.text, truncated: collected.truncated, + invalidUtf8: collected.invalidUtf8, }), ), RateLimited: () => @@ -526,6 +531,7 @@ export const make = Effect.gen(function* () { operation: input.operation, request, maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + timeout: input.timeout, allowReserve: input.allowReserve ?? interactive, acceptNotModified: input.ifNoneMatch !== undefined, }), diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 0097794c7b9d..88ddf1349e86 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -38,10 +38,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; -export const AllowGitHubReserve = Context.Reference( - "t3/sourceControl/AllowGitHubReserve", - { defaultValue: () => false }, -); +export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; function commandHosts(args: ReadonlyArray): Array { const hosts: Array = []; From 649418f01fcb4ae5ebbc07739e0bfee8fae3aaec Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:14:13 -0700 Subject: [PATCH 06/59] feat(server): source control, media and discovery use GitHub's API instead of gh (#16321) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/assets/AssetAccess.test.ts | 28 +- apps/server/src/assets/GitHubMediaFetch.ts | 51 +- apps/server/src/git/GitManager.test.ts | 18 +- apps/server/src/server.ts | 3 +- apps/server/src/sourceControl/GitHubApi.ts | 3 +- .../src/sourceControl/GitHubCli.test.ts | 1262 +++++---------- apps/server/src/sourceControl/GitHubCli.ts | 1439 ++++++++--------- .../GitHubSourceControlProvider.test.ts | 108 +- .../GitHubSourceControlProvider.ts | 111 +- .../SourceControlDiscovery.test.ts | 2 + .../SourceControlProviderRegistry.test.ts | 15 +- .../SourceControlProviderRegistry.ts | 3 +- .../src/sourceControl/gitHubPullRequests.ts | 11 - 13 files changed, 1340 insertions(+), 1714 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index b3381befa3ba..12ec00256973 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -16,12 +16,12 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; -import { ChildProcessSpawner } from "effect/process"; import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import { githubMediaResponse } from "./GitHubMediaFetch.ts"; vi.mock("node:fs/promises", async (importOriginal) => { @@ -126,7 +126,7 @@ const layerTest = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { - it.effect("loads private media immediately after login and reuses the found credential", () => { + it.effect("loads private media immediately after login with the GitHub credential", () => { let lookups = 0; const authorizations: Array = []; return Effect.gen(function* () { @@ -138,19 +138,21 @@ describe("AssetAccess", () => { expect((yield* githubMediaResponse(asset, {})).status).toBe(404); expect((yield* githubMediaResponse(asset, {})).status).toBe(200); expect((yield* githubMediaResponse(asset, {})).status).toBe(200); - expect(lookups).toBe(2); + // Caching the token is GitHubCredentials' job; this asks it every time. + expect(lookups).toBe(3); expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]); }).pipe( Effect.provide( - Layer.mock(GitHubCli.GitHubCli)({ - execute: () => - Effect.sync(() => ({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: ++lookups === 1 ? "" : "signed-in", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + ++lookups === 1 + ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })) + : Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), }), ), Effect.provideService( diff --git a/apps/server/src/assets/GitHubMediaFetch.ts b/apps/server/src/assets/GitHubMediaFetch.ts index 39ed14f8b609..7b26c3e69acd 100644 --- a/apps/server/src/assets/GitHubMediaFetch.ts +++ b/apps/server/src/assets/GitHubMediaFetch.ts @@ -12,7 +12,7 @@ import { type HttpClientResponse, } from "effect/http"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; /** * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned @@ -37,8 +37,6 @@ const isCredentialedHost = (url: string) => { const MAX_REDIRECTS = 3; /** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */ const MANUAL_REDIRECT: RequestInit = { redirect: "manual" }; -const TOKEN_CACHE_TTL_MS = 5 * 60_000; -const TOKEN_CACHE_MAX_ENTRIES = 32; /** Passed through so a seek in a long video costs one upstream range request, not a full download. */ const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const; const FORWARDED_RESPONSE_HEADERS = [ @@ -57,45 +55,14 @@ const SVG_CONTENT_TYPE = "image/svg+xml"; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; /** - * A media request per image and one per video seek, each of which would otherwise spawn `gh`. - * The token is what `gh auth token` would print again on the next call, and it is held no longer - * than a signed asset URL lives. + * The github.com credential, or null without one: a public asset still loads, and a private one + * fails the way it does in a browser that is not signed in. */ -const tokenCache = new Map(); - -const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: { - readonly cwd: string; - readonly host: string; -}) { - // `gh` stores a token per host, not per repository, so the directory it runs in is not part - // of the answer and must not fragment the cache a client could otherwise churn. This route - // pins no credential; if it ever does, the pin belongs in this key. - const key = input.host; - const now = yield* Clock.currentTimeMillis; - const cached = tokenCache.get(key); - if (cached !== undefined && now - cached.at < TOKEN_CACHE_TTL_MS) return cached.token; - const github = yield* GitHubCli.GitHubCli; - // No credential is a normal state: a public asset still loads, and a private one fails the way - // it does in a browser that is not signed in. - const token = yield* github - .execute({ - cwd: input.cwd, - args: ["auth", "token", "--hostname", input.host], - env: { GH_DEBUG: "" }, - }) - .pipe( - Effect.map((output) => output.stdout.trim()), - Effect.orElseSucceed(() => ""), - ); - // A login or recovered CLI failure must take effect on the next media request. - if (token.length === 0) return null; - if (tokenCache.size >= TOKEN_CACHE_MAX_ENTRIES) { - tokenCache.delete(tokenCache.keys().next().value!); - } - const redacted = Redacted.make(token); - tokenCache.set(key, { at: now, token: redacted }); - return redacted; -}); +const githubToken = GitHubCredentials.GitHubCredentials.pipe( + Effect.flatMap((credentials) => credentials.get("github.com")), + Effect.map((credential) => credential.token), + Effect.orElseSucceed(() => null), +); /** * Follows GitHub's redirect to the signed object itself, and never carries the credential off @@ -146,7 +113,7 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon requestHeaders: Record, ) { // Both media hosts are served by github.com's account, which is the host `gh` stores it under. - const token = yield* githubToken({ cwd: asset.cwd, host: "github.com" }); + const token = yield* githubToken; const forwarded: Record = {}; for (const name of FORWARDED_REQUEST_HEADERS) { const value = requestHeaders[name]; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 43eaa08de53a..42fdc87afe57 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -36,6 +36,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; @@ -383,7 +384,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => { + // The fake still speaks in gh's command shapes; the service methods below translate to them. + const execute = (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -518,7 +523,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { - execute, // The fake answers the CLI shape, so batched lookups read it the way the fallback does. listPullRequestsByHead: (input) => execute({ @@ -724,7 +728,12 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), + Effect.provide( + Layer.merge( + Layer.succeed(GitHubCli.GitHubCli, gitHubCli), + Layer.mock(GitHubApi.GitHubApi)({}), + ), + ), ), ); @@ -2660,8 +2669,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { provider: "github", providerOperation: "listChangeRequests", providerCommand: "gh", - errorDetail: - "GitHub API rate limit exceeded. For the GraphQL quota and reset time, run `gh api graphql -f query='{rateLimit{remaining resetAt}}'`; `gh api rate_limit` reports REST.", + errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); const loggedText = [ warning?.message ?? "", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1294e6bdcf32..2579f1203cd6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -562,7 +562,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. - Layer.provideMerge(Layer.mergeAll(layerSourceControlProviderRegistry, GitHubCli.layer)), + Layer.provideMerge(layerSourceControlProviderRegistry), + Layer.provideMerge(GitHubCli.layer), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index a6cc60b50aa8..01aa1c9ccfcf 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -145,6 +145,7 @@ export interface GitHubGraphQlInput { readonly query: string; readonly variables?: Readonly>; readonly allowReserve?: boolean; + readonly maxResponseBytes?: number; } export class GitHubApi extends Context.Service< @@ -567,7 +568,7 @@ export const make = Effect.gen(function* () { HttpClientRequest.acceptJson, HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), ), - maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, allowReserve, acceptNotModified: false, graphql: true, diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 88c877605381..2efab4674fc2 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,157 +1,109 @@ -import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; -import * as Cache from "effect/Cache"; +import { assert, it, describe } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as TestClock from "effect/testing/TestClock"; -import * as Clock from "effect/Clock"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; -const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), +const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), stdout, stderr: "", stdoutTruncated: false, stderrTruncated: false, }); -const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") => - processOutput( - JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }), - ); - -const isBudgetReading = (input: VcsProcess.VcsProcessInput) => - input.args[0] === "api" && - input.args[1] === "graphql" && - input.args.at(-1)?.includes("rateLimit"); - -const mockRun = vi.fn(); - -// Budget readings are answered here, so `mockRun` sees only the commands under test. -const layer = GitHubCli.layer.pipe( - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => (isBudgetReading(input) ? Effect.succeed(quotaOutput()) : mockRun(input)), - }), - ), -); +const remotesOutput = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + +const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ + status, + headers: {}, + body: body === undefined ? "" : encodeJson(body), + truncated: false, + invalidUtf8: false, +}); -afterEach(() => { - mockRun.mockReset(); +const node = (number: number, headRefName: string, owner = "acme") => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + isCrossRepository: owner !== "acme", + updatedAt: "2026-01-02T00:00:00Z", + headRepository: { name: "web", nameWithOwner: `${owner}/web` }, + headRepositoryOwner: { login: owner }, }); -it.effect("reads the GraphQL budget once per window, preserves the reserve, and resumes", () => - Effect.gen(function* () { - let readings = 0; - const commands: string[] = []; - let remaining = 501; - let resetAt = DateTime.formatIso( - DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000), - ); - const gh = yield* GitHubCli.make.pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - Effect.sync(() => { - if (isBudgetReading(input)) { - readings++; - assert.strictEqual(input.args[3], "enterprise.test"); - return quotaOutput(remaining, resetAt); - } - commands.push(input.args.slice(0, 2).join(" ")); - return processOutput("[]"); - }), - }), - ); - const read = (command: string) => - gh.execute({ - cwd: "/repo", - args: - command === "repo" - ? ["repo", "view", "enterprise.test/acme/web", "--json", "name"] - : ["pr", command, "--repo=enterprise.test/acme/web", "--json", "number"], +/** + * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what + * `git remote -v` prints; `git` records every driver call. + */ +function harness(input: { + readonly remotes: string; + readonly api: Partial; + readonly localBranches?: ReadonlyArray; +}) { + const git: Array = []; + const record = + (name: string, value: A) => + (args: unknown) => + Effect.sync(() => { + git.push([name, args]); + return value; }); - yield* read("list"); - const failure = yield* read("view").pipe(Effect.flip); - assert.strictEqual(failure._tag, "GitHubCliRateLimitError"); - assert.deepStrictEqual(commands, ["pr list"]); - yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true)); - yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] }); - assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]); - // One reading covers the whole window. - assert.strictEqual(readings, 1); - yield* TestClock.adjust("1 minute"); - remaining = 5000; - resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000)); - yield* Effect.all([read("list"), read("repo")], { concurrency: 2 }); - assert.strictEqual(readings, 2); - assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]); - }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), -); - -it.effect("reads the budget again at a near reset, and every ten minutes in a long window", () => - Effect.gen(function* () { - let readings = 0; - const startedAt = yield* Clock.currentTimeMillis; - let resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000)); - const gh = yield* GitHubCli.make.pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - Effect.sync(() => { - if (!isBudgetReading(input)) return processOutput("[]"); - readings++; - return quotaOutput(5000, resetAt); - }), + const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (args) => + Effect.sync(() => { + git.push(["execute", args.args]); + return processOutput(""); }), - ); - const read = gh.execute({ cwd: "/repo", args: ["pr", "list"] }); - yield* read; - // The window resets ten seconds in, so the reading expires with it. - resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000 + 3_600_000)); - yield* TestClock.adjust("10 seconds"); - yield* read; - assert.strictEqual(readings, 2); - yield* TestClock.adjust("9 minutes"); - yield* read; - assert.strictEqual(readings, 2); - yield* TestClock.adjust("1 minute"); - yield* read; - assert.strictEqual(readings, 3); - }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), -); - -it.effect("reads anyway when the budget reading fails", () => - Effect.gen(function* () { - const gh = yield* GitHubCli.make.pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - isBudgetReading(input) - ? Effect.fail( - new VcsProcessSpawnError({ - operation: "GitHubCli.execute", - command: "gh", - cwd: "/gone", - cause: new Error("ENOENT"), - }), - ) - : Effect.succeed(processOutput("[]")), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), + ensureRemote: (args) => + Effect.sync(() => { + git.push(["ensureRemote", args]); + return args.preferredName; }), - ); - const result = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }); - assert.strictEqual(result.stdout, "[]"); - }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), -); + fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), + setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), + switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, + listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), + }); + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (args) => + Effect.succeed( + args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), + ), + }); + const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + Layer.provide( + Layer.mergeAll( + driver, + process, + Layer.mock(GitHubApi.GitHubApi)(input.api), + NodeServices.layer, + ), + ), + ); + return { layer, git }; +} describe("selectGitHubBaseRepository", () => { const remotes = (...entries: ReadonlyArray) => @@ -223,50 +175,97 @@ describe("selectGitHubBaseRepository", () => { }); }); -describe("GitHubCli.listPullRequestsByHead", () => { - const remoteOutput = - "origin\tgit@github.com:acme/web.git (fetch)\norigin\tgit@github.com:acme/web.git (push)\n"; - const node = (number: number, headRefName: string) => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - baseRefName: "main", - headRefName, - state: "MERGED", - mergedAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-02T00:00:00Z", - headRepository: { name: "web", nameWithOwner: "acme/web" }, - headRepositoryOwner: { login: "acme" }, +describe("GitHubCli repository resolution", () => { + it.effect("reads the repository gh would pick from the remotes", () => { + const paths: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + paths.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "trunk", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); + assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); }); - const decodeRequest = Schema.decodeSync( - Schema.fromJsonString( - Schema.Struct({ - query: Schema.String, - variables: Schema.Record(Schema.String, Schema.Unknown), - }), - ), - ); - const jsonOutput = (value: unknown) => processOutput(JSON.stringify(value)); - const git = (input: VcsProcess.VcsProcessInput) => - input.args[0] === "remote" - ? processOutput(remoteOutput) - : { ...processOutput(""), exitCode: ChildProcessSpawner.ExitCode(1) }; - it.effect("reads heads on one repository in one GraphQL document", () => - Effect.gen(function* () { - const documents: Array<{ query: string; variables: Record }> = []; - mockRun.mockImplementation((input) => - Effect.sync(() => { - if (input.command === "git") return git(input); - documents.push(decodeRequest(input.stdin ?? "")); - return jsonOutput({ - data: { - repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } }, - rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" }, - }, - }); - }), + it.effect("reads an SSH alias remote through github.com", () => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github:acme/web.git"]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + yield* gh.getDefaultBranch({ cwd: "/repo" }); + // A provider's host hint for the same alias (`github` here) resolves the same way. + yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + assert.strictEqual( + GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), + "github.example.com", ); + assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails clearly when no remote is on GitHub", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), + api: {}, + }); + return Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubCliCommandError"); + assert.include(String((error.cause as Error).message), "No GitHub repository"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubCli.listPullRequestsByHead", () => { + const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); + + it.effect("reads heads on one repository in one GraphQL document", () => { + const documents: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + documents.push(input); + return encodeJson({ + data: { repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } } }, + }); + }), + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; const lookups = yield* Effect.all( ["feature/a", "feature/b"].map((headSelector) => @@ -283,12 +282,11 @@ describe("GitHubCli.listPullRequestsByHead", () => { yield* TestClock.adjust("50 millis"); const [first, second] = yield* Fiber.join(lookups); assert.deepStrictEqual( - first?.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]), - [[7, "merged", "acme/web"]], + first?.map((pr) => pr.number), + [7], ); assert.deepStrictEqual(second, []); assert.strictEqual(documents.length, 1); - assert.include(documents[0]!.query, "rateLimit"); assert.deepStrictEqual(documents[0]!.variables, { owner: "acme", name: "web", @@ -297,701 +295,303 @@ describe("GitHubCli.listPullRequestsByHead", () => { h1: "feature/b", s1: ["OPEN", "CLOSED", "MERGED"], }); - }).pipe(Effect.provide(layer)), - ); + }).pipe(Effect.provide(layer)); + }); - it.effect("asks gh pr list when gh could read another repository", () => - Effect.gen(function* () { - const commands: Array> = []; - mockRun.mockImplementation((input) => - Effect.sync(() => { - commands.push([input.command, ...input.args]); - if (input.command === "git") { - return processOutput( - input.args[0] === "remote" - ? "a\tgit@github.com:me/web.git (fetch)\nb\tgit@github.com:acme/web.git (fetch)\n" - : "", - ); - } - return input.args[3] === "feature/empty" - ? processOutput("") - : jsonOutput([node(8, "feature/a")]); - }), - ); + it.effect("matches an owner:branch selector on the head owner", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.succeed( + encodeJson({ + data: { + repository: { + h0: { + nodes: + input.variables?.h0 === "main" + ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] + : [], + }, + }, + }, + }), + ), + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - const pullRequests = yield* gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: "feature/a", - state: "all", - limit: 100, - rateLimitHost: "github.com", - }); + const open = yield* gh + .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("50 millis"); assert.deepStrictEqual( - pullRequests.map((pr) => pr.number), + (yield* Fiber.join(open)).map((pr) => pr.number), [8], ); - assert.deepStrictEqual(commands.at(-1), [ - "gh", - "pr", - "list", - "--head", - "feature/a", - "--state", - "all", - "--limit", - "100", - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ]); - const empty = yield* gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: "feature/empty", - state: "all", - limit: 100, - rateLimitHost: "github.com", - }); - assert.deepStrictEqual(empty, []); - }).pipe(Effect.provide(layer)), - ); + }).pipe(Effect.provide(layer)); + }); - it.effect("fails a rate-limited document whole instead of asking head by head", () => - Effect.gen(function* () { - let ghCalls = 0; - mockRun.mockImplementation((input) => { - if (input.command === "git") return Effect.succeed(git(input)); - ghCalls++; - return Effect.fail( - new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh", - cwd: "/repo", - exitCode: 1, - failureKind: "rate-limited", - detail: "API rate limit exceeded.", - stderrLength: 24, - stderrTruncated: false, - }), - ); - }); + it.effect("maps API failures onto the errors callers handle", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.fail( + input.variables?.h0 === "missing" + ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) + : new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "x", + retryAt: 123, + }), + ), + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - const lookups = yield* Effect.all( - ["feature/a", "feature/b"].map((headSelector) => - gh - .listPullRequestsByHead({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }) - .pipe(Effect.flip), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); + const read = (headSelector: string) => + gh + .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) + .pipe(Effect.flip, Effect.forkChild); + const missing = yield* read("missing"); yield* TestClock.adjust("50 millis"); - const errors = yield* Fiber.join(lookups); - assert.deepStrictEqual( - errors.map((error) => error._tag), - ["GitHubCliRateLimitError", "GitHubCliRateLimitError"], - ); - assert.strictEqual(ghCalls, 1); - }).pipe(Effect.provide(layer)), - ); -}); - -describe("GitHubCli.layer", () => { - it.effect("shares the registry budget with CLI reads through nested layer providers", () => - Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const gh = yield* GitHubCli.GitHubCli; - yield* budget.observe("github.com", quotaOutput(0).stdout); - const error = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }).pipe(Effect.flip); + assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); + const limited = yield* read("limited"); + yield* TestClock.adjust("50 millis"); + const error = yield* Fiber.join(limited); assert.strictEqual(error._tag, "GitHubCliRateLimitError"); - expect(mockRun).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layer.pipe(Layer.provide(GitHubGraphQlBudget.layer)))), - ); + assert.propertyVal(error, "retryAt", 123); + }).pipe(Effect.provide(layer)); + }); +}); - it.effect("keeps quota snapshots separate for verified credentials on the same host", () => - Effect.gen(function* () { - let reads = 0; - const gh = yield* GitHubCli.make.pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => - Effect.sync(() => { - if (isBudgetReading(input)) { - return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000); - } - reads++; - return processOutput("[]"); - }), - }), - ); - const read = (token: string) => - gh.execute({ cwd: "/repo", args: ["pr", "list", "--repo", "github.com/acme/web"] }).pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, { - host: "github.com", - token: Redacted.make(token), - credentialFingerprint: token, +describe("GitHubCli.getPullRequest", () => { + it.effect("reads a pull request by number, and by URL on its own repository", () => { + const variables: Array = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: (input) => + Effect.sync(() => { + variables.push(input.variables); + return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); }), - ); - yield* read("empty").pipe(Effect.flip); - yield* read("healthy"); - yield* read("empty").pipe(Effect.flip); - assert.strictEqual(reads, 1); - }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), - ); - - it.effect("pins concurrent cached commands to their own verified credentials", () => - Effect.gen(function* () { - mockRun.mockImplementation((input) => - Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")), - ); + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - // Constructed outside either request, like the PR service's read caches. - const cache = yield* Cache.make({ - lookup: (host: string) => - gh.execute({ - cwd: "/repo", - args: ["api", "user", "--hostname", host], - env: { GH_DEBUG: "api", GH_TOKEN: "changed-after-verification" }, - }), - capacity: 2, - timeToLive: "1 minute", + assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); + yield* gh.getPullRequest({ + cwd: "/repo", + reference: "https://github.com/other/thing/pull/42", }); - const results = yield* Effect.forEach( - ["github.com", "github.example.test"], - (host, index) => - Cache.get(cache, host).pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, { - host, - token: Redacted.make(`credential-${index}`), - credentialFingerprint: `fingerprint-${index}`, - }), - ), - { concurrency: 2 }, - ); - expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]); - for (const [input] of mockRun.mock.calls) { - expect(input.env).toMatchObject({ - GH_HOST: input.args[3], - GH_DEBUG: "", - GH_TOKEN: input.env?.GITHUB_TOKEN, - GH_ENTERPRISE_TOKEN: input.env?.GH_TOKEN, - GITHUB_ENTERPRISE_TOKEN: input.env?.GH_TOKEN, - }); - } - expect((yield* gh.execute({ cwd: "/repo", args: ["api", "user"] })).stdout).toBe("ambient"); - }).pipe(Effect.provide(layer)), - ); + assert.deepStrictEqual(variables, [ + { owner: "acme", name: "web", number: 42 }, + { owner: "other", name: "thing", number: 42 }, + ]); + }).pipe(Effect.provide(layer)); + }); - it.effect("refuses other or implicit hosts before exposing a scoped credential to gh", () => - Effect.gen(function* () { + it.effect("fails a missing pull request as not found", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - for (const args of [ - ["api", "user", "--hostname", "other.example.test"], - ["api", "user", "--hostname=other.example.test"], - ["pr", "view", "1", "--repo", "other.example.test/owner/repo"], - ["repo", "view", "other.example.test/owner/repo", "--json", "name"], - ["api", "https://other.example.test/user", "--hostname", "github.com"], - ["api", "user"], - ]) { - const failure = yield* gh.execute({ cwd: "/repo", args }).pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, { - host: "github.com", - token: Redacted.make("secret-credential"), - credentialFingerprint: "fingerprint", - }), - Effect.flip, - ); - expect(failure._tag).toBe("GitHubCliCommandError"); - expect(yield* encodeGitHubCliError(failure)).not.toContain("secret-credential"); - } - expect(mockRun).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layer)), - ); + const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); + }).pipe(Effect.provide(layer)); + }); +}); - it.effect("pins repository-targeted writes on enterprise hosts", () => - Effect.gen(function* () { - mockRun.mockReturnValue(Effect.succeed(processOutput(""))); - const gh = yield* GitHubCli.GitHubCli; - yield* gh - .execute({ - cwd: "/repo", - args: ["pr", "merge", "1", "--repo", "github.example.test/owner/repo"], - }) - .pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, { - host: "github.example.test", - token: Redacted.make("enterprise-credential"), - credentialFingerprint: "fingerprint", - }), - ); - yield* gh - .execute({ - cwd: "/repo", - args: ["repo", "view", "github.example.test/owner/repo", "--json", "name"], - }) - .pipe( - Effect.provideService(GitHubCli.PinnedGitHubCredential, { - host: "github.example.test", - token: Redacted.make("enterprise-credential"), - credentialFingerprint: "fingerprint", +describe("GitHubCli writes", () => { + it.effect("creates a cross-repository pull request with an owner:branch head", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + requests.push(input); + return restResponse({ number: 1 }, 201); }), - ); - expect(mockRun.mock.calls[0]?.[0].env).toMatchObject({ - GH_HOST: "github.example.test", - GH_ENTERPRISE_TOKEN: "enterprise-credential", - GH_DEBUG: "", - }); - }).pipe(Effect.provide(layer)), - ); - - it("does not classify a missing cwd as an unavailable gh executable", () => { - const context = { command: "gh", cwd: "/repo" } as const; - const missingCwd = new VcsProcessSpawnError({ - operation: "GitHubCli.execute", - command: "gh", - cwd: context.cwd, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "access", - pathOrDescriptor: context.cwd, - }), + }, }); - - const commandFailure = GitHubCli.fromVcsError(context, missingCwd); - - assert.equal(commandFailure._tag, "GitHubCliCommandError"); - assert.strictEqual(commandFailure.cause, missingCwd); - assert.notProperty(commandFailure, "operation"); - }); - - it.effect("parses pull request view output", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "OPEN", - isDraft: true, - mergedAt: null, - updatedAt: "2026-08-24T12:34:56Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }), - ), - ), - ); - + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Body"); const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.getPullRequest({ + yield* gh.createPullRequest({ cwd: "/repo", - reference: "#42", + baseBranch: "main", + headSelector: "me:feature", + title: "Title", + bodyFile, }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - closedAt: null, - mergedAt: null, - isDraft: true, - updatedAt: "2026-08-24T12:34:56.000Z", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - expect(mockRun).toHaveBeenCalledWith({ - operation: "GitHubCli.execute", - command: "gh", - args: [ - "pr", - "view", - "#42", - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - cwd: "/repo", - timeoutMs: 30_000, + assert.strictEqual(requests[0]!.method, "POST"); + assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + assert.deepStrictEqual(requests[0]!.body, { + base: "main", + head: "me:feature", + title: "Title", + body: "Body", + maintainer_can_modify: true, }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("trims pull request fields decoded from gh json", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - number: 42, - title: " Add PR thread creation \n", - url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", - baseRefName: " main ", - headRefName: "\tfeature/pr-threads\t", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: " octocat/codething-mvp ", - }, - headRepositoryOwner: { - login: " octocat ", - }, - }), - ), - ), - ); + }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); + }); + it.effect("creates a repository under an organization the viewer is not", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "acme/new", + html_url: "https://github.com/acme/new", + ssh_url: "git@github.com:acme/new.git", + }); + }), + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.getPullRequest({ + const urls = yield* gh.createRepository({ cwd: "/repo", - reference: "#42", + repository: "acme/new", + visibility: "private", }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - closedAt: null, - mergedAt: null, - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("skips invalid entries when parsing pr lists", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 0, - title: "invalid", - url: "https://github.com/pingdotgg/codething-mvp/pull/0", - baseRefName: "main", - headRefName: "feature/invalid", - }, - { - number: 43, - title: " Valid PR ", - url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", - baseRefName: " main ", - headRefName: " feature/pr-list ", - headRepository: { - nameWithOwner: " ", - }, - headRepositoryOwner: { - login: " ", - }, - }, - ]), - ), - ), - ); - - const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.listOpenPullRequests({ - cwd: "/repo", - headSelector: "feature/pr-list", + assert.deepStrictEqual(urls, { + nameWithOwner: "acme/new", + url: "https://github.com/acme/new", + sshUrl: "git@github.com:acme/new.git", }); + assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); + }).pipe(Effect.provide(layer)); + }); +}); - assert.deepStrictEqual(result, [ - { - number: 43, - title: "Valid PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/43", - baseRefName: "main", - headRefName: "feature/pr-list", - state: "open", - closedAt: null, - mergedAt: null, - }, - ]); - }).pipe(Effect.provide(layer)), - ); +describe("GitHubCli.checkoutPullRequest", () => { + const repository = (fullName: string, defaultBranch = "main") => + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: defaultBranch, + }); - it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () => - // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as - // {id, name} only. These entries must decode instead of being dropped, - // with nameWithOwner rebuilt from the owner login. - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([ - { - number: 2829, - title: "Codex turn mapping", - url: "https://github.com/pingdotgg/codething-mvp/pull/2829", - baseRefName: "main", - headRefName: "t3code/codex-turn-mapping", - state: "OPEN", - mergedAt: null, - isCrossRepository: false, - headRepository: { - id: "R_kgDORLtfbQ", - name: "codething-mvp", - }, - headRepositoryOwner: { - id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3", - login: "pingdotgg", - }, - }, - ]), + it.effect("checks a same-repository pull request out from its head branch", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), ), - ), - ); - + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.listOpenPullRequests({ - cwd: "/repo", - headSelector: "t3code/codex-turn-mapping", - }); - - assert.deepStrictEqual(result, [ - { - number: 2829, - title: "Codex turn mapping", - url: "https://github.com/pingdotgg/codething-mvp/pull/2829", - baseRefName: "main", - headRefName: "t3code/codex-turn-mapping", - state: "open", - closedAt: null, - mergedAt: null, - isCrossRepository: false, - headRepositoryNameWithOwner: "pingdotgg/codething-mvp", - headRepositoryOwnerLogin: "pingdotgg", - }, + yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); + assert.deepStrictEqual(git, [ + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], + ["switchRef", { cwd: "/repo", refName: "feature/x" }], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, + ], ]); - }).pipe(Effect.provide(layer)), - ); + }).pipe(Effect.provide(layer)); + }); - it.effect("reads repository clone URLs", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", + it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: () => + Effect.fail( + new GitHubApi.GitHubApiRequestError({ + host: "github.com", + operation: "x", + cause: "offline", }), ), - ), - ); - + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "octocat/codething-mvp", - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }); - }).pipe(Effect.provide(layer)), - ); + yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); + // Nothing touched the local branches: `main` must not be reset to the fork's commit. + assert.deepStrictEqual(git, []); + }).pipe(Effect.provide(layer)); + }); - it.effect("creates repositories and parses clone URLs from create output", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n", + it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["someone/main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), ), - ), - ); - - const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.createRepository({ - cwd: "/repo", - repository: "octocat/codething-mvp", - visibility: "private", - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }); - expect(mockRun).toHaveBeenCalledTimes(1); - expect(mockRun).toHaveBeenNthCalledWith(1, { - operation: "GitHubCli.execute", - command: "gh", - args: ["repo", "create", "octocat/codething-mvp", "--private"], - cwd: "/repo", - timeoutMs: 30_000, - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("falls back to constructed URLs when create output omits a URL", () => - Effect.gen(function* () { - mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const gh = yield* GitHubCli.GitHubCli; - const result = yield* gh.createRepository({ - cwd: "/repo", - repository: "octocat/codething-mvp", - visibility: "private", - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }); - }).pipe(Effect.provide(layer)), - ); - - it.effect("surfaces a friendly error when the pull request is not found", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh pr view", - cwd: "/repo", - exitCode: 1, - failureKind: "not-found", - detail: - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - + rest: (input) => + Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), + }, + }); + return Effect.gen(function* () { const gh = yield* GitHubCli.GitHubCli; - const error = yield* gh - .getPullRequest({ - cwd: "/repo", - reference: "4888", - }) - .pipe(Effect.flip); - - assert.equal(error.message.includes("Pull request not found"), true); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - assert.strictEqual(error.command, "gh"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); - }).pipe(Effect.provide(layer)), - ); + yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); + assert.deepStrictEqual(git, [ + [ + "ensureRemote", + { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, + ], + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, + ], + ["switchRef", { cwd: "/repo", refName: "someone/main" }], + ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); - it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitHubCli.execute", - command: "gh", - cwd: "/repo", - exitCode: 1, - failureKind: "rate-limited", - detail: "API rate limit exceeded.", - stderrLength: 82, - stderrTruncated: false, + it("names the local branch the way gh pr checkout does", () => { + const name = (headRefName: string, isCrossRepository: boolean) => + GitHubCli.pullRequestCheckoutBranchName({ + headRefName, + headOwner: "someone", + isCrossRepository, + defaultBranch: "main", }); - mockRun.mockReturnValueOnce(Effect.fail(cause)); - - const gh = yield* GitHubCli.GitHubCli; - const error = yield* gh - .listOpenPullRequests({ - cwd: "/repo", - headSelector: "feature/rate-limited", - }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "GitHubCliRateLimitError"); - assert.include(error.detail, "GitHub API rate limit exceeded"); - assert.include(error.detail, "gh api rate_limit"); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, "user ID"); - const paused = yield* gh - .execute({ cwd: "/other-repo", args: ["pr", "list"] }) - .pipe(Effect.flip); - assert.strictEqual(paused._tag, "GitHubCliRateLimitError"); - expect(mockRun).toHaveBeenCalledTimes(1); - yield* TestClock.adjust("30 seconds"); - mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); - yield* gh.execute({ cwd: "/other-repo", args: ["pr", "list"] }); - expect(mockRun).toHaveBeenCalledTimes(2); - }).pipe(Effect.provide(layer)), - ); + assert.strictEqual(name("main", true), "someone/main"); + assert.strictEqual(name("feature", true), "feature"); + assert.strictEqual(name("main", false), "main"); + }); }); - -it.effect("accepts conditional 304 responses and preserves HTTP errors and retry delays", () => - Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - const request = { - cwd: "/repo", - args: [ - "api", - "repos/acme/web/pulls/1", - "--hostname", - "github.com", - "--include", - "-H", - 'If-None-Match: "one"', - ], - acceptNotModified: true, - }; - const respond = (status: number, headers = "") => - mockRun.mockImplementation(() => - Effect.succeed({ - ...processOutput(`HTTP/2.0 ${status}\r\n${headers}\r\n`), - exitCode: ChildProcessSpawner.ExitCode(1), - }), - ); - respond(304); - expect((yield* gh.execute(request)).stdout).toContain("304"); - expect(mockRun.mock.calls[0]?.[0].allowNonZeroExit).toBe(true); - respond(401); - expect((yield* gh.execute(request).pipe(Effect.flip))._tag).toBe( - "GitHubCliAuthenticationError", - ); - respond(403); - expect((yield* gh.execute(request).pipe(Effect.flip))._tag).toBe("GitHubCliCommandError"); - for (const status of [403, 429]) { - respond(status, "Retry-After: 120\r\n"); - expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({ - _tag: "GitHubCliRateLimitError", - retryAt: (yield* Clock.currentTimeMillis) + 120_000, - }); - } - respond( - 403, - `X-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: ${Math.floor((yield* Clock.currentTimeMillis) / 1_000) + 60}\r\n`, - ); - expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({ - _tag: "GitHubCliRateLimitError", - retryAt: (yield* Clock.currentTimeMillis) + 60_000, - }); - respond(500); - expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({ - _tag: "GitHubCliCommandError", - httpStatus: 500, - }); - }).pipe(Effect.provide(layer)), -); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 88ddf1349e86..95501d3c163a 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,77 +1,38 @@ -import * as Cache from "effect/Cache"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; -import * as Exit from "effect/Exit"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; -import * as Redacted from "effect/Redacted"; import * as Request from "effect/Request"; import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { - TrimmedNonEmptyString, - type SourceControlRepositoryVisibility, - type VcsError, -} from "@t3tools/contracts"; +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { decodeGitHubPullRequestEntries, - decodeGitHubPullRequestJson, - decodeGitHubPullRequestListJson, type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; -const DEFAULT_TIMEOUT_MS = 30_000; - -/** Server-local credential scope; never put its value in RPC payloads or cache keys. */ -export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; - export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; -function commandHosts(args: ReadonlyArray): Array { - const hosts: Array = []; - const repositoryHost = (repository: string | undefined) => { - if (repository === undefined) return null; - if (/^https?:\/\//i.test(repository)) { - try { - return new URL(repository).host.toLowerCase(); - } catch { - return null; - } - } - const parts = repository.split("/"); - return parts.length === 3 ? parts[0]!.toLowerCase() : null; - }; - if (args[0] === "repo" && args[1] === "view") hosts.push(repositoryHost(args[2])); - for (let index = 0; index < args.length; index++) { - const arg = args[index]!; - if (arg === "--hostname") hosts.push(args[++index]?.toLowerCase() ?? null); - else if (arg.startsWith("--hostname=")) hosts.push(arg.slice(11).toLowerCase()); - else if (arg === "--repo" || arg === "-R") hosts.push(repositoryHost(args[++index])); - else if (arg.startsWith("--repo=")) hosts.push(repositoryHost(arg.slice(7))); - else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2))); - else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg)); - } - return hosts; -} - -function targetsVerifiedHost(args: ReadonlyArray, host: string): boolean { - const hosts = commandHosts(args); - return hosts.length > 0 && hosts.every((target) => target === host); -} - const gitHubCliFailureFields = { command: Schema.Literal("gh"), cwd: Schema.String, @@ -82,12 +43,8 @@ export class GitHubCliUnavailableError extends Schema.TaggedError; - readonly timeoutMs?: number; - /** Piped to the child's stdin, for payloads that must never appear in argv. */ - readonly stdin?: string; - readonly env?: NodeJS.ProcessEnv; - readonly maxOutputBytes?: number; - readonly rateLimitHost?: string; - readonly allowReserve?: boolean; - readonly acceptNotModified?: boolean; - }) => Effect.Effect; - readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; @@ -302,16 +214,16 @@ export class GitHubCli extends Context.Service< }) => Effect.Effect, GitHubCliError>; /** - * Pull requests whose head is `headSelector`, in the repository `gh pr list` would read in - * `cwd`. Lookups on one repository that arrive together share one GraphQL document; a - * checkout whose repository gh could pick another way is asked through `gh pr list`. + * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the + * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive + * together share one GraphQL document. */ readonly listPullRequestsByHead: (input: { readonly cwd: string; readonly headSelector: string; readonly state: "open" | "closed" | "merged" | "all"; readonly limit: number; - /** The checkout's GitHub host. Without it the lookup is not batched. */ + /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ readonly rateLimitHost?: string; }) => Effect.Effect, GitHubCliError>; @@ -353,67 +265,73 @@ export class GitHubCli extends Context.Service< } >()("t3/sourceControl/GitHubCli") {} -const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ - nameWithOwner: TrimmedNonEmptyString, - url: TrimmedNonEmptyString, - sshUrl: TrimmedNonEmptyString, +/** + * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a + * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin + * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of + * `git config --get-regexp '^remote\..*\.gh-resolved$'`. + * + * Null whenever gh might weigh the remotes differently: a remote on another host or under an + * SSH alias, more than one mark, or several remotes with none of those names. Callers then + * fall back to the provider's remote, then to the best-ranked remote on the host. + */ +export function selectGitHubBaseRepository(input: { + readonly remotes: string; + readonly resolved: string; + readonly host: string; +}): { readonly owner: string; readonly name: string } | null { + const host = input.host.toLowerCase(); + const repositories = new Map(); + for (const line of input.remotes.split("\n")) { + const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); + if (!match) continue; + const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); + if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + repositories.set(match[1]!, { owner, name }); + } + const marks = input.resolved + .split("\n") + .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); + if (marks.length > 1) return null; + const [mark] = marks; + if (mark) { + if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; + const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); + return owner && name && rest.length === 0 ? { owner, name } : null; + } + // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the + // top place has no defined winner. + const score = (remoteName: string) => + ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; + const ranked = [...repositories.entries()].toSorted( + ([left], [right]) => score(right) - score(left), + ); + const [top, next] = ranked; + return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) + ? top[1] + : null; +} + +const RawRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + ssh_url: TrimmedNonEmptyString, + default_branch: Schema.optional(Schema.NullOr(Schema.String)), }); -const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect( - Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema), -); +const decodeRawRepository = decodeJsonResult(RawRepositorySchema); -function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, +function repositoryCloneUrls( + raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { - return { - nameWithOwner: raw.nameWithOwner, - url: raw.url, - sshUrl: raw.sshUrl, - }; + return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; } -/** - * `gh repo create` prints the canonical URL of the new repository on stdout - * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a - * follow-up `gh repo view`, which can race GitHub's GraphQL eventual - * consistency window and falsely report the just-created repo as missing. - */ -function deriveRepositoryCloneUrlsFromCreateOutput( - stdout: string, - repository: string, -): GitHubRepositoryCloneUrls { - const fallbackHost = "github.com"; - const match = stdout.match(/https?:\/\/[^\s]+/); - if (match) { - const cleaned = match[0].replace(/\.git$/, ""); - try { - const parsed = new URL(cleaned); - const pathname = parsed.pathname.replace(/^\/+|\/+$/g, ""); - const segments = pathname.split("/").filter(Boolean); - if (segments.length === 2) { - const nameWithOwner = `${segments[0]}/${segments[1]}`; - return { - nameWithOwner, - url: `${parsed.origin}/${nameWithOwner}`, - sshUrl: `git@${parsed.host}:${nameWithOwner}.git`, - }; - } - } catch { - // Fall through to the input-derived defaults below. - } - } - return { - nameWithOwner: repository, - url: `https://${fallbackHost}/${repository}`, - sshUrl: `git@${fallbackHost}:${repository}.git`, - }; -} +const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); type PullRequestListState = "open" | "closed" | "merged" | "all"; -const PULL_REQUEST_LIST_JSON_FIELDS = - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner"; -/** The `gh pr list --json` fields above, as GraphQL selects them. */ +/** The pull request fields every read selects, in GraphQL. */ const PULL_REQUEST_NODE_SELECTION = "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; const GRAPHQL_STATES: Record> = { @@ -424,7 +342,7 @@ const GRAPHQL_STATES: Record> = { }; /** * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * `gh pr list`, but half that keeps each answer near half a second. + * single lookup, but half that keeps each answer near half a second. */ const HEAD_LOOKUPS_PER_DOCUMENT = 50; /** @@ -433,8 +351,13 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50; */ const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; /** A full document is 5,000 rows of well under 2 KB each. */ -const HEAD_LOOKUP_MAX_OUTPUT_BYTES = 16_000_000; -const RATE_LIMIT_READING = "query { rateLimit { cost limit remaining resetAt } }"; +const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; +/** + * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch + * named like a busy default (`main`) is the case this bounds; the owner's own row is near the + * top because the newest come first. + */ +const OWNER_HEAD_SCAN_LIMIT = 100; class PullRequestsByHeadRead extends Request.Class< { @@ -445,25 +368,17 @@ class PullRequestsByHeadRead extends Request.Class< readonly headRefName: string; readonly state: PullRequestListState; readonly limit: number; + readonly allowReserve: boolean; }, ReadonlyArray, GitHubCliError > {} -const GraphQlVariables = Schema.Record( - Schema.String, - Schema.Union([Schema.String, Schema.Array(Schema.String)]), -); -/** A GraphQL request body for `gh api graphql --input -`. */ -const encodeGraphQlRequest = Schema.encodeSync( - Schema.fromJsonString(Schema.Struct({ query: Schema.String, variables: GraphQlVariables })), -); - /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ function buildPullRequestsByHeadQuery( lookups: ReadonlyArray>, -): { readonly document: string; readonly variables: typeof GraphQlVariables.Type } { - const variables: Record> = {}; +): { readonly document: string; readonly variables: Record } { + const variables: Record = {}; const declarations: string[] = ["$owner: String!", "$name: String!"]; const selections: string[] = []; for (const [index, lookup] of lookups.entries()) { @@ -482,419 +397,303 @@ function buildPullRequestsByHeadQuery( }; } -/** The reset time of a `rateLimit` reading, which sets when the next one is due. */ -const decodeRateLimitReading = (raw: string) => - Result.map( - decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ rateLimit: Schema.Struct({ resetAt: Schema.String }) }), - }), - )(raw), - (reading) => reading.data.rateLimit.resetAt, - ); +const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } + } +}`; const decodePullRequestsByHead = decodeJsonResult( Schema.Struct({ data: Schema.Struct({ - repository: Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), + ), ), }), }), ); +const decodePullRequestByNumber = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), + }), + }), +); + +/** A repository on a GitHub host: the API it is read through, and its owner and name. */ +export interface GitHubRepositoryLocator { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ +function parseGitHubRepositorySelector( + selector: string, + defaultHost: string, +): GitHubRepositoryLocator | null { + const trimmed = selector.trim().replace(/\.git$/i, ""); + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); + return owner && name && rest.length === 0 + ? { host: url.host.toLowerCase(), owner, name } + : null; + } catch { + return null; + } + } + const parts = trimmed.split("/").filter(Boolean); + if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; + if (parts.length === 3) + return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; + return null; +} + /** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * ask gh itself. + * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request + * URL, or a branch name. */ -export function selectGitHubBaseRepository(input: { - readonly remotes: string; - readonly resolved: string; - readonly host: string; -}): { readonly owner: string; readonly name: string } | null { - const host = input.host.toLowerCase(); - const repositories = new Map(); - for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); - if (!match) continue; - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(match[1]!, { owner, name }); +function parsePullRequestReference( + reference: string, +): + | { readonly kind: "number"; readonly number: number } + | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } + | { readonly kind: "branch"; readonly headSelector: string } { + const trimmed = reference.trim(); + const numbered = /^#?([1-9]\d*)$/.exec(trimmed); + if (numbered) return { kind: "number", number: Number(numbered[1]) }; + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); + if (match) { + return { + kind: "url", + locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, + number: Number(match[3]), + }; + } + } catch { + // Not a URL after all; read it as a branch. + } } - const marks = input.resolved - .split("\n") - .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); - if (marks.length > 1) return null; - const [mark] = marks; - if (mark) { - if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; - const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); - return owner && name && rest.length === 0 ? { owner, name } : null; + return { kind: "branch", headSelector: trimmed }; +} + +/** + * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH + * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through + * `github.com`, which is what such an alias almost always stands for (issue #6198). + */ +export function gitHubApiHostForRemote(remoteUrl: string): string | null { + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider === null) return null; + const host = new URL(provider.baseUrl).host.toLowerCase(); + // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. + if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { + return host.includes("github") ? "github.com" : null; } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. - const score = (remoteName: string) => - ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; - const ranked = [...repositories.entries()].toSorted( - ([left], [right]) => score(right) - score(left), - ); - const [top, next] = ranked; - return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) - ? top[1] - : null; + return provider.kind === "github" ? host : null; +} + +/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ +function apiHostForHint(host: string): string { + const normalized = host.toLowerCase(); + return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; +} + +/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ +export function pullRequestCheckoutBranchName(input: { + readonly headRefName: string; + readonly headOwner: string | null; + readonly isCrossRepository: boolean; + readonly defaultBranch: string | null; +}): string { + // gh prefixes the owner only where the fork's branch would take over the default branch's + // name, which is the one collision every fork pull request from `main` would hit. + return input.isCrossRepository && + input.headOwner !== null && + input.defaultBranch !== null && + input.headRefName === input.defaultBranch + ? `${input.headOwner}/${input.headRefName}` + : input.headRefName; } /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const limits = yield* SourceControlRateLimit.SourceControlRateLimit; - - const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( - function* (input) { - const credential = yield* PinnedGitHubCredential; - if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) { - return yield* new GitHubCliCommandError({ - command: "gh", - cwd: input.cwd, - cause: new Error("The GitHub command does not target the verified credential's host."), - }); - } - const token = credential === null ? undefined : Redacted.value(credential.token); - const env = - credential === null - ? input.env - : { - ...input.env, - GH_HOST: credential.host, - GH_TOKEN: token, - GITHUB_TOKEN: token, - GH_ENTERPRISE_TOKEN: token, - GITHUB_ENTERPRISE_TOKEN: token, - GH_DEBUG: "", - }; - const result = yield* process - .run({ - operation: "GitHubCli.execute", - command: "gh", - args: input.args, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - ...(input.acceptNotModified ? { allowNonZeroExit: true } : {}), - ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), - ...(env !== undefined ? { env } : {}), - ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), - }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); - if (result.exitCode !== 0 && input.acceptNotModified) { - const status = /^HTTP\/\S+ (\d+)/.exec(result.stdout)?.[1]; - if (status !== "304" || !input.args.includes("--include")) { - const context = { command: "gh" as const, cwd: input.cwd, cause: undefined }; - const headers = result.stdout.split(/\r?\n\r?\n/, 1)[0] ?? ""; - const header = (name: string) => - new RegExp(`^${name}:\\s*(.*)$`, "im").exec(headers)?.[1]?.trim(); - if ( - status === "429" || - (status === "403" && - (header("x-ratelimit-remaining") === "0" || - header("retry-after") !== undefined || - /rate limit/i.test(result.stderr))) - ) { - const now = DateTime.toEpochMillis(yield* DateTime.now); - const reset = Number(header("x-ratelimit-reset")) * 1_000; - const retryAt = - SourceControlRateLimit.retryAtFromHeader(header("retry-after"), now) ?? - (Number.isFinite(reset) && reset > now ? reset : undefined); - return yield* new GitHubCliRateLimitError({ - ...context, - ...(retryAt === undefined ? {} : { retryAt }), - }); - } - if (status === "401") return yield* new GitHubCliAuthenticationError(context); - return yield* new GitHubCliCommandError({ - ...context, - ...(status === undefined ? {} : { httpStatus: Number(status) }), - }); - } - } - return result; - }, - ); + const environment = yield* HostProcessEnvironment; + const api = yield* GitHubApi.GitHubApi; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; - /** - * A GraphQL `rateLimit` reading per host and credential, so the budget knows the balance - * before reads that cannot report their own cost (`gh pr list`). GraphQL documents keep it - * current in between. Each reading costs one point and is redone at the reset, or after ten - * minutes so a `gh auth switch` is not priced against the old account for a whole hour. - */ - const budgetReading = yield* Cache.makeWith( - (key: string) => { - const host = key.split("\0")[0]!; - return executeRaw({ - cwd: globalThis.process.cwd(), - args: ["api", "graphql", "--hostname", host, "-f", `query=${RATE_LIMIT_READING}`], - }).pipe( - Effect.tap((result) => budget.observe(host, result.stdout)), - Effect.flatMap((result) => - Clock.currentTimeMillis.pipe( - Effect.map((now) => { - const resetAtMs = Date.parse( - decodeRateLimitReading(result.stdout).pipe( - Result.match({ onFailure: () => "", onSuccess: (reading) => reading }), - ), - ); - // A reset that is missing or already past (a skewed clock) waits a minute, so a - // bad reading cannot ask again on every read. - return Duration.millis( - Number.isFinite(resetAtMs) && resetAtMs > now - ? Math.min(resetAtMs - now, 600_000) - : 60_000, - ); - }), - ), - ), - ); - }, - { - capacity: 32, - timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value : Duration.minutes(1)), - }, - ); + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubCli.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + const commandFailure = (cwd: string, detail: string) => + new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); /** - * Runs a GraphQL-priced read under its host's pause and the GraphQL budget. `run` receives - * the document with `rateLimit` added, so a GraphQL read can report what it spent; a CLI read - * ignores it and is priced at one point. + * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else + * the remote the caller resolved the provider from, else the best-ranked GitHub remote. */ - const guardedRead = (input: { + const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { readonly cwd: string; - readonly host: string; - readonly document: string; - readonly allowReserve: boolean; - readonly run: (document: string) => Effect.Effect; - }) => - Effect.gen(function* () { - const credential = yield* PinnedGitHubCredential; - const key = { provider: "github" as const, host: input.host }; - const guarded = Effect.gen(function* () { - const lease = yield* limits.check( - key, - input.allowReserve ? { allowPaused: true } : undefined, - ); - // A failed reading leaves the budget unknown; it never blocks the read itself. - yield* Cache.get( - budgetReading, - `${input.host}\0${yield* SourceControlRateLimit.CredentialScope}`, - ).pipe(Effect.ignore); - return yield* budget - .query( - input.host, - input.document, - input.allowReserve ? { allowReserve: true } : undefined, - ) - .pipe( - Effect.flatMap(input.run), - Effect.tap(() => limits.recordSuccess({ ...key, lease })), - Effect.tapError((error) => - error._tag === "GitHubCliRateLimitError" - ? limits.recordRateLimit({ ...key, lease }) - : Effect.void, - ), - ); - }); - return yield* guarded.pipe( - Effect.provideService( - SourceControlRateLimit.CredentialScope, - credential?.credentialFingerprint ?? (yield* SourceControlRateLimit.CredentialScope), - ), - Effect.catchTags({ - SourceControlRateLimitPausedError: (cause) => - Effect.fail( - new GitHubCliRateLimitError({ - command: "gh", - cwd: input.cwd, - retryAt: cause.retryAt, - cause, - }), - ), - }), - ); + readonly host?: string | undefined; + }) { + const envRepository = environment.GH_REPO?.trim(); + const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + if (envRepository) { + const locator = parseGitHubRepositorySelector(envRepository, defaultHost); + if (locator !== null) return locator; + } + const [remotes, resolved] = yield* Effect.all([ + gitRead(input.cwd, ["remote", "-v"]), + gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); + const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; + const fetchRemotes = remoteOutput + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ + name: match[1]!, + url: match[2]!, + host: gitHubApiHostForRemote(match[2]!), + })); + const host = + (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? + fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? + fetchRemotes.find((remote) => remote.host !== null)?.host ?? + defaultHost; + const selected = selectGitHubBaseRepository({ + remotes: remoteOutput, + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + host, }); - - const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( - function* (input) { - const [command, action] = input.args; - if ( - !( - (command === "pr" && (action === "list" || action === "view")) || - (command === "repo" && action === "view") - ) - ) - return yield* executeRaw(input); - const credential = yield* PinnedGitHubCredential; - if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) - return yield* executeRaw(input); - return yield* guardedRead({ - cwd: input.cwd, - host: ( - credential?.host ?? - commandHosts(input.args).find((host) => host !== null) ?? - input.rateLimitHost ?? - input.env?.GH_HOST ?? - globalThis.process.env.GH_HOST ?? - "github.com" - ).toLowerCase(), - document: "query {}", - allowReserve: input.allowReserve ?? (yield* AllowGitHubReserve), - run: () => executeRaw(input), + if (selected !== null) return { host, ...selected }; + // gh's own order without its prompt: upstream, github, origin, then the first remote, among + // the ones on this host (an SSH alias counts as its API host). + const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); + const candidates = fetchRemotes + .filter((remote) => remote.host === host) + .toSorted((left, right) => { + const l = rank(left.name); + const r = rank(right.name); + return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); }); - }, - ); + for (const remote of candidates) { + const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); + if (owner && name && rest.length === 0) return { host, owner, name }; + } + return yield* commandFailure( + input.cwd, + `No GitHub repository on ${host} was found among this checkout's git remotes.`, + ); + }); - const listPullRequestsWithCli = (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly rateLimitHost?: string | undefined; - }) => - execute({ - cwd: input.cwd, - ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - input.state, - "--limit", - String(input.limit), - "--json", - PULL_REQUEST_LIST_JSON_FIELDS, - ], - }).pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) return Effect.succeed([]); - const decoded = decodeGitHubPullRequestListJson(raw); + const graphqlJson = ( + cwd: string, + input: GitHubApi.GitHubGraphQlInput, + decode: (raw: string) => Result.Result, + onDecodeFailure: (cause: unknown) => GitHubCliError, + ) => + api.graphql(input).pipe( + Effect.mapError((error) => fromGitHubApiError(cwd, error)), + Effect.flatMap((raw) => { + const decoded = decode(raw); return Result.isSuccess(decoded) ? Effect.succeed(decoded.success) - : Effect.fail( - new GitHubChangeRequestListDecodeError({ - command: "gh", - cwd: input.cwd, - cause: decoded.failure, - }), - ); + : Effect.fail(onDecodeFailure(decoded.failure)); }), ); - const git = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubCli.baseRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); + const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => + api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); - /** The repository gh reads in `cwd`, or null when gh could pick it another way. */ - const resolveBaseRepository = (cwd: string, host: string) => - globalThis.process.env.GH_REPO - ? Effect.succeed(null) - : Effect.all([ - git(cwd, ["remote", "-v"]), - git(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe( - Effect.map(([remotes, resolved]) => - remotes.exitCode === 0 - ? selectGitHubBaseRepository({ - remotes: remotes.stdout, - resolved: resolved.exitCode === 0 ? resolved.stdout : "", - host, - }) - : null, - ), - Effect.orElseSucceed(() => null), - ); + /** Pull requests whose head branch is `headRefName` on one repository. */ + const readPullRequestsByHead = (input: { + readonly cwd: string; + readonly locator: GitHubRepositoryLocator; + readonly lookups: ReadonlyArray< + Pick + >; + readonly allowReserve: boolean; + readonly onDecodeFailure: (cause: unknown) => GitHubCliError; + }) => { + const query = buildPullRequestsByHeadQuery(input.lookups); + return graphqlJson( + input.cwd, + { + host: input.locator.host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, + allowReserve: input.allowReserve, + // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred + // fork pull requests, so the usual cap would cut the answer short. + maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, + }, + decodePullRequestsByHead, + input.onDecodeFailure, + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail( + new GitHubPullRequestNotFoundError({ + command: "gh", + cwd: input.cwd, + cause: new Error("The repository could not be read."), + }), + ) + : Effect.succeed(decoded.data.repository), + ), + ); + }; const headResolver = RequestResolver.makeGrouped({ key: ({ request, context }) => - JSON.stringify([ + [ request.host, request.owner, request.name, - Context.getOrElse(context, PinnedGitHubCredential, () => null)?.credentialFingerprint ?? - null, + String(request.allowReserve), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? "", Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), + ].join("\0"), resolver: (entries) => { const [first] = entries; - const { cwd, host, owner, name } = first.request; - const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); - const readCli = (entry: (typeof entries)[number]) => - listPullRequestsWithCli({ - cwd: entry.request.cwd, - headSelector: entry.request.headRefName, - state: entry.request.state, - limit: entry.request.limit, - rateLimitHost: entry.request.host, - }).pipe( - Effect.exit, - Effect.map((exit) => entry.completeUnsafe(exit)), - ); - return guardedRead({ + const { cwd, host, owner, name, allowReserve } = first.request; + return readPullRequestsByHead({ cwd, - host, - document: query.document, - allowReserve: false, - run: (document) => - executeRaw({ - cwd, - args: ["api", "graphql", "--hostname", host, "--input", "-"], - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a - // hundred fork pull requests, so the 1 MB default would cut the answer short. - maxOutputBytes: HEAD_LOOKUP_MAX_OUTPUT_BYTES, - stdin: encodeGraphQlRequest({ - query: document, - variables: { owner, name, ...query.variables }, - }), - }).pipe(Effect.tap((result) => budget.observe(host, result.stdout))), + locator: { host, owner, name }, + lookups: entries.map((entry) => entry.request), + allowReserve, + onDecodeFailure: (cause) => + new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), }).pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestsByHead(result.stdout); - if (!Result.isSuccess(decoded)) { - return Effect.forEach(entries, readCli, { discard: true }); + Effect.map((aliases) => { + for (const [index, entry] of entries.entries()) { + const alias = aliases[`h${index}`]; + entry.completeUnsafe( + Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), + ); } - const aliases = decoded.success.data.repository; - return Effect.forEach( - entries, - (entry, index) => { - const alias = aliases[`h${index}`]; - if (alias == null) return readCli(entry); - entry.completeUnsafe(Exit.succeed(decodeGitHubPullRequestEntries(alias.nodes))); - return Effect.void; - }, - { discard: true }, - ); }), - // A document GitHub refused as a whole (a renamed repository, a field an older - // Enterprise host lacks) leaves each lookup to gh. A rate limit fails them all: - // asking one at a time would only spend what the pause is saving. - Effect.catchIf( - (error) => error._tag !== "GitHubCliRateLimitError", - () => Effect.forEach(entries, readCli, { discard: true }), - ), Effect.catchCause((cause) => Effect.sync(() => { for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); @@ -907,174 +706,368 @@ export const make = Effect.gen(function* () { RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT), ); - const listPullRequestsByHead: GitHubCli["Service"]["listPullRequestsByHead"] = Effect.fn( - "GitHubCli.listPullRequestsByHead", - )(function* (input) { - const host = input.rateLimitHost?.toLowerCase(); - const credential = yield* PinnedGitHubCredential; - // `owner:branch` selectors and other hosts keep gh's own handling. - const repository = - host === undefined || - input.headSelector.includes(":") || - (credential !== null && credential.host !== host) - ? null - : yield* resolveBaseRepository(input.cwd, host); - if (host === undefined || repository === null) { - return yield* listPullRequestsWithCli(input); - } - return yield* Effect.request( + const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly rateLimitHost?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); + const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); + // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the + // owner is matched on the rows it returns. + const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); + const rows = yield* Effect.request( new PullRequestsByHeadRead({ cwd: input.cwd, - host, - owner: repository.owner, - name: repository.name, - headRefName: input.headSelector, + ...locator, + headRefName, state: input.state, - limit: Math.min(Math.max(Math.trunc(input.limit), 1), 100), + limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, + allowReserve: input.allowReserve, }), headResolver, ); + if (!ownerMatch) return rows; + const headOwner = ownerMatch[1]!.toLowerCase(); + return rows + .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) + .slice(0, limit); }); - return GitHubCli.of({ - execute, - listPullRequestsByHead, - listOpenPullRequests: (input) => - execute({ + const toSummaries = (rows: ReadonlyArray) => + rows.map(pullRequestSummary); + + const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { + readonly cwd: string; + readonly reference: string; + readonly rateLimitHost?: string | undefined; + }) { + const parsed = parsePullRequestReference(input.reference); + if (parsed.kind === "branch") { + // `gh pr view ` prefers an open pull request, then the newest of any state. + const [open] = yield* listByHead({ cwd: input.cwd, - ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), + headSelector: parsed.headSelector, + state: "open", + limit: 1, + rateLimitHost: input.rateLimitHost, allowReserve: true, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestListDecodeError({ - command: "gh", - cwd: input.cwd, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success.map(pullRequestSummary)); - }), - ), - ), - ), - getPullRequest: (input) => - execute({ - cwd: input.cwd, - ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), + }); + const found = + open ?? + (yield* listByHead({ + cwd: input.cwd, + headSelector: parsed.headSelector, + state: "all", + limit: 1, + rateLimitHost: input.rateLimitHost, + allowReserve: true, + }))[0]; + if (found === undefined) { + return yield* new GitHubPullRequestNotFoundError({ + command: "gh", + cwd: input.cwd, + cause: new Error("No pull request has this head branch."), + }); + } + return found; + } + const locator = + parsed.kind === "url" + ? parsed.locator + : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); + const decodeFailure = (cause: unknown) => + new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); + const decoded = yield* graphqlJson( + input.cwd, + { + host: locator.host, + operation: "getPullRequest", + query: PULL_REQUEST_BY_NUMBER_QUERY, + variables: { owner: locator.owner, name: locator.name, number: parsed.number }, allowReserve: true, - args: [ - "pr", - "view", - input.reference, - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestDecodeError({ - command: "gh", - cwd: input.cwd, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(pullRequestSummary(decoded.success)); - }), - ), - ), - ), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeRawGitHubRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitHubRepositoryDecodeError({ - command: "gh", - cwd: input.cwd, - cause, - }), - ), - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createRepository: (input) => - execute({ + }, + decodePullRequestByNumber, + decodeFailure, + ); + const node = decoded.data.repository?.pullRequest; + if (node == null) { + return yield* new GitHubPullRequestNotFoundError({ + command: "gh", cwd: input.cwd, - args: ["repo", "create", input.repository, `--${input.visibility}`], - }).pipe( - Effect.map((result) => - deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), - ), - ), - createPullRequest: (input) => - execute({ + cause: new Error("The pull request does not exist."), + }); + } + const [record] = decodeGitHubPullRequestEntries([node]); + if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); + return record; + }); + + const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( + cwd: string, + locator: GitHubRepositoryLocator, + ) { + const response = yield* rest(cwd, { + host: locator.host, + operation: "getRepository", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, + allowReserve: true, + }); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* new GitHubRepositoryDecodeError({ + command: "gh", + cwd, + cause: decoded.failure, + }); + } + return decoded.success; + }); + + const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( + cwd: string, + host: string, + ) { + const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); + const decoded = decodeViewerLogin(response.body); + if (Result.isFailure(decoded)) { + return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); + } + return decoded.success.login; + }); + + const gitFailure = (cwd: string) => (cause: unknown) => + new GitHubCliCommandError({ command: "gh", cwd, cause }); + + const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); + + /** + * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a + * fork gets a remote of its own), checked out under the name gh would give it, and set to + * track the head. A head branch that is gone is read from the base's `refs/pull//head`. + * An existing branch fast-forwards, or with `force` is reset to the pull request. + */ + const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( + "GitHubCli.checkoutPullRequest", + )(function* (input) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); + const base = + reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); + const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; + const isCrossRepository = + pullRequest.isCrossRepository ?? + (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); + const headOwner = + pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; + + const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.mapError(gitFailure(input.cwd)), + ); + const remoteFor = (nameWithOwner: string) => + remotes + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .find( + (match) => + match !== null && + normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === + nameWithOwner.toLowerCase(), + )?.[1] ?? null; + const baseRemote = Effect.suspend(() => { + const known = remoteFor(baseNameWithOwner); + return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); + }); + // A fork's branch named like the base's default branch is checked out under the owner's + // prefix. Without the default branch that collision cannot be ruled out, and the checkout + // would reset the local default branch to the fork's commit, so it fails instead. + const defaultBranch = isCrossRepository + ? yield* readRepository(input.cwd, base).pipe( + Effect.map((repository) => repository.default_branch ?? null), + ) + : null; + const localBranch = pullRequestCheckoutBranchName({ + headRefName: pullRequest.headRefName, + headOwner, + isCrossRepository, + defaultBranch, + }); + + /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ + const headRemote = Effect.gen(function* () { + if (!isCrossRepository) return yield* baseRemote; + if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); + const known = remoteFor(headNameWithOwner); + if (known !== null) return known; + const [owner, name] = headNameWithOwner.split("/"); + const fork = yield* readRepository(input.cwd, { + host: base.host, + owner: owner!, + name: name!, + }); + const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ cwd: input.cwd, - args: [ - "pr", - "create", - "--base", - input.baseBranch, - "--head", - input.headSelector, - "--title", - input.title, - "--body-file", - input.bodyFile, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ + preferredName: headOwner ?? "fork", + url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, + }); + }); + + const exists = (yield* git + .listLocalBranchNames(input.cwd) + .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); + + // The commit to check out, fetched from the head branch where it still exists. + const target = yield* Effect.gen(function* () { + const remoteName = yield* headRemote; + yield* git.fetchRemoteTrackingBranch({ cwd: input.cwd, - ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }), - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], - }).pipe( - Effect.map((value) => { - const trimmed = value.stdout.trim(); - return trimmed.length > 0 ? trimmed : null; + remoteName, + remoteBranch: pullRequest.headRefName, + }); + return { + ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, + upstream: { remoteName, remoteBranch: pullRequest.headRefName }, + }; + }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* runGit(input.cwd, "fetchPullRef", [ + "fetch", + "--quiet", + "--no-tags", + yield* baseRemote, + `refs/pull/${pullRequest.number}/head`, + ]); + const { commitSha } = yield* git.resolveCommit({ + cwd: input.cwd, + revision: "FETCH_HEAD", + }); + return { ref: commitSha, upstream: null }; }), ), - checkoutPullRequest: (input) => - execute({ + Effect.mapError(gitFailure(input.cwd)), + ); + + yield* Effect.gen(function* () { + if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + if (exists) { + yield* runGit( + input.cwd, + "sync", + input.force === true + ? ["reset", "--hard", "--quiet", target.ref] + : ["merge", "--ff-only", "--quiet", target.ref], + ); + } + // Tracking is set once the branch is the pull request's, so a sync that fails leaves an + // existing branch's upstream as it was. + if (target.upstream !== null) { + yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); + } + }).pipe(Effect.mapError(gitFailure(input.cwd))); + }); + + return GitHubCli.of({ + listPullRequestsByHead: (input) => + AllowGitHubReserve.pipe( + Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), + ), + listOpenPullRequests: (input) => + listByHead({ cwd: input.cwd, - args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], - }).pipe(Effect.asVoid), + headSelector: input.headSelector, + state: "open", + limit: input.limit ?? 1, + rateLimitHost: input.rateLimitHost, + allowReserve: true, + }).pipe(Effect.map(toSummaries)), + getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + Effect.map((locator) => locator.host), + Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), + )).toLowerCase(); + const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + if (locator === null) { + return yield* commandFailure(input.cwd, "Repositories are named owner/name."); + } + return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); + }), + createRepository: (input) => + Effect.gen(function* () { + const locator = parseGitHubRepositorySelector( + input.repository, + (environment.GH_HOST ?? "github.com").toLowerCase(), + ); + const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); + const owner = locator?.owner ?? viewer; + const name = locator?.name ?? input.repository.trim(); + const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); + const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); + const response = yield* rest(input.cwd, { + host, + operation: "createRepository", + method: "POST", + path: + isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, + body: { name, private: input.visibility === "private" }, + }); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* new GitHubRepositoryDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }); + } + return repositoryCloneUrls(decoded.success); + }), + createPullRequest: (input) => + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd }); + const body = yield* fileSystem + .readFileString(input.bodyFile) + .pipe(Effect.mapError(gitFailure(input.cwd))); + yield* rest(input.cwd, { + host: locator.host, + operation: "createPullRequest", + method: "POST", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, + // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. + // gh allows maintainer edits unless told otherwise; the API's default is not documented. + body: { + base: input.baseBranch, + head: input.headSelector, + title: input.title, + body, + maintainer_can_modify: true, + }, + }); + }), + getDefaultBranch: (input) => + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); + const repository = yield* readRepository(input.cwd, locator); + const branch = repository.default_branch?.trim() ?? ""; + return branch.length > 0 ? branch : null; + }), + checkoutPullRequest, }); }); export const layer = Layer.effect(GitHubCli, make).pipe( + Layer.provideMerge(GitHubApi.layer), + Layer.provideMerge(GitHubCredentials.layer), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index a486c27ab66c..6376e2d868e5 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -3,11 +3,12 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -25,37 +26,26 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider(github: Partial) { +function makeProvider( + github: Partial, + api: Partial = {}, +) { return GitHubSourceControlProvider.make.pipe( - Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)), + Effect.provide( + Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), + ), ); } -it.effect("uses the enterprise quota for a current-repository default branch read", () => - Effect.gen(function* () { - // github.com is out of quota; the enterprise read must not be priced against it. - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe( - "github.com", - '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":0,"resetAt":"2099-01-01T00:00:00Z"}}}', - ); - const provider = yield* GitHubSourceControlProvider.make; - const branch = yield* provider.getDefaultBranch({ - cwd: "/enterprise-repo", - context: { - provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, - remoteName: "origin", - remoteUrl: "https://enterprise.test/acme/web.git", - }, - }); - assert.strictEqual(branch, "main"); - }).pipe( - Effect.provide(GitHubCli.layer), - Effect.provideService(VcsProcess.VcsProcess, { - run: () => Effect.succeed(processResult("main")), - }), - ), -); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ + status: 200, + headers: {}, + body, + truncated: false, + invalidUtf8: false, +}); it.effect("maps GitHub PR summaries into provider-neutral change requests", () => Effect.gen(function* () { @@ -412,27 +402,20 @@ it.effect.each(["pull", "issues"])( "resolves %s subjects on the linked host without using the checkout", (kind) => Effect.gen(function* () { - const provider = yield* makeProvider({ - execute: (input) => { - assert.deepStrictEqual(input.args, [ - "api", - "--hostname", - "github.com", - "repos/owner/repo/issues/42", - "--jq", - "{title, body}", - ]); - assert.strictEqual(input.maxOutputBytes, 32_000); - assert.strictEqual(input.timeoutMs, 3_000); - return Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: JSON.stringify({ title: "Pairing expiry", body: "Preserve remote access" }), - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); + const provider = yield* makeProvider( + {}, + { + rest: (input) => { + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.path, "repos/owner/repo/issues/42"); + return Effect.succeed( + restResponse( + encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), + ), + ); + }, }, - }); + ); const lookup = provider.resolveLink?.({ cwd: "/unrelated", url: new URL(`https://github.com/owner/repo/${kind}/42`), @@ -456,23 +439,20 @@ it.effect.each(["read", "decode"] as const)( "retains the %s failure without exposing its raw contents", (stage) => Effect.gen(function* () { - const cause = new GitHubCli.GitHubCliCommandError({ - command: "gh", - cwd: "/repo", - cause: new Error("private response text"), - }); - const provider = yield* makeProvider({ - execute: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "private response text", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), + const cause = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "resolveLink", + status: 500, }); + const provider = yield* makeProvider( + {}, + { + rest: () => + stage === "read" + ? Effect.fail(cause) + : Effect.succeed(restResponse("private response text")), + }, + ); const lookup = provider.resolveLink?.({ cwd: "/repo", url: new URL("https://github.com/owner/repo/issues/42"), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 905146d5a733..8e183e8b9ace 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,22 +2,33 @@ import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import { + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderDiscoveryItem, +} from "@t3tools/contracts"; + +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import { combinedAuthOutput, firstSafeAuthLine, + probeSourceControlProvider, providerAuth, type SourceControlAuthProbeInput, type SourceControlCliDiscoverySpec, + type SourceControlManagedCliDiscoverySpec, } from "./SourceControlProviderDiscovery.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const decodeLinkSubject = Schema.decodeUnknownEffect( Schema.fromJsonString( - Schema.Struct({ title: Schema.String, body: Schema.NullOr(Schema.String) }), + Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }), ), ); @@ -111,8 +122,70 @@ export const discovery = { "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; +const decodeViewer = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ login: Schema.String })), +); + +/** The environment variable gh would take a github.com token from, if one is set. */ +function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { + return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; +} + +/** + * GitHub is usable with a token from the environment or with `gh` to hand one over. An + * environment token is checked against the API, since `gh auth status` may not know it. + */ +export const makeDiscovery = Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const process = yield* VcsProcess.VcsProcess; + const environment = yield* HostProcessEnvironment; + return { + type: "managed-cli", + kind: discovery.kind, + label: discovery.label, + installHint: discovery.installHint, + probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { + const cli = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); + const variable = environmentTokenVariable(environment); + if (variable === null) return cli; + const viewer = yield* api + .rest({ host: "github.com", operation: "discovery", path: "user" }) + .pipe(Effect.result); + const login = Result.isSuccess(viewer) + ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login + : undefined; + return { + ...cli, + status: "available" as const, + auth: + login !== undefined + ? providerAuth({ + status: "authenticated", + account: login, + host: "github.com", + detail: `Using the token in ${variable} from the server environment.`, + }) + : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" + ? // Only a refusal says the token is bad; a network error or a pause says nothing. + providerAuth({ + status: "unknown", + host: "github.com", + detail: `Could not check the token in ${variable}: ${viewer.failure.message}`, + }) + : providerAuth({ + status: "unauthenticated", + host: "github.com", + detail: `GitHub refused the token in ${variable}. Replace it, or unset it to use \`gh auth login\`.`, + }), + } satisfies SourceControlProviderDiscoveryItem; + }), + refineUnknownRemote: () => Effect.succeed(null), + } satisfies SourceControlManagedCliDiscoverySpec; +}); + export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + const api = yield* GitHubApi.GitHubApi; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { @@ -138,7 +211,7 @@ export const make = Effect.gen(function* () { reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, ), - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -177,7 +250,7 @@ export const make = Effect.gen(function* () { reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, ), - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -188,15 +261,15 @@ export const make = Effect.gen(function* () { input: { readonly cwd: string; readonly url: URL }, endpoint: string, ) { - const result = yield* github - .execute({ - cwd: input.cwd, - args: ["api", "--hostname", input.url.host, endpoint, "--jq", "{title, body}"], - env: { GH_PROMPT_DISABLED: "1" }, - timeoutMs: 3_000, - maxOutputBytes: 32_000, + const result = yield* api + .rest({ + host: input.url.host, + operation: "resolveLink", + path: endpoint, + maxResponseBytes: 1_000_000, }) .pipe( + Effect.timeout("3 seconds"), Effect.mapError( (cause) => new SourceControlProviderError({ @@ -208,7 +281,7 @@ export const make = Effect.gen(function* () { }), ), ); - const subject = yield* decodeLinkSubject(result.stdout).pipe( + const subject = yield* decodeLinkSubject(result.body).pipe( Effect.mapError( (cause) => new SourceControlProviderError({ @@ -220,7 +293,7 @@ export const make = Effect.gen(function* () { }), ), ); - return { title: subject.title, body: subject.body }; + return { title: subject.title, body: subject.body ?? null }; }); return SourceControlProvider.SourceControlProvider.of({ @@ -255,7 +328,7 @@ export const make = Effect.gen(function* () { reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, ), - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -280,7 +353,7 @@ export const make = Effect.gen(function* () { reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, ), - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -297,7 +370,7 @@ export const make = Effect.gen(function* () { repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -314,7 +387,7 @@ export const make = Effect.gen(function* () { repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, ), - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -335,7 +408,7 @@ export const make = Effect.gen(function* () { operation: "getDefaultBranch", command: error.command, cwd: input.cwd, - detail: error.detail, + detail: error.message, cause: error, }), ), @@ -352,7 +425,7 @@ export const make = Effect.gen(function* () { reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, ), - detail: error.detail, + detail: error.message, cause: error, }), ), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 0705523cccb9..672963fc9ec4 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -16,6 +16,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; @@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 1629ff385c3f..39dbcaba04ff 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -13,6 +13,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; @@ -41,6 +42,7 @@ function makeRegistry(input: { }>; readonly process?: Partial; readonly github?: Partial; + readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { @@ -95,6 +97,7 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), + Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { @@ -305,9 +308,15 @@ it.effect( Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }], - github: { - execute: () => - Effect.succeed(processOutput(JSON.stringify({ title: "GitHub issue", body: null }))), + githubApi: { + rest: () => + Effect.succeed({ + status: 200, + headers: {}, + body: JSON.stringify({ title: "GitHub issue", body: null }), + truncated: false, + invalidUtf8: false, + }), }, gitlab: { execute: () => diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index a497c852bcd6..7a626b13f50f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -305,6 +305,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.gen(function* () { const github = yield* GitHubSourceControlProvider.make; + const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; const gitlab = yield* GitLabSourceControlProvider.make; const forgejo = yield* ForgejoSourceControlProvider.make; const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; @@ -315,7 +316,7 @@ export const make = Effect.gen(function* () { { kind: "github", provider: github, - discovery: GitHubSourceControlProvider.discovery, + discovery: githubDiscovery, }, { kind: "gitlab", diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index c29a3806e13d..2309993f0300 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -111,7 +111,6 @@ function normalizeGitHubPullRequestRecord( } const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); /** @@ -139,13 +138,3 @@ export function decodeGitHubPullRequestListJson( > { return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries); } - -export function decodeGitHubPullRequestJson( - raw: string, -): Result.Result> { - const result = decodeGitHubPullRequest(raw); - if (Result.isSuccess(result)) { - return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); - } - return Result.fail(result.failure); -} From 9ac8f33f1685d1d6e843b657829d0d7462a13813 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:14:14 -0700 Subject: [PATCH 07/59] feat: choose the GitHub account per host, save a GitHub token, and fewer reads per PR action (#16322) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/scripts/measure-pr-preview.ts | 3 +- apps/server/src/git/GitManager.test.ts | 2 +- .../pullRequest/GitHubPullRequestCli.test.ts | 66 +----- .../src/pullRequest/GitHubPullRequestCli.ts | 36 +-- .../GitHubPullRequestProvider.test.ts | 128 ++++------ .../pullRequest/GitHubPullRequestProvider.ts | 60 +++-- .../pullRequest/gitHubPullRequestJson.test.ts | 43 ---- .../src/pullRequest/gitHubPullRequestJson.ts | 63 ----- apps/server/src/serverSettings.test.ts | 48 ++++ apps/server/src/serverSettings.ts | 82 ++++++- .../src/sourceControl/GitHubApi.test.ts | 2 + apps/server/src/sourceControl/GitHubCli.ts | 8 +- .../sourceControl/GitHubCredentials.test.ts | 170 +++++++++++++ .../src/sourceControl/GitHubCredentials.ts | 99 +++++++- .../GitHubSourceControlProvider.test.ts | 75 ++++++ .../GitHubSourceControlProvider.ts | 137 ++++++++--- .../SourceControlDiscovery.test.ts | 2 + .../SourceControlProviderRegistry.test.ts | 2 + .../src/sourceControl/gitHubAuthStatus.ts | 31 +++ .../GitHubAccountSettings.logic.test.ts | 55 +++++ .../settings/GitHubAccountSettings.logic.ts | 52 ++++ .../settings/GitHubAccountSettings.tsx | 224 ++++++++++++++++++ .../settings/GitHubTokenSettings.tsx | 138 +++++++++++ .../settings/RedactedSensitiveText.tsx | 2 +- .../settings/SourceControlSettings.tsx | 33 ++- .../src/components/settings/settingsSearch.ts | 10 + docs/user/source-control.md | 19 +- packages/contracts/src/pullRequest.ts | 9 +- packages/contracts/src/settings.test.ts | 7 + packages/contracts/src/settings.ts | 42 ++++ packages/contracts/src/sourceControl.ts | 14 ++ packages/shared/src/serverSettings.test.ts | 11 + packages/shared/src/serverSettings.ts | 4 + 34 files changed, 1322 insertions(+), 359 deletions(-) create mode 100644 apps/server/src/sourceControl/GitHubCredentials.test.ts create mode 100644 apps/web/src/components/settings/GitHubAccountSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/GitHubAccountSettings.logic.ts create mode 100644 apps/web/src/components/settings/GitHubAccountSettings.tsx create mode 100644 apps/web/src/components/settings/GitHubTokenSettings.tsx diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 4e0691fe3a06..414bd99d0745 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -157,9 +157,11 @@ await Effect.runPromise( ForgejoCli.layer, AzureDevOpsCli.layer, // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. - BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())), + BitbucketApi.layer, ), ), + // Default settings: no saved Bitbucket token, gh's own GitHub account choice. + Layer.provide(ServerSettings.layerTest()), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), Layer.provide(GitVcsDriver.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index ae291cd17cc6..98fc82b53982 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -14,6 +14,7 @@ import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.t import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -64,7 +65,7 @@ const measuredApi = Layer.effect( }); }), ).pipe( - Layer.provide(GitHubCredentials.layer), + Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 42fdc87afe57..8f743c65fa31 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4553,7 +4553,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { Effect.flip, Effect.map((error) => error.message), ); - expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); + expect(errorMessage).toContain("No GitHub credential on the server"); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 33f0df8e8bcb..d9579f317d95 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -18,7 +18,6 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; -import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -4041,45 +4040,6 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("sends the base comparison's head as a variable, not inside the document", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - data: { - repository: { - pullRequest: { - viewerCanUpdateBranch: true, - baseRef: { compare: { behindBy: 4 } }, - }, - }, - }, - }), - ), - ), - ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - - const comparison = yield* cli.getPullRequestBaseComparison({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - headRef: "fork:feat/page", - }); - - expect(varsAt(0)).toEqual({ - owner: "acme", - name: "web", - number: 7, - headRef: "fork:feat/page", - }); - expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); - expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2)); - }), - ); - it.effect("stops GraphQL reads at the protected reserve until reset", () => Effect.gen(function* () { mockedExecute.mockReturnValue( @@ -4088,10 +4048,11 @@ layer("GitHubPullRequestCli.layer", (it) => { encodeJson({ data: { repository: { - pullRequest: { - viewerCanUpdateBranch: true, - baseRef: { compare: { behindBy: 4 } }, - }, + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, rateLimit: { cost: 1, @@ -4110,13 +4071,12 @@ layer("GitHubPullRequestCli.layer", (it) => { repository: "acme/web", host: "github.com", number: 7, - headRef: "fork:feat/page", } as const; - yield* cli.getPullRequestBaseComparison(input); + yield* cli.getViewerAccess(input); expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); - const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input)); + const error = yield* Effect.flip(cli.getViewerAccess(input)); assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); if (error._tag !== "SourceControlRateLimitPausedError") return; @@ -4136,10 +4096,11 @@ layer("GitHubPullRequestCli.layer", (it) => { encodeJson({ data: { repository: { - pullRequest: { - viewerCanUpdateBranch: true, - baseRef: { compare: { behindBy: 4 } }, - }, + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: true, + viewerPermission: "READ", + pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, rateLimit: { cost: 1, @@ -4171,12 +4132,11 @@ layer("GitHubPullRequestCli.layer", (it) => { ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - yield* cli.getPullRequestBaseComparison({ + yield* cli.getViewerAccess({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7, - headRef: "fork:feat/page", }); const access = yield* cli.getViewerAccess({ cwd: "/w", diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index d393db9fa78b..9e98286dc821 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -94,8 +94,6 @@ import { decodePullRequestStackMembershipsJson, pullRequestSearchGraphQlQuery, PULL_REQUEST_SEARCH_MAX_ROWS, - BASE_COMPARISON_GRAPHQL_QUERY, - decodeBaseComparisonJson, PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, @@ -115,7 +113,6 @@ import { UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, VIEWER_PERMISSIONS_GRAPHQL_QUERY, decodeViewerPermissionsJson, - type GitHubBaseComparison, type GitHubPullRequestActivity, type GitHubPullRequestActivityPage, type GitHubWorkflowRunPage, @@ -146,7 +143,7 @@ export class GitHubPullRequestReadError extends Schema.TaggedError Effect.Effect; - /** - * How far the branch trails its base, and whether this viewer may update it. Its own read - * because the comparison needs the head ref the detail answers with — a fork's branch is not - * addressable in the base repository by name alone. - */ - readonly getPullRequestBaseComparison: (input: { - readonly cwd: string; - readonly repository: string; - readonly host: string; - readonly number: number; - /** Qualified `owner:branch`, which is the only form a fork's head resolves under. */ - readonly headRef: string; - /** Manual action checks may use the quota held back from automatic reads. */ - readonly allowReserve?: boolean | undefined; - }) => Effect.Effect; - readonly getPullRequestActivity: (input: { readonly cwd: string; readonly repository: string; @@ -2170,19 +2151,6 @@ export const make = Effect.gen(function* () { ); }, - getPullRequestBaseComparison: (input) => { - const { owner, name } = parseRepositorySelector(input.repository); - return graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestBaseComparison", - ...(input.allowReserve === true ? { allowReserve: true } : {}), - variables: { owner, name, number: input.number, headRef: input.headRef }, - query: BASE_COMPARISON_GRAPHQL_QUERY, - decode: decodeBaseComparisonJson, - }); - }, - getPullRequestActivity: (input) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 6905b3826880..88079ebb94c7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,7 +7,9 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -486,8 +488,6 @@ describe("gitHubViewerPermissions", () => { url: "https://github.com/acme/web/actions/runs/123", }, ]), - getPullRequestBaseComparison: () => - Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), getViewerAccess: () => Effect.succeed({ canWrite: true, @@ -585,7 +585,6 @@ it.effect("does not classify same-repository gates as fork workflow approvals", Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), - getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), listWorkflowRunsRequiringApproval: () => Effect.die("same-repository pull requests must not probe fork workflow approvals"), getViewerAccess: () => @@ -625,7 +624,6 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), - getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), listWorkflowRunsRequiringApproval: () => Effect.fail( new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ @@ -669,7 +667,6 @@ it.effect("propagates workflow discovery rate limits", () => Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), - getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), listWorkflowRunsRequiringApproval: () => Effect.fail( new GitHubApi.GitHubApiRateLimitError({ @@ -691,6 +688,14 @@ it.effect("propagates workflow discovery rate limits", () => ); describe("getViewerPermissions", () => { + const access = { + canWrite: true, + canTriage: true, + canUpdate: true, + didAuthor: false, + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; + it.effect("checks fresh access without reading branch details for unrelated operations", () => { let accessReads = 0; return Effect.gen(function* () { @@ -711,44 +716,27 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.die("Unexpected detail read"), - getPullRequestBaseComparison: () => Effect.die("Unexpected comparison read"), - getViewerAccess: () => - Effect.sync(() => { - accessReads++; - return { - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }; - }), + getViewerAccess: () => Effect.sync(() => (accessReads++, access)), }), ), ); }); - const layerWithComparison = ( - comparison: Effect.Effect<{ - readonly behindBy: number | null; - readonly viewerCanUpdate: boolean; - }>, + const layerWithDetail = ( + detail: Effect.Effect, + onDetail: (allowReserve: boolean) => void = () => {}, ) => Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed(openDetail), - getPullRequestBaseComparison: () => comparison, - getViewerAccess: () => - Effect.succeed({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), + getPullRequestDetail: () => + GitHubCli.AllowGitHubReserve.pipe( + Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), + Effect.flatMap(() => detail), + ), + getViewerAccess: () => Effect.die("Unexpected access read"), }); - it.effect("offers update-branch when the comparison grants it", () => + it.effect("answers access and update-branch from the one detail read", () => Effect.gen(function* () { const provider = yield* make; const permissions = yield* provider.getViewerPermissions({ @@ -758,16 +746,20 @@ describe("getViewerPermissions", () => { number: 7, }); + expect(permissions.actions).toContain("merge"); expect(permissions.actions).toContain("update-branch"); expect(permissions.updateMethods).toEqual(["merge", "rebase"]); }).pipe( - Effect.provide(layerWithComparison(Effect.succeed({ behindBy: 3, viewerCanUpdate: true }))), + Effect.provide( + layerWithDetail( + Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }), + ), + ), ), ); it.effect("uses the GraphQL reserve for manual permission checks", () => { - let viewerAllowReserve: boolean | undefined; - let comparisonAllowReserve: boolean | undefined; + let allowed: boolean | undefined; return Effect.gen(function* () { const provider = yield* make; yield* provider.getViewerPermissions({ @@ -776,36 +768,15 @@ describe("getViewerPermissions", () => { host: "github.com", number: 7, }); - - expect(viewerAllowReserve).toBe(true); - expect(comparisonAllowReserve).toBe(true); + expect(allowed).toBe(true); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ - revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed(openDetail), - getPullRequestBaseComparison: (input) => - Effect.sync(() => { - comparisonAllowReserve = input.allowReserve; - return { behindBy: 3, viewerCanUpdate: true }; - }), - getViewerAccess: (input) => - Effect.sync(() => { - viewerAllowReserve = input.allowReserve; - return { - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }; - }), - }), + layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)), ), ); }); - it.effect("withholds update-branch when the comparison cannot be read", () => + it.effect("still answers from the light access read when the detail read fails", () => Effect.gen(function* () { const provider = yield* make; const permissions = yield* provider.getViewerPermissions({ @@ -814,37 +785,42 @@ describe("getViewerPermissions", () => { host: "github.com", number: 7, }); - - expect(permissions.actions).not.toContain("update-branch"); - expect(permissions.updateMethods).toBeUndefined(); - // The rest of the answer survives a comparison nobody could make. expect(permissions.actions).toContain("merge"); + expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ revalidateChecks: (_input, read) => read, - getPullRequestDetail: () => Effect.succeed(openDetail), - getPullRequestBaseComparison: () => + getPullRequestDetail: () => Effect.fail( new GitHubPullRequestCli.GitHubPullRequestReadError({ command: "gh", cwd: "/w", - operation: "getPullRequestBaseComparison", - cause: new Error("unreadable"), + operation: "getPullRequestDetail", + cause: new Error("head changed"), }), ), - getViewerAccess: () => - Effect.succeed({ - canWrite: true, - canTriage: true, - canUpdate: true, - didAuthor: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), + getViewerAccess: () => Effect.succeed(access), }), ), ), ); + + it.effect("withholds update-branch where GitHub could not compare the branch", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).not.toContain("update-branch"); + expect(permissions.updateMethods).toBeUndefined(); + expect(permissions.actions).toContain("merge"); + }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))), + ); }); describe("getChangeRequest commits", () => { diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 940b04715c84..74c5852a48a4 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,6 +7,7 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { PullRequestProviderError, @@ -109,6 +110,7 @@ export function gitHubProviderFailure( case "GitHubCliMissingError": return { reason: "missing-tool" }; case "GitHubNotSignedInError": + case "GitHubHostDisabledError": case "GitHubApiAuthenticationError": return { reason: "unauthenticated" }; case "GitHubApiRateLimitError": @@ -513,38 +515,34 @@ export const make = Effect.gen(function* () { getReviewThreadComments: (input) => cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), - getViewerPermissions: (input) => - Effect.all( - [ - cli.getViewerAccess({ ...input, allowReserve: true }), - // Whether this viewer may update the branch is only on the comparison, and the - // comparison only resolves through the head ref the detail carries. A failure here - // withholds that one action rather than the whole answer, the way the detail path - // leaves the banner unknown. - input.includeUpdateBranch === false - ? Effect.succeed(false) - : cli.getPullRequestDetail(input).pipe( - Effect.flatMap((pullRequest) => - pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null - ? Effect.succeed(false) - : cli - .getPullRequestBaseComparison({ - ...input, - headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, - allowReserve: true, - }) - .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)), - ), - Effect.orElseSucceed(() => false), - ), - ], - { concurrency: 2 }, - ).pipe( - Effect.mapError(fail("getViewerPermissions")), - Effect.map(([access, canUpdateBranch]) => - gitHubViewerPermissions({ ...access, canUpdateBranch }), + getViewerPermissions: (input) => { + const lightAccess = cli + .getViewerAccess({ ...input, allowReserve: true }) + .pipe(Effect.map((access) => gitHubViewerPermissions(access))); + if (input.includeUpdateBranch === false) { + return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); + } + // The core detail already carries the viewer's access, the merge settings, and the base + // comparison, so one read usually answers what used to take three. When that heavier read + // fails, the light access read still answers, withholding only update-branch. + return cli.getPullRequestDetail(input).pipe( + Effect.provideService(GitHubCli.AllowGitHubReserve, true), + Effect.map((pullRequest) => + gitHubViewerPermissions({ + ...pullRequest.viewerAccess, + canUpdateBranch: + pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, + }), ), - ), + Effect.catchIf( + (error) => + error._tag !== "GitHubApiRateLimitError" && + error._tag !== "SourceControlRateLimitPausedError", + () => lightAccess, + ), + Effect.mapError(fail("getViewerPermissions")), + ); + }, getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 2e008fe4c636..3105634dc087 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -11,7 +11,6 @@ import { decodePullRequestStackMembershipsJson, buildReviewerRequest, buildSetFilesViewedGraphQlMutation, - decodeBaseComparisonJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, @@ -1696,48 +1695,6 @@ describe("decodePullRequestFilesJson", () => { }); }); -describe("how far a branch trails its base", () => { - const comparison = (pullRequest: unknown) => - JSON.stringify({ data: { repository: { pullRequest } } }); - - it("reads the commit count and whether this viewer may move the branch", () => { - const decoded = expectSuccess( - decodeBaseComparisonJson( - comparison({ viewerCanUpdateBranch: true, baseRef: { compare: { behindBy: 12 } } }), - ), - ); - expect(decoded).toEqual({ behindBy: 12, viewerCanUpdate: true }); - }); - - it("reads a current branch as nothing to do", () => { - expect( - expectSuccess( - decodeBaseComparisonJson( - comparison({ viewerCanUpdateBranch: false, baseRef: { compare: { behindBy: 0 } } }), - ), - ), - ).toEqual({ behindBy: 0, viewerCanUpdate: false }); - }); - - it("answers unknown where the head could not be compared", () => { - // A pull request from a fork whose repository is gone, which GitHub answers with a null - // comparison beside a perfectly good pull request. - expect( - expectSuccess( - decodeBaseComparisonJson(comparison({ viewerCanUpdateBranch: true, baseRef: null })), - ).behindBy, - ).toBeNull(); - expect(expectSuccess(decodeBaseComparisonJson(comparison(null)))).toEqual({ - behindBy: null, - viewerCanUpdate: false, - }); - }); - - it("refuses a body that is not the answer to this question", () => { - expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); - }); -}); - describe("decodePullRequestFilesViewedJson", () => { const page = ( nodes: ReadonlyArray, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 1409e92a1fad..ce89cf6fe44a 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2892,75 +2892,12 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean { * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses * fails whole, taking the people down with the teams. */ -/** - * Where the branch stands against its base, and whether this viewer may move it. - * - * `mergeStateStatus` is not the answer: GitHub only reports BEHIND where the repository requires - * branches to be up to date before merging, so on every other repository a stale branch reads as - * CLEAN or BLOCKED like any other. The comparison counts the commits instead, which is the same - * number GitHub's own "out-of-date" banner shows. - * - * `headRef` is qualified `owner:branch` because a pull request from a fork has no branch of that - * name in the base repository, and an unqualified name is simply not found there. - */ -export const BASE_COMPARISON_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - viewerCanUpdateBranch - baseRef { - compare(headRef: $headRef) { - behindBy - } - } - } - } -}`; - -const RawBaseComparisonSchema = Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Struct({ - pullRequest: Schema.NullOr( - Schema.Struct({ - viewerCanUpdateBranch: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** Null where the head repository is gone, which is a comparison nobody can make. */ - baseRef: Schema.optional( - Schema.NullOr( - Schema.Struct({ - compare: Schema.optional( - Schema.NullOr(Schema.Struct({ behindBy: Schema.Number })), - ), - }), - ), - ), - }), - ), - }), - ), - }), -}); - -const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema); - export interface GitHubBaseComparison { /** Null where the host could not compare, which the page reads as "unknown". */ readonly behindBy: number | null; readonly viewerCanUpdate: boolean; } -export function decodeBaseComparisonJson( - raw: string, -): Result.Result { - const decoded = decodeBaseComparison(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const pullRequest = decoded.success.data.repository?.pullRequest; - const behindBy = pullRequest?.baseRef?.compare?.behindBy; - return Result.succeed({ - behindBy: typeof behindBy === "number" && behindBy >= 0 ? behindBy : null, - viewerCanUpdate: pullRequest?.viewerCanUpdateBranch === true, - }); -} - export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4376d5d7ea69..59f188d7d1b5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1588,6 +1588,54 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); + it.effect( + "keeps GitHub tokens per host in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ + github: { tokens: { "GitHub.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }); + assert.deepEqual(saved.github.tokens, { + "github.com": "ghp_dotcom", + "ghe.acme.test": "ghp_ghe", + }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "ghp_dotcom"); + assert.notInclude(raw, "ghp_ghe"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).github; + assert.notInclude(forClient.tokens["github.com"]!, "ghp_dotcom"); + assert.isAbove(forClient.tokens["github.com"]!.length, 0); + + // Echoing the redacted values back keeps them; host and account changes leave tokens alone. + yield* serverSettings.updateSettings({ github: { tokens: forClient.tokens } }); + yield* serverSettings.updateSettings({ + github: { hosts: { "github.com": { enabled: true, account: "work" } } }, + }); + assert.deepEqual((yield* serverSettings.getSettings).github.tokens, { + "github.com": "ghp_dotcom", + "ghe.acme.test": "ghp_ghe", + }); + + // An empty token removes that host's token and nothing else. + const cleared = yield* serverSettings.updateSettings({ + github: { tokens: { "github.com": "" } }, + }); + assert.equal(cleared.github.tokens["github.com"] ?? "", ""); + assert.equal(cleared.github.tokens["ghe.acme.test"], "ghp_ghe"); + const remaining = yield* Effect.forEach(["github.com", "ghe.acme.test"], (host) => + secrets.get(`github-token-${Buffer.from(host, "utf8").toString("base64url")}`), + ); + assert.isTrue(Option.isNone(remaining[0]!)); + assert.isTrue(Option.isSome(remaining[1]!)); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6452c8d775a2..7cbcd895ac14 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -161,6 +161,11 @@ const BITBUCKET_SECRET_NAMES = { } as const; const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; +/** Hosts are case-insensitive; a patch can arrive before decoding lowercased its keys. */ +function gitHubTokenSecretName(host: string): string { + return `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`; +} + const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); function redactProviderEnvironmentVariable( @@ -204,7 +209,13 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS accessToken: redactSecret(settings.bitbucket.accessToken), apiToken: redactSecret(settings.bitbucket.apiToken), }; - return { ...settings, providerInstances, usageLimitSources, bitbucket }; + const github = { + ...settings.github, + tokens: Object.fromEntries( + Object.entries(settings.github.tokens).map(([host, token]) => [host, redactSecret(token)]), + ), + }; + return { ...settings, providerInstances, usageLimitSources, bitbucket, github }; } export function applyProviderInstanceMutation( @@ -707,7 +718,24 @@ const make = Effect.gen(function* () { bitbucket[field] = SECRET_REDACTED; moved = true; } - return moved ? { ...settings, bitbucket } : settings; + const tokens = { ...settings.github.tokens }; + for (const [host, value] of Object.entries(tokens)) { + if (value.length === 0 || value === SECRET_REDACTED) continue; + const stored = yield* secretStore + .set(gitHubTokenSecretName(host), textEncoder.encode(value)) + .pipe( + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to move a GitHub token into the secret store", { + host, + }).pipe(Effect.as(false)), + ), + ); + if (!stored) continue; + tokens[host] = SECRET_REDACTED; + moved = true; + } + return moved ? { ...settings, bitbucket, github: { ...settings.github, tokens } } : settings; }); const loadSettingsFromDisk = Effect.gen(function* () { @@ -882,11 +910,27 @@ const make = Effect.gen(function* () { ); bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; } + const tokens: Record = {}; + for (const [host, value] of Object.entries(settings.github.tokens)) { + if (value !== SECRET_REDACTED) { + tokens[host] = value; + continue; + } + const secret = yield* secretStore + .get(gitHubTokenSecretName(host)) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + tokens[host] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], bitbucket, + github: { ...settings.github, tokens }, }; }); @@ -1047,12 +1091,46 @@ const make = Effect.gen(function* () { bitbucket[field] = SECRET_REDACTED; } + const tokens: Record = {}; + for (const [rawHost, raw] of Object.entries(next.github.tokens)) { + const host = rawHost.trim().toLowerCase(); + let value = raw; + if (value === SECRET_REDACTED) { + // The marker keeps what is saved; a hand-edited plaintext token moves into the store. + const inline = current.github.tokens[host]; + if (inline === undefined || inline === SECRET_REDACTED || inline.length === 0) { + tokens[host] = SECRET_REDACTED; + continue; + } + value = inline; + } + const secretName = gitHubTokenSecretName(host); + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + tokens[host] = SECRET_REDACTED; + } + const nextHosts = new Set( + Object.keys(next.github.tokens).map((host) => host.trim().toLowerCase()), + ); + for (const host of Object.keys(current.github.tokens)) { + if (nextHosts.has(host.trim().toLowerCase())) continue; + changes.push({ + kind: "remove", + secretName: gitHubTokenSecretName(host), + operation: "remove-stale-secret", + }); + } + return { settings: { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], bitbucket, + github: { ...next.github, tokens }, }, changes, }; diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 85fe9a2dcfbe..a5c0f82be443 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -15,6 +15,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ServerSettings from "../serverSettings.ts"; const NOW = Date.parse("2026-10-05T12:00:00.000Z"); @@ -416,6 +417,7 @@ describe("GitHubCredentials", () => { GitHubCredentials.layer.pipe( Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), Layer.provide(NodeServices.layer), + Layer.provide(ServerSettings.layerTest()), ); it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 95501d3c163a..f4463be6de83 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -44,7 +44,7 @@ export class GitHubCliUnavailableError extends Schema.TaggedError = {}, + signedOut: ReadonlyArray = [], + tokens: Record = {}, +) { + const calls: Array> = []; + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input.args); + const user = input.args[input.args.indexOf("--user") + 1]; + if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + } + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }); + const layer = GitHubCredentials.layer.pipe( + Layer.provideMerge( + ServerSettings.ServerSettingsService.layerTest({ github: { hosts, tokens } }), + ), + Layer.provide(process), + Layer.provide(NodeServices.layer), + ); + return { layer, calls }; +} + +describe("GitHubCredentials", () => { + beforeEach(() => { + for (const name of TOKEN_VARIABLES) vi.stubEnv(name, ""); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.effect("asks gh for the active login when Settings pin nothing", () => { + const { layer, calls } = harness(); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("GitHub.com"); + expect(Redacted.value(credential.token)).toBe("active-token"); + expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("passes --user for an account pinned in Settings", () => { + const { layer, calls } = harness({ "github.com": { account: "work" } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("github.com"); + expect(Redacted.value(credential.token)).toBe("token-for-work"); + expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a host turned off in Settings without asking gh", () => { + const { layer, calls } = harness({ "github.com": { enabled: false } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("github.com")); + expect(error._tag).toBe("GitHubHostDisabledError"); + expect(error.message).toBe( + "GitHub host github.com is turned off in Settings → Source Control.", + ); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets an environment token win over a pinned account, as gh does", () => { + vi.stubEnv("GH_TOKEN", "from-env"); + const { layer, calls } = harness({ "github.com": { account: "work" } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("github.com"); + expect(Redacted.value(credential.token)).toBe("from-env"); + expect(credential.source).toBe("env"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks up a changed account on the next request without a restart", () => { + const { layer, calls } = harness(); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const settings = yield* ServerSettings.ServerSettingsService; + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + + yield* settings.updateSettings({ + github: { hosts: { "github.com": { account: "work", enabled: true } } }, + }); + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); + + yield* settings.updateSettings({ github: { hosts: { "github.com": { enabled: false } } } }); + expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( + "GitHubHostDisabledError", + ); + + yield* settings.updateSettings({ github: { hosts: {} } }); + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + // The unpinned token stayed cached; only the newly pinned account cost a gh call. + expect(calls).toHaveLength(2); + }).pipe(Effect.provide(layer)); + }); + + it.effect("falls back to the active login when the pinned one is no longer signed in", () => { + const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + expect(calls).toEqual([ + ["auth", "token", "--hostname", "github.com", "--user", "gone"], + ["auth", "token", "--hostname", "github.com"], + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => { + vi.stubEnv("GH_TOKEN", "env-token"); + const { layer, calls } = harness({}, [], { "github.com": "saved-token" }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const credential = yield* credentials.get("github.com"); + expect(Redacted.value(credential.token)).toBe("saved-token"); + expect(credential.source).toBe("settings"); + expect(calls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps a host turned off even with a token saved in Settings", () => { + const { layer } = harness({ "github.com": { enabled: false } }, [], { + "github.com": "saved-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( + "GitHubHostDisabledError", + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index 7207373bcf31..fb043d809c68 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; /** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ @@ -20,7 +21,7 @@ const TOKEN_TTL = Duration.minutes(5); /** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ const MISSING_TTL = Duration.seconds(10); -export const GitHubCredentialSource = Schema.Literals(["env", "gh"]); +export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; export interface GitHubCredential { @@ -41,13 +42,25 @@ export class GitHubCliMissingError extends Schema.TaggedError()( "GitHubNotSignedInError", + { host: Schema.String, account: Schema.optional(Schema.String) }, +) { + override get message(): string { + return this.account === undefined + ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.` + : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`; + } +} + +/** The user turned the host off in Settings. */ +export class GitHubHostDisabledError extends Schema.TaggedError()( + "GitHubHostDisabledError", { host: Schema.String }, ) { override get message(): string { - return `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`; + return `GitHub host ${this.host} is turned off in Settings → Source Control.`; } } @@ -65,8 +78,13 @@ export class GitHubCliFailedError extends Schema.TaggedError + const fromGh = (host: string, account: string | undefined) => process .run({ operation: "GitHubCredentials.get", command: "gh", - args: ["auth", "token", "--hostname", host], + args: [ + "auth", + "token", + "--hostname", + host, + ...(account === undefined ? [] : ["--user", account]), + ], cwd: workingDirectory, // Never let gh print the token into a debug log. env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, @@ -146,19 +171,48 @@ export const make = Effect.gen(function* () { ? new GitHubCliMissingError({ host }) : // gh exits non-zero with "no oauth token" when it has no login for the host. error._tag === "VcsProcessExitError" - ? new GitHubNotSignedInError({ host }) + ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }) : new GitHubCliFailedError({ host, cause: error }), ), Effect.map((output) => output.stdout.trim()), Effect.filterOrFail( (token) => token !== "", - () => new GitHubNotSignedInError({ host }), + () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }), ), ); - const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) { + /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ + const hostChoice = (host: string) => + serverSettings.getSettings.pipe( + Effect.map((settings) => settings.github.hosts[host]), + Effect.orElseSucceed(() => undefined), + ); + + /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ + const savedToken = (host: string) => + serverSettings.getSettings.pipe( + Effect.map((settings) => settings.github.tokens[host]?.trim() || null), + Effect.orElseSucceed(() => null), + ); + + /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ + const cacheKey = (host: string, account: string | undefined) => + account === undefined ? host : `${host}\u0000${account}`; + + const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { + const [host = key, choice] = key.split("\u0000"); + // An environment token wins over a pinned account, exactly as it does in gh. const fromEnv = environmentToken(host, environment); - const token = fromEnv ?? (yield* fromGh(host)); + // A pinned login gh no longer holds (logged out, expired) falls back to the active one, + // which is what discovery reports as the account in use. + const token = + fromEnv ?? + (yield* fromGh(host, choice).pipe( + Effect.catchTags({ + GitHubNotSignedInError: (error) => + choice === undefined ? Effect.fail(error) : fromGh(host, undefined), + }), + )); return { host, token: Redacted.make(token), @@ -181,8 +235,31 @@ export const make = Effect.gen(function* () { }); return GitHubCredentials.of({ - get: (host) => Cache.get(cache, normalizeHost(host)), - invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { + const host = normalizeHost(rawHost); + const choice = yield* hostChoice(host); + if (choice?.enabled === false) { + return yield* new GitHubHostDisabledError({ host }); + } + // A token saved in Settings is the most deliberate choice, so it comes before the + // environment and gh. It is read from the secret store each time, so it needs no cache. + const saved = yield* savedToken(host); + if (saved !== null) { + return { + host, + token: Redacted.make(saved), + source: "settings", + fingerprint: yield* fingerprintOf(host, saved), + } satisfies GitHubCredential; + } + return yield* Cache.get(cache, cacheKey(host, choice?.account)); + }), + invalidate: (rawHost) => { + const host = normalizeHost(rawHost); + return hostChoice(host).pipe( + Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), + ); + }, }); }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 6376e2d868e5..ea6624aef892 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -329,6 +329,7 @@ it("parses GitHub auth status accounts by host and active state", () => { authenticated: true, active: true, error: null, + environmentVariable: null, }, { host: "github.com", @@ -336,6 +337,7 @@ it("parses GitHub auth status accounts by host and active state", () => { authenticated: false, active: false, error: null, + environmentVariable: null, }, { host: "github.example.test", @@ -343,6 +345,7 @@ it("parses GitHub auth status accounts by host and active state", () => { authenticated: true, active: false, error: null, + environmentVariable: null, }, ], ); @@ -466,3 +469,75 @@ it.effect.each(["read", "decode"] as const)( else assert.propertyVal(error.cause, "_tag", "SchemaError"); }), ); + +const multiAccountStatus = (extra: ReadonlyArray> = []) => + processResult( + JSON.stringify({ + hosts: { + "github.com": [ + { state: "success", active: true, host: "github.com", login: "personal" }, + { state: "success", active: false, host: "github.com", login: "work" }, + ...extra, + ], + "ghe.acme.test": [ + { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" }, + ], + }, + }), + ); + +it("reports every gh login and leads with the account Settings pin", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { + hosts: { "github.com": { account: "work", enabled: true } }, + tokens: {}, + }); + assert.deepStrictEqual(auth.account, Option.some("work")); + assert.deepStrictEqual(auth.accounts, [ + { host: "github.com", account: "personal", active: true, authenticated: true }, + { host: "github.com", account: "work", active: false, authenticated: true }, + { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" }, + ]); +}); + +it("falls back to gh's active login when the pinned account is gone", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { + hosts: { "github.com": { account: "former-job", enabled: true } }, + tokens: {}, + }); + assert.deepStrictEqual(auth.account, Option.some("personal")); +}); + +it("reports unauthenticated when Settings turn off every signed-in host", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { + hosts: { "github.com": { enabled: false } }, + tokens: {}, + }); + assert.strictEqual(auth.status, "unauthenticated"); + assert.deepStrictEqual( + auth.detail, + Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."), + ); +}); + +it("names the environment token that overrides the Settings choice", () => { + const auth = GitHubSourceControlProvider.parseGitHubAuth( + multiAccountStatus([ + { + state: "success", + active: false, + host: "github.com", + login: "bot", + tokenSource: "GH_TOKEN", + }, + ]), + { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, + ); + assert.deepStrictEqual(auth.account, Option.some("bot")); + assert.deepStrictEqual( + auth.detail, + Option.some( + "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.", + ), + ); + assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN"); +}); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 8e183e8b9ace..0b868ba3f439 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -4,16 +4,24 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { + DEFAULT_SERVER_SETTINGS, SourceControlProviderError, type ChangeRequest, + type GitHubSettings, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; -import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; +import { + effectiveGitHubAccount, + findAuthenticatedGitHubAccount, + parseGitHubAuthStatus, + type GitHubAuthStatusAccount, +} from "./gitHubAuthStatus.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import { combinedAuthOutput, @@ -60,29 +68,78 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } -function parseGitHubAuth(input: SourceControlAuthProbeInput) { +function authAccounts(accounts: ReadonlyArray) { + return accounts.map((entry) => ({ + host: entry.host, + account: entry.account, + active: entry.active, + authenticated: entry.authenticated, + ...(entry.error === null ? {} : { error: entry.error }), + ...(entry.environmentVariable === null + ? {} + : { environmentVariable: entry.environmentVariable }), + })); +} + +/** + * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will + * use: Settings can pin a login per host or turn a host off, and an environment token beats both. + */ +export function parseGitHubAuth( + input: SourceControlAuthProbeInput, + settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, +) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); - const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); - const host = authenticatedAccount?.host; + const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; + const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); + // Lead with the host gh would pick, unless Settings turned it off. + const orderedHosts = fallback + ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] + : hosts; + const chosen = orderedHosts + .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings)) + .find((entry) => entry !== undefined); + const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {}; - if (authenticatedAccount) { - return providerAuth({ - status: "authenticated", - account: authenticatedAccount.account, - host, - }); + if (chosen) { + return { + ...providerAuth({ + status: "authenticated", + account: chosen.account, + host: chosen.host, + detail: + chosen.environmentVariable === null + ? undefined + : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`, + }), + ...accounts, + }; + } + + if (fallback) { + return { + ...providerAuth({ + status: "unauthenticated", + host: fallback.host, + detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.", + }), + ...accounts, + }; } const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0]; if (authStatus.parsed) { - return providerAuth({ - status: "unauthenticated", - host: failedAccount?.host, - detail: - failedAccount?.error ?? - "Run `gh auth login` to authenticate GitHub CLI with an active account.", - }); + return { + ...providerAuth({ + status: "unauthenticated", + host: failedAccount?.host, + detail: + failedAccount?.error ?? + "Run `gh auth login` to authenticate GitHub CLI with an active account.", + }), + ...accounts, + }; } // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit @@ -98,14 +155,12 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { if (input.exitCode !== 0) { return providerAuth({ status: "unauthenticated", - host, detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", }); } return providerAuth({ status: "unknown", - host, detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", }); } @@ -132,51 +187,75 @@ function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null } /** - * GitHub is usable with a token from the environment or with `gh` to hand one over. An - * environment token is checked against the API, since `gh auth status` may not know it. + * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one + * over. Reads the + * GitHub settings on every probe, so a saved account choice shows on rescan. An environment + * token is checked against the API, since `gh auth status` may not know it. */ export const makeDiscovery = Effect.gen(function* () { const api = yield* GitHubApi.GitHubApi; const process = yield* VcsProcess.VcsProcess; const environment = yield* HostProcessEnvironment; + const serverSettings = yield* ServerSettings.ServerSettingsService; + return { type: "managed-cli", kind: discovery.kind, label: discovery.label, installHint: discovery.installHint, probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { - const cli = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); + const settings = yield* serverSettings.getSettings.pipe( + Effect.map((current) => current.github), + Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), + ); + const cli = yield* probeSourceControlProvider({ + cwd, + process, + spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, + }); + // A token saved in Settings wins over the environment, which wins over gh. + const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); - if (variable === null) return cli; + const tokenSource = savedToken ? "the token saved in Settings" : variable; + // A host turned off in Settings stays off even with a token. + if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; const viewer = yield* api .rest({ host: "github.com", operation: "discovery", path: "user" }) .pipe(Effect.result); const login = Result.isSuccess(viewer) ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login : undefined; + // The per-host logins stay, so the account picker still lists every host gh knows. + const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; return { ...cli, status: "available" as const, - auth: - login !== undefined + auth: { + ...(login !== undefined ? providerAuth({ status: "authenticated", account: login, host: "github.com", - detail: `Using the token in ${variable} from the server environment.`, + detail: savedToken + ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." + : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, }) : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" ? // Only a refusal says the token is bad; a network error or a pause says nothing. providerAuth({ status: "unknown", host: "github.com", - detail: `Could not check the token in ${variable}: ${viewer.failure.message}`, + detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, }) : providerAuth({ status: "unauthenticated", host: "github.com", - detail: `GitHub refused the token in ${variable}. Replace it, or unset it to use \`gh auth login\`.`, - }), + detail: savedToken + ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." + : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, + })), + ...accounts, + }, } satisfies SourceControlProviderDiscoveryItem; }), refineUnknownRemote: () => Effect.succeed(null), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 672963fc9ec4..54de8cc14fde 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/process"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: { }).pipe(Layer.provide(NodeServices.layer)), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), + ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitLabCli.GitLabCli)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 39dbcaba04ff..08751017b351 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -7,6 +7,7 @@ import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -96,6 +97,7 @@ function makeRegistry(input: { layerProcess, Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), + ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), diff --git a/apps/server/src/sourceControl/gitHubAuthStatus.ts b/apps/server/src/sourceControl/gitHubAuthStatus.ts index d58909c560c2..7321ead2d4ec 100644 --- a/apps/server/src/sourceControl/gitHubAuthStatus.ts +++ b/apps/server/src/sourceControl/gitHubAuthStatus.ts @@ -1,3 +1,4 @@ +import type { GitHubSettings } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -7,6 +8,7 @@ const GitHubAuthStatusAccountSchema = Schema.Struct({ active: Schema.Boolean, host: Schema.String, login: Schema.String, + tokenSource: Schema.optional(Schema.String), }); const GitHubAuthStatusSchema = Schema.Struct({ @@ -23,6 +25,8 @@ export interface GitHubAuthStatusAccount { readonly authenticated: boolean; readonly active: boolean; readonly error: string | null; + /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */ + readonly environmentVariable: string | null; } export interface GitHubAuthStatus { @@ -30,6 +34,8 @@ export interface GitHubAuthStatus { readonly accounts: ReadonlyArray; } +const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u; + function nonEmptyString(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; @@ -54,6 +60,9 @@ export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { authenticated: account.state === "success", active: account.active, error: account.error?.trim() || null, + environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "") + ? (account.tokenSource ?? null) + : null, }, ]; }), @@ -70,3 +79,25 @@ export function findAuthenticatedGitHubAccount( accounts.find((account) => account.authenticated) ); } + +/** + * The login GitHub requests for a host will use, honoring Settings: an environment token + * first (gh's own precedence), then the pinned account, then gh's active login. + * Returns undefined when the host is turned off or has no usable login. + */ +export function effectiveGitHubAccount( + host: string, + accounts: ReadonlyArray, + settings: GitHubSettings, +): GitHubAuthStatusAccount | undefined { + const choice = settings.hosts[host]; + if (choice?.enabled === false) return undefined; + const usable = accounts.filter((account) => account.host === host && account.authenticated); + return ( + usable.find((account) => account.environmentVariable !== null) ?? + (choice?.account === undefined + ? undefined + : usable.find((account) => account.account === choice.account)) ?? + findAuthenticatedGitHubAccount(usable) + ); +} diff --git a/apps/web/src/components/settings/GitHubAccountSettings.logic.test.ts b/apps/web/src/components/settings/GitHubAccountSettings.logic.test.ts new file mode 100644 index 000000000000..a1a895297da2 --- /dev/null +++ b/apps/web/src/components/settings/GitHubAccountSettings.logic.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic"; + +describe("groupGitHubAccounts", () => { + it("groups logins by host, keeping broken and environment logins out of the picker", () => { + expect( + groupGitHubAccounts([ + { host: "github.com", account: "personal", active: true, authenticated: true }, + { host: "github.com", account: "work", active: false, authenticated: true }, + { host: "github.com", account: "old", active: false, authenticated: false, error: "bad" }, + { + host: "github.com", + account: "bot", + active: false, + authenticated: true, + environmentVariable: "GH_TOKEN", + }, + { host: "ghe.acme.test", account: "jm", active: false, authenticated: true }, + ]), + ).toEqual([ + { + host: "github.com", + activeAccount: "personal", + selectable: ["personal", "work"], + broken: [ + { host: "github.com", account: "old", active: false, authenticated: false, error: "bad" }, + ], + environmentVariable: "GH_TOKEN", + }, + { + host: "ghe.acme.test", + activeAccount: "jm", + selectable: ["jm"], + broken: [], + environmentVariable: null, + }, + ]); + }); +}); + +describe("nextGitHubHosts", () => { + it("pins an account, keeps other hosts, and drops a host back on gh's defaults", () => { + const other = { "ghe.acme.test": { enabled: false } }; + const pinned = nextGitHubHosts(other, "github.com", { account: "work" }); + expect(pinned).toEqual({ ...other, "github.com": { enabled: true, account: "work" } }); + + const disabled = nextGitHubHosts(pinned, "github.com", { enabled: false }); + expect(disabled["github.com"]).toEqual({ enabled: false, account: "work" }); + + expect(nextGitHubHosts(disabled, "github.com", { enabled: true, account: null })).toEqual( + other, + ); + }); +}); diff --git a/apps/web/src/components/settings/GitHubAccountSettings.logic.ts b/apps/web/src/components/settings/GitHubAccountSettings.logic.ts new file mode 100644 index 000000000000..466372f7699d --- /dev/null +++ b/apps/web/src/components/settings/GitHubAccountSettings.logic.ts @@ -0,0 +1,52 @@ +import type { GitHubSettings, SourceControlProviderAuth } from "@t3tools/contracts"; + +export type GitHubDiscoveredAccount = NonNullable[number]; + +export interface GitHubHostGroup { + readonly host: string; + /** gh's active stored login, the one used when Settings pin nothing. */ + readonly activeAccount: string | null; + /** Stored logins that work and can be pinned with `gh auth token --user`. */ + readonly selectable: ReadonlyArray; + /** Logins gh holds but cannot use; shown with their error. */ + readonly broken: ReadonlyArray; + /** Set when GH_TOKEN or a sibling overrides every stored login for this host. */ + readonly environmentVariable: string | null; +} + +/** Groups gh's logins by host, in the order gh reported the hosts. */ +export function groupGitHubAccounts( + accounts: ReadonlyArray, +): ReadonlyArray { + const hosts = [...new Set(accounts.map((entry) => entry.host))]; + return hosts.map((host) => { + const entries = accounts.filter((entry) => entry.host === host); + const stored = entries.filter((entry) => entry.environmentVariable === undefined); + const selectable = stored.filter((entry) => entry.authenticated); + return { + host, + activeAccount: (selectable.find((entry) => entry.active) ?? selectable[0])?.account ?? null, + selectable: selectable.map((entry) => entry.account), + broken: stored.filter((entry) => !entry.authenticated), + environmentVariable: + entries.find((entry) => entry.environmentVariable)?.environmentVariable ?? null, + }; + }); +} + +/** + * The full `hosts` map after one host changes. Hosts left on gh's defaults (enabled, + * no pinned account) are dropped so settings only hold real choices. + */ +export function nextGitHubHosts( + current: GitHubSettings["hosts"], + host: string, + change: { readonly enabled?: boolean; readonly account?: string | null }, +): GitHubSettings["hosts"] { + const previous = current[host]; + const enabled = change.enabled ?? previous?.enabled ?? true; + const account = change.account === undefined ? previous?.account : (change.account ?? undefined); + const { [host]: _replaced, ...rest } = current; + if (enabled && account === undefined) return rest; + return { ...rest, [host]: { enabled, ...(account === undefined ? {} : { account }) } }; +} diff --git a/apps/web/src/components/settings/GitHubAccountSettings.tsx b/apps/web/src/components/settings/GitHubAccountSettings.tsx new file mode 100644 index 000000000000..98e7289b260f --- /dev/null +++ b/apps/web/src/components/settings/GitHubAccountSettings.tsx @@ -0,0 +1,224 @@ +import type { EnvironmentId, SourceControlProviderAuth } from "@t3tools/contracts"; +import { EyeIcon, EyeOffIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic"; +import { redactedPlaceholder } from "./RedactedSensitiveText"; + +/** Sentinel select value for "follow gh's active login"; logins never contain spaces. */ +const ACTIVE_ACCOUNT = "active gh account"; + +/** + * A login, blurred like RedactedSensitiveText until the panel reveals it. Plain text, not a + * button, so it can sit inside select options; one panel toggle reveals every login. + */ +function RedactedLogin(props: { + readonly account: string; + readonly revealed: boolean; + /** Distinguishes hidden logins from each other for a screen reader, e.g. "Account 2". */ + readonly label?: string; +}) { + return props.revealed ? ( + {props.account} + ) : ( + + + {redactedPlaceholder(props.account)} + + {props.label ?? "Hidden account"} + + ); +} + +/** + * Per-host GitHub choices for one environment: turn a host off, or pin which of the + * logins `gh` holds is used instead of its active one. Changes save immediately. + */ +export function GitHubAccountSettings({ + environmentId, + auth, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly auth: SourceControlProviderAuth; + readonly onSaved: () => void; +}) { + const hosts = useEnvironmentSettings(environmentId, (settings) => settings.github.hosts); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "save GitHub account settings", + }); + const [saving, setSaving] = useState(false); + const [revealed, setRevealed] = useState(false); + const groups = groupGitHubAccounts(auth.accounts ?? []); + + const save = async ( + host: string, + change: { readonly enabled?: boolean; readonly account?: string | null }, + ) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { github: { hosts: nextGitHubHosts(hosts, host, change) } } }, + }); + if (result._tag === "Success") onSaved(); + } finally { + setSaving(false); + } + }; + + if (groups.length === 0) { + return ( +

+ Sign in with gh auth login on + the server host, then rescan to choose accounts here. +

+ ); + } + + return ( +
+
+

+ Choose which gh login each + GitHub host uses, or turn a host off. +

+ +
+ {groups.map((group) => { + const choice = hosts[group.host]; + const enabled = choice?.enabled ?? true; + const stalePin = + choice?.account !== undefined && !group.selectable.includes(choice.account); + const pinned = choice?.account !== undefined && !stalePin ? choice.account : ACTIVE_ACCOUNT; + return ( +
+
+
+ {group.host} + {group.selectable.length === 1 && group.selectable[0] !== undefined ? ( +

+ Signed in as + +

+ ) : null} +
+ void save(group.host, { enabled: checked })} + /> +
+ {group.selectable.length > 1 ? ( +
+ Account +
+ +
+
+ ) : null} + {stalePin ? ( +
+

+ The chosen login is no longer signed in, so the active gh login is used. +

+ +
+ ) : null} + {group.broken.map((entry) => ( +

+ + can't be used: {entry.error ?? "gh reports this login as invalid."} +

+ ))} + {group.environmentVariable ? ( +

+ {group.environmentVariable} is set on the server, so it overrides the account chosen + here until it is unset. +

+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/settings/GitHubTokenSettings.tsx b/apps/web/src/components/settings/GitHubTokenSettings.tsx new file mode 100644 index 000000000000..80ad552f15b2 --- /dev/null +++ b/apps/web/src/components/settings/GitHubTokenSettings.tsx @@ -0,0 +1,138 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button, InlineButton } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +const DEFAULT_HOST = "github.com"; + +/** Where to create a token for a host: GitHub's own page, or the same path on an Enterprise host. */ +function newTokenUrl(host: string): string { + return `https://${host}/settings/personal-access-tokens/new`; +} + +/** + * A GitHub token for one host, kept in the server's secret store. It is used before + * `GH_TOKEN` and the `gh` login, so it also works on a server without `gh` installed. + * Write-only: the saved token is never shown, only whether one is set. + */ +export function GitHubTokenSettings({ + environmentId, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly onSaved: () => void; +}) { + const tokens = useEnvironmentSettings(environmentId, (settings) => settings.github.tokens); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "save GitHub token", + }); + const [host, setHost] = useState(DEFAULT_HOST); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + const normalizedHost = host.trim().toLowerCase(); + const isSaved = (tokens[normalizedHost] ?? "").length > 0; + const savedHosts = Object.entries(tokens) + .filter(([, value]) => value.length > 0) + .map(([saved]) => saved); + + const save = async (target: string, token: string) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { github: { tokens: { [target]: token } } } }, + }); + if (result._tag === "Success") { + setDraft(""); + onSaved(); + } + } finally { + setSaving(false); + } + }; + + return ( +
{ + event.preventDefault(); + if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim()); + }} + > + {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */} +
+

+ A token saved here is used before{" "} + GH_TOKEN and the{" "} + gh login, so GitHub works + without the GitHub CLI. Give it read and write access to pull requests and contents.{" "} + + } + > + Create a token + + +

+
+
+ + setHost(event.target.value)} + /> +
+
+ + setDraft(event.target.value)} + /> +
+
+
+

+ {savedHosts.length === 0 + ? "No token saved; the server uses GH_TOKEN or the gh login." + : `Saved for ${savedHosts.join(", ")}.`} +

+
+ {isSaved ? ( + + ) : null} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/RedactedSensitiveText.tsx b/apps/web/src/components/settings/RedactedSensitiveText.tsx index 4ae2276b8e63..46f0897db342 100644 --- a/apps/web/src/components/settings/RedactedSensitiveText.tsx +++ b/apps/web/src/components/settings/RedactedSensitiveText.tsx @@ -5,7 +5,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; -function redactedPlaceholder(value: string): string { +export function redactedPlaceholder(value: string): string { let state = 0x811c9dc5; for (let index = 0; index < value.length; index += 1) { state ^= value.charCodeAt(index); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 9fba0edc7d44..978d7a57105d 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -57,6 +57,8 @@ import { type Icon, } from "../Icons"; import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; +import { GitHubAccountSettings } from "./GitHubAccountSettings"; +import { GitHubTokenSettings } from "./GitHubTokenSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; import { @@ -222,6 +224,9 @@ function itemSummary({ if (auth) { if (auth.status === "authenticated") { + // The server names the account its requests use, Settings choice included, and + // says when an environment token overrides it. + const authDetail = optionLabel(auth.detail); return ( <> Authenticated @@ -231,6 +236,7 @@ function itemSummary({ ) : null} + {authDetail ? · {authDetail} : null} ); } @@ -241,6 +247,11 @@ function itemSummary({ return Available. {item.installHint}; } + // Signed in, but every login is turned off here: the fix is the switch below, not the CLI. + if (auth.status === "unauthenticated" && auth.accounts?.some((entry) => entry.authenticated)) { + return {optionLabel(auth.detail) ?? `Every ${item.label} host is turned off.`}; + } + if (auth.status === "unauthenticated") { return ( @@ -283,7 +294,8 @@ function DiscoveryItemRow({ if ( (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || (item.kind === "bitbucket" && - searchTargetId === searchableSetting("bitbucket-credentials").id) + searchTargetId === searchableSetting("bitbucket-credentials").id) || + (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id) ) { setIsExpanded(true); } @@ -603,6 +615,25 @@ export function SourceControlSettingsPanel() { onSaved={handleScan} /> + ) : item.kind === "github" ? ( + +
+ {/* Shown even without gh: a saved token is how GitHub works without the CLI. */} + + {item.status === "available" ? ( + + ) : null} +
+
) : undefined} ))} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4797c9b64dde..18b2fe5a30a7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -751,6 +751,16 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "project-defaults", }, + { + id: "github-accounts", + title: "GitHub accounts and token", + to: "/settings/source-control", + searchTerms: [ + "github gh account login user host enterprise ghes switch multiple accounts disable sign in token personal access token pat api key credential", + ], + environmentOnly: true, + scope: "environment-defaults", + }, { id: "bitbucket-credentials", title: "Bitbucket credentials", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d5ffeb0b4778..03c360ff3a10 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -11,11 +11,18 @@ and choose **Rescan**. ### GitHub -Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in: +T3 Code talks to GitHub's API directly and only needs a token. Any of these works, in this +order of precedence: -```bash -gh auth login -``` +1. A token saved in **Settings → Source Control → GitHub**. It is kept in the server's secret + store, and works without the GitHub CLI. +2. `GH_TOKEN` (`GH_ENTERPRISE_TOKEN` with `GH_HOST` for GitHub Enterprise Server) in the + server's environment. +3. [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, signed in with `gh auth login`. + +If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same place to pick +the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence +over that choice; a host turned off stays off either way. ### Forgejo and Gitea @@ -136,7 +143,7 @@ environment clears its permission. GitHub review details, linked PR status, and permitted review actions can then use another connected environment signed in to the same GitHub account. Each needs a project on that host. A connected local environment is preferred for actions and can answer slow or failed reads. -Browsers and mobile clients need a paired environment to use its GitHub CLI credentials. +Browsers and mobile clients need a paired environment to use its GitHub credentials. Credentials stay on their machines. Previously verified credentials remain usable for routing for ten minutes during a GitHub outage; new credentials must be verified first. An action with an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or @@ -164,7 +171,7 @@ does not show its diff, so marks are made and read on web and desktop. - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, check the credentials saved in Settings → Source Control, or confirm the running server received the environment variables. -- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. +- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0, or save a token in Settings → Source Control. - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS remotes can require separate setup from the hosting provider's API access. - **A review cannot load:** open it on the host website while resolving connectivity, permissions, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 68cdfc43e40f..2cd0e45cc8e0 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1264,16 +1264,17 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T /** * What each host needs before it can be read, so a failure names the fix rather than the - * symptom. Bitbucket is credentials on the server rather than a signed-in CLI, which is why - * these are whole sentences instead of a tool name to interpolate. + * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and + * Bitbucket they mean "no credential" and "a refused credential", not a missing tool. */ const PROVIDER_REQUIREMENT: Partial< Record > = { github: { missing: - "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.", - unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.", + unauthenticated: + "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.", }, forgejo: { missing: diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 20fc7b7ddd38..419db85883bc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -987,6 +987,13 @@ describe("ServerSettingsPatch.providerInstances", () => { }); describe("ServerSettingsPatch string normalization", () => { + it("lowercases GitHub hosts and defaults them to enabled", () => { + const patch = decodeServerSettingsPatch({ + github: { hosts: { " GitHub.com ": { account: " work " } } }, + }); + expect(patch.github?.hosts).toEqual({ "github.com": { account: "work", enabled: true } }); + }); + it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ addProjectBaseDirectory: " ~/Development ", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e73079a58d0..7984ca20b57a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1013,6 +1013,37 @@ export const BitbucketSettings = Schema.Struct({ }); export type BitbucketSettings = typeof BitbucketSettings.Type; +/** + * Per-host choices for the GitHub CLI's logins. `account` pins one of the logins + * `gh` holds for the host instead of its active one; a disabled host gets no + * credential at all. A token saved here wins over `GH_TOKEN` and friends, which win over `gh`. + */ +/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */ +export const GitHubHost = TrimmedNonEmptyString.pipe( + Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), +); + +export const GitHubHostSettings = Schema.Struct({ + account: Schema.optionalKey(TrimmedNonEmptyString), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type GitHubHostSettings = typeof GitHubHostSettings.Type; + +export const GitHubSettings = Schema.Struct({ + /** Keyed by lowercased host, for example `github.com`. */ + hosts: Schema.Record(GitHubHost, GitHubHostSettings).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + /** + * A token per host, used before `GH_TOKEN` and `gh`. The server keeps each one in its secret + * store; settings and clients only ever see a redaction marker for a saved token. + */ + tokens: Schema.Record(GitHubHost, TrimmedString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), +}); +export type GitHubSettings = typeof GitHubSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1423,6 +1454,7 @@ export const ServerSettings = Schema.Struct({ ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Keyed by a user-chosen id so a source keeps its rows across edits. Entries // this build cannot decode round-trip untouched, as provider instances do. usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( @@ -1714,6 +1746,16 @@ export const ServerSettingsPatch = Schema.Struct({ apiToken: Schema.optionalKey(TrimmedString), }), ), + /** + * `hosts` replaces the whole map, so an omitted host or account clears it. `tokens` merges per + * host: an empty token removes that host's token, the redaction marker keeps it. + */ + github: Schema.optionalKey( + Schema.Struct({ + hosts: Schema.optionalKey(Schema.Record(GitHubHost, GitHubHostSettings)), + tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index b0d2bd4a75bd..f9717b45c4c3 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -123,6 +123,20 @@ export const SourceControlProviderAuth = Schema.Struct({ account: Schema.Option(TrimmedNonEmptyString), host: Schema.Option(TrimmedNonEmptyString), detail: Schema.Option(TrimmedNonEmptyString), + /** Every login the provider CLI holds, across hosts. Only GitHub reports these today. */ + accounts: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + host: TrimmedNonEmptyString, + account: TrimmedNonEmptyString, + active: Schema.Boolean, + authenticated: Schema.Boolean, + error: Schema.optionalKey(TrimmedNonEmptyString), + /** Set when the login comes from a token variable such as `GH_TOKEN`, which wins over Settings. */ + environmentVariable: Schema.optionalKey(TrimmedNonEmptyString), + }), + ), + ), }); export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index fb25119cadce..f7305812e74f 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -41,6 +41,17 @@ describe("serverSettings helpers", () => { logsAfterDays: 30, }); }); + it("replaces GitHub host choices so a cleared account pin does not survive", () => { + const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + github: { hosts: { "github.com": { account: "work", enabled: true } } }, + }); + expect(pinned.github.hosts).toEqual({ "github.com": { account: "work", enabled: true } }); + expect( + applyServerSettingsPatch(pinned, { + github: { hosts: { "github.com": { enabled: false } } }, + }).github.hosts, + ).toEqual({ "github.com": { enabled: false } }); + }); it("replaces SSH host lists when saving, editing, and removing hosts", () => { const host = { id: "mini", label: "Mac mini", target: "mini" }; const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index aabc3612c988..5e66b0ebe332 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -378,6 +378,10 @@ export function applyServerSettingsPatch( ], } : {}), + // Host replacement: deepMerge would keep a cleared account pin. + ...(patch.github?.hosts !== undefined + ? { github: { ...next.github, hosts: patch.github.hosts } } + : {}), ...(projectSettingsOverridesPatch !== undefined ? { projectSettingsOverrides: Object.fromEntries( From 1eae9c2efec43b1d27e60d967001f5721c4a0429 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:14:14 -0700 Subject: [PATCH 08/59] fix(server): Rebase stack moves each layer onto the rebased layer below it (#16551) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/measure-pr-preview.ts | 6 +- .../pullRequest/GitHubPullRequestCli.test.ts | 5 + .../src/pullRequest/GitHubPullRequestCli.ts | 6 + .../pullRequest/githubStackActions.test.ts | 224 +++++++----------- .../src/pullRequest/githubStackActions.ts | 133 +++-------- .../src/pullRequest/githubStackRebase.test.ts | 162 +++++++++++++ .../src/pullRequest/githubStackRebase.ts | 155 ++++++++++++ 7 files changed, 452 insertions(+), 239 deletions(-) create mode 100644 apps/server/src/pullRequest/githubStackRebase.test.ts create mode 100644 apps/server/src/pullRequest/githubStackRebase.ts diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index 98fc82b53982..c4a688b1aa7a 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -73,7 +73,11 @@ const measuredApi = Layer.effect( Layer.provide(NodeServices.layer), ); -const services = GitHubPullRequestCli.layer.pipe(Layer.provideMerge(measuredApi)); +const services = GitHubPullRequestCli.layer.pipe( + Layer.provideMerge(measuredApi), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index d9579f317d95..7e6146834db8 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -16,6 +17,7 @@ import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; @@ -98,6 +100,7 @@ const layer = it.layer( Layer.provideMerge(mockApi), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provide(NodeCrypto.layer), + Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), ); @@ -492,6 +495,8 @@ it.effect( Layer.provide(Layer.mergeAll(credentials, http)), Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), + Layer.merge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 9e98286dc821..ce944b931aa8 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -6,6 +6,7 @@ import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; @@ -43,6 +44,7 @@ import { import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { ACTOR_AVATARS_GRAPHQL_QUERY, @@ -1113,6 +1115,8 @@ const SIMPLE_ACTION_MUTATIONS = { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const api = yield* GitHubApi.GitHubApi; + const vcsProcess = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; const revalidateChecks = yield* makeChecksRevalidator; const routingIdentities = new Map< string, @@ -2495,6 +2499,8 @@ export const make = Effect.gen(function* () { if (input.stackNumber !== undefined) return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( Effect.provideService(GitHubApi.GitHubApi, api), + Effect.provideService(VcsProcess.VcsProcess, vcsProcess), + Effect.provideService(FileSystem.FileSystem, fileSystem), ); if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts index af6794616f7c..7600b9dc9a26 100644 --- a/apps/server/src/pullRequest/githubStackActions.test.ts +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -4,7 +4,11 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import * as FileSystem from "effect/FileSystem"; +import * as Redacted from "effect/Redacted"; +import { ChildProcessSpawner } from "effect/process"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; /** @@ -17,32 +21,57 @@ type Send = ( kind: "graphql" | "rest", ) => Effect.Effect; +/** Every git command the cascade ran, in order; none actually runs. */ +let gitCalls: Array> = []; +const fakeGit = Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + gitCalls.push(input.args); + const stdout = + input.args[0] === "rev-parse" || input.args[0] === "merge-base" ? "new-sha\n" : ""; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), +); + const runGitHubStackAction = (send: Send, input: Parameters[0]) => runStackAction(input).pipe( Effect.provide( - Layer.mock(GitHubApi.GitHubApi)({ - graphql: (request) => { - // GitHub refuses a document that declares a variable it never uses. - const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); - const unused = declared.filter( - (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), - ); - if (unused.length > 0) { - return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); - } - return send(words(request.query, request.variables), "graphql"); - }, - rest: (request) => - send(words(request.path, request.body), "rest").pipe( - Effect.map((body) => ({ - status: 200, - headers: {}, - body, - truncated: false, - invalidUtf8: false, - })), - ), - }), + Layer.mergeAll( + fakeGit, + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + graphql: (request) => { + // GitHub refuses a document that declares a variable it never uses. + const declared = [...request.query.matchAll(/\$(\w+)\s*:/g)].map((match) => match[1]!); + const unused = declared.filter( + (name) => !new RegExp(`\\$${name}(?!\\w)(?!\\s*:)`).test(request.query), + ); + if (unused.length > 0) { + return Effect.die(new Error(`Variables declared but not used: ${unused.join(", ")}`)); + } + return send(words(request.query, request.variables), "graphql"); + }, + rest: (request) => + send(words(request.path, request.body), "rest").pipe( + Effect.map((body) => ({ + status: 200, + headers: {}, + body, + truncated: false, + invalidUtf8: false, + })), + ), + }), + ), ), ); @@ -250,41 +279,6 @@ it.effect("refuses a changed stack before performing any mutation", () => }), ); -it.effect("rebases unmerged layers bottom to top without local git commands", () => - Effect.gen(function* () { - const api = fake([stack, access, ...rebaseResponses]); - yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - const mutations = api.calls.filter(isMutation); - expect(mutations).toHaveLength(2); - expect(mutations[0]).toContain("id=PR_2"); - expect(mutations[0]).toContain("sha=bbb"); - expect(mutations[1]).toContain("id=PR_3"); - expect(mutations[1]).toContain("sha=ccc"); - }), -); - -it.effect("does not update later layers after a rebase failure", () => - Effect.gen(function* () { - const api = fake([stack, access, branch(2, "bbb")]); - const execute: Send = (call, kind) => - !isMutation(call) - ? api.execute(call, kind) - : Effect.fail( - new GitHubApi.GitHubApiAuthenticationError({ - host: "github.com", - operation: "runGitHubStackAction", - }), - ); - const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( - Effect.result, - ); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, - }); - }), -); - it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => Effect.gen(function* () { const api = fake([ @@ -325,7 +319,7 @@ it.effect("allows a fork that explicitly permits maintainer updates", () => ...rebaseResponses, ]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - expect(api.calls.at(-1)).toContain("id=PR_3"); + expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2); }), ); @@ -348,92 +342,52 @@ it.effect("bounds polling and reports a still-running merge without claiming suc }), ); -it.effect("rejects a push after preflight without rebasing the new revision", () => +it.effect("rebases the open layers bottom to top in a scratch clone, each onto the one below", () => Effect.gen(function* () { - const api = fake([stack, access, branch(2, "new-head")]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, - }); - expect(api.calls).toHaveLength(3); - }), -); - -it.effect("skips current layers without submitting a rebase mutation", () => - Effect.gen(function* () { - const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + gitCalls = []; + const api = fake([stack, access]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - expect(api.calls.some((args) => isMutation(args))).toBe(false); - }), -); - -it.effect("keeps earlier progress and stops after a later layer fails", () => - Effect.gen(function* () { - const api = fake([ - stack, - access, - branch(2, "bbb"), - rebased, - branch(3, "ccc", 1, ["rebased-sha"]), - { data: { updatePullRequestBranch: null } }, + // GitHub is only read; every change is a git push with a lease on the reviewed head. + expect(api.calls.some(isMutation)).toBe(false); + const rebases = gitCalls.filter((args) => args[0] === "rebase"); + expect(rebases).toEqual([ + ["rebase", "--quiet", "--onto", "origin/main", "new-sha"], + ["rebase", "--quiet", "--onto", "new-sha", "bbb"], ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, - }); - }), -); - -it.effect("reports partial progress when a later head changes during the rebase", () => - Effect.gen(function* () { - const api = fake([ - stack, - access, - branch(2, "bbb"), - rebased, - branch(3, "concurrent-head", 1, ["rebased-sha"]), + expect(gitCalls.filter((args) => args[0] === "push")).toEqual([ + [ + "push", + "--quiet", + "--force-with-lease=refs/heads/middle:bbb", + "origin", + "new-sha:refs/heads/middle", + ], + [ + "push", + "--quiet", + "--force-with-lease=refs/heads/top:ccc", + "origin", + "new-sha:refs/heads/top", + ], ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, - }); - if (result._tag === "Failure") { - expect(result.failure.message).toContain("Earlier updates remain on GitHub"); - } - expect(api.calls.filter((args) => isMutation(args))).toHaveLength(1); }), ); -it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => +it.effect("checks every layer's write access before any git runs", () => Effect.gen(function* () { + gitCalls = []; const api = fake([ stack, - access, - branch(2, "bbb", rebasedParent ? 1 : 0), - ...(rebasedParent ? [rebased] : []), - branch(3, "ccc", 1, ["concurrent-parent-head"]), + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, ]); - const result = yield* runGitHubStackAction(api.execute, { - ...input, - action: "update-branch", - }).pipe(Effect.result); - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, - }); - expect(api.calls.at(-1)?.includes('ids=["PR_2"]')).toBe(true); - expect(api.calls.filter((args) => isMutation(args))).toHaveLength(rebasedParent ? 1 : 0); + yield* Effect.flip(runGitHubStackAction(api.execute, { ...input, action: "update-branch" })); + expect(gitCalls).toEqual([]); }), ); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts index 0ddb57a274bc..59d29627b81b 100644 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -10,6 +10,7 @@ import * as Schema from "effect/Schema"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; +import { cascadeRebaseStack } from "./githubStackRebase.ts"; const stackErrorIdentity = { repository: Schema.String, @@ -78,7 +79,10 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError = []; - for (const [index, layer] of open.entries()) { - yield* Effect.gen(function* () { - const read = yield* api.graphql({ - host: input.host, - operation: "runGitHubStackAction", - allowReserve: true, - variables: { - owner, - name, - number: layer.number, - sha: layer.headSha, - ids: processed.map((head) => head.id), - }, - // GitHub rejects a declared variable the document never uses, so `$ids` is always - // selected; an empty list asks for nothing. - query: `query($owner:String!,$name:String!,$number:Int!,$sha:String!,$ids:[ID!]!){processed:nodes(ids:$ids){... on PullRequest{headRefOid}} repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, - }); - const { - data: { - processed: observed, - repository: { pullRequest: pr }, - }, - } = yield* decodeRebaseBranch(read); - // A push to an earlier layer must not silently become the next layer's new base. - const changed = processed.find( - (head, index) => observed?.[index]?.headRefOid !== head.headSha, - ); - if (changed !== undefined) - return yield* new GitHubStackChangedError({ - ...identity, - number: changed.number, - completed: index, - }); - if (pr.headRefOid !== layer.headSha) - return yield* new GitHubStackChangedError({ - ...identity, - number: layer.number, - completed: index, - }); - if (pr.baseRef.compare.behindBy === 0) { - processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); - return; - } - // Pass the reviewed revision to GitHub, including when a push races this read. - const updated = yield* api.graphql({ - host: input.host, - operation: "runGitHubStackAction", - variables: { id: pr.id, sha: layer.headSha }, - query: - "mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", - }); - const response = yield* decodeRebaseResponse(updated); - processed.push({ - id: pr.id, - number: layer.number, - headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, - }); - }).pipe( - Effect.mapError((cause) => - cause._tag === "GitHubStackChangedError" - ? cause - : new GitHubStackRebaseFailedError({ - ...identity, - number: layer.number, - completed: index, - cause, - }), - ), - ); - } + // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old + // copy of every lower layer into the one above it. The cascade moves each layer's own commits. + yield* cascadeRebaseStack({ + host: input.host, + repository: input.repository, + base: stack.base, + layers: open.map((layer) => ({ + number: layer.number, + headBranch: layer.headBranch, + headSha: layer.headSha!, + })), + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackRebaseConflictError" || + cause._tag === "GitHubStackRebaseGitError" + ? new GitHubStackRebaseFailedError({ + ...identity, + number: cause.number, + completed: cause.completed, + cause, + }) + : cause, + ), + ); return; } if (open.some((layer) => layer.isDraft)) diff --git a/apps/server/src/pullRequest/githubStackRebase.test.ts b/apps/server/src/pullRequest/githubStackRebase.test.ts new file mode 100644 index 000000000000..26b801d13bd8 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackRebase.test.ts @@ -0,0 +1,162 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { cascadeRebaseStack } from "./githubStackRebase.ts"; + +const layer = Layer.mergeAll( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + }), + VcsProcess.layer, +).pipe(Layer.provideMerge(NodeServices.layer)); + +/** + * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a + * second layer sits on the first. Returns each branch's head. + */ +const setup = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" }); + const remote = `${root}/remote.git`; + const work = `${root}/work`; + const git = (cwd: string, ...args: string[]) => + process + .run({ + operation: "test", + command: "git", + args, + cwd, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }) + .pipe(Effect.map((out) => out.stdout.trim())); + const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text); + yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote); + yield* git(root, "clone", "--quiet", remote, work); + yield* write("index.ts", "export const answer = 41;\n"); + yield* git(work, "add", "-A"); + yield* git(work, "commit", "--quiet", "-m", "Initial"); + yield* git(work, "switch", "--quiet", "-c", "feat/answer-42"); + yield* write("index.ts", "export const answer = 42;\n"); + yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42"); + yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc"); + yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n"); + yield* git(work, "commit", "--quiet", "-am", "B: document the answer"); + yield* git(work, "switch", "--quiet", "main"); + yield* write("README.md", "main moved ahead\n"); + yield* git(work, "add", "-A"); + yield* git(work, "commit", "--quiet", "-m", "Docs"); + yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc"); + const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`); + return { + remote, + head, + git: (...args: string[]) => git(remote, ...args), + layers: [ + { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") }, + { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") }, + ], + }; +}); + +it.layer(layer)("cascadeRebaseStack", (it) => { + it.effect("moves each layer's own commits onto the rebased layer below it", () => + Effect.gen(function* () { + const repo = yield* setup; + const completed = yield* cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: repo.layers, + remote: repo.remote, + }); + assert.strictEqual(completed, 2); + + const main = yield* repo.head("main"); + const bottom = yield* repo.head("feat/answer-42"); + const top = yield* repo.head("feat/answer-doc"); + // Bottom sits on the new main; top sits on the new bottom with only its own commit. + expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main); + expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom); + expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe( + "B: document the answer", + ); + }).pipe(Effect.scoped), + ); + + it.effect("refuses to overwrite a layer pushed after it was reviewed", () => + Effect.gen(function* () { + const repo = yield* setup; + const stale = [ + repo.layers[0]!, + // Reviewed at the bottom layer's head, but the branch is really at its own commit. + { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha }, + ]; + const error = yield* Effect.flip( + cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: stale, + remote: repo.remote, + }), + ); + expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 }); + expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha); + }).pipe(Effect.scoped), + ); + + it.effect("stops at a conflicting layer and leaves it untouched", () => + Effect.gen(function* () { + const repo = yield* setup; + // Make main conflict with the bottom layer's change to the same line. + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" }); + const run = (...args: string[]) => + process.run({ + operation: "test", + command: "git", + args, + cwd: scratch, + env: { + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@t", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@t", + }, + }); + yield* run("clone", "--quiet", repo.remote, "."); + yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n"); + yield* run("commit", "--quiet", "-am", "Conflict"); + yield* run("push", "--quiet", "origin", "main"); + + const error = yield* Effect.flip( + cascadeRebaseStack({ + host: "github.com", + repository: "acme/web", + base: "main", + layers: repo.layers, + remote: repo.remote, + }), + ); + expect(error).toMatchObject({ + _tag: "GitHubStackRebaseConflictError", + number: 1, + completed: 0, + }); + expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/pullRequest/githubStackRebase.ts b/apps/server/src/pullRequest/githubStackRebase.ts new file mode 100644 index 000000000000..2cd2b5f88bb7 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackRebase.ts @@ -0,0 +1,155 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Base64 from "effect/encoding/Base64"; + +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +/** One open layer of a stack, bottom to top, with the head the reader reviewed. */ +export interface CascadeLayer { + readonly number: number; + readonly headBranch: string; + readonly headSha: string; +} + +/** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ +export class GitHubStackRebaseConflictError extends Schema.TaggedError()( + "GitHubStackRebaseConflictError", + { number: Schema.Int, completed: Schema.Int }, +) { + override get message(): string { + return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; + } +} + +/** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ +export class GitHubStackRebaseGitError extends Schema.TaggedError()( + "GitHubStackRebaseGitError", + { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, +) { + override get message(): string { + return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; + } +} + +/** The git URL of a repository on a GitHub host, which is also where its token is good. */ +function remoteUrl(host: string, repository: string): string { + return `https://${host.trim().toLowerCase()}/${repository}.git`; +} + +/** + * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer + * below it (the bottom one onto the stack's base). Each layer moves only its own commits — + * `git rebase --onto ` — so a rebased lower layer is never replayed into + * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. + * + * It works in a throwaway clone, so the environment's checkout never moves, and pushes each + * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused + * rather than overwritten. The stacks API has no rebase endpoint to call instead. + */ +export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { + readonly host: string; + readonly repository: string; + readonly base: string; + readonly layers: ReadonlyArray; + /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ + readonly remote?: string; +}) { + const api = yield* GitHubApi.GitHubApi; + const process = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const { token } = yield* api.credential(input.host); + const first = input.layers[0]; + if (first === undefined) return 0; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( + Effect.mapError( + (cause) => + new GitHubStackRebaseGitError({ + step: "preparing", + number: first.number, + completed: 0, + cause, + }), + ), + ); + // The token rides in a header for this process only: never in the URL, argv, or git config. + const remote = input.remote ?? remoteUrl(input.host, input.repository); + const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; + const env = { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, + GIT_CONFIG_VALUE_0: authorization, + GIT_CONFIG_KEY_1: "user.name", + GIT_CONFIG_VALUE_1: "T3 Code", + GIT_CONFIG_KEY_2: "user.email", + GIT_CONFIG_VALUE_2: "noreply@t3.codes", + }; + const git = (args: ReadonlyArray, allowNonZeroExit = false) => + process.run({ + operation: "cascadeRebaseStack", + command: "git", + args, + cwd: directory, + env, + timeoutMs: 120_000, + ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), + }); + const failed = (step: string, number: number, completed: number) => (cause: unknown) => + new GitHubStackRebaseGitError({ step, number, completed, cause }); + + yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); + yield* git(["remote", "add", "origin", remote]).pipe( + Effect.mapError(failed("preparing", first.number, 0)), + ); + yield* git([ + "fetch", + "--quiet", + "--no-tags", + "origin", + `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, + ...input.layers.map( + (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, + ), + ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); + + let parentOld = `origin/${input.base}`; + let parentNew = `origin/${input.base}`; + for (const [index, layer] of input.layers.entries()) { + // The bottom layer's old parent is where it forked from the base; above that it is the + // reviewed head of the layer below, which is exactly the commits this layer must not replay. + const upstream = + index === 0 + ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( + Effect.mapError(failed("reading the fork point", layer.number, index)), + )).stdout.trim() + : parentOld; + yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( + Effect.mapError(failed("checking out", layer.number, index)), + ); + const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( + Effect.mapError(failed("rebasing", layer.number, index)), + ); + if (rebase.exitCode !== 0) { + yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); + return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); + } + const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( + Effect.mapError(failed("rebasing", layer.number, index)), + )).stdout.trim(); + if (rebased !== layer.headSha) { + yield* git([ + "push", + "--quiet", + `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, + "origin", + `${rebased}:refs/heads/${layer.headBranch}`, + ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); + } + parentOld = layer.headSha; + parentNew = rebased; + } + return input.layers.length; +}, Effect.scoped); From 3dfe373fddb966101371805b0b102ae5af75b15b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:16:26 -0700 Subject: [PATCH 09/59] fix(web): diff panel keeps the chosen scope while a turn runs (#16571) Co-authored-by: Claude Opus 5.5 (1M context) --- .../web/src/components/ChatView.logic.test.ts | 20 ++++++ apps/web/src/components/ChatView.logic.ts | 5 +- apps/web/src/components/ChatView.tsx | 6 +- .../web/src/hooks/useActiveThreadRef.test.tsx | 61 +++++++++++++++++++ apps/web/src/hooks/useActiveThreadRef.ts | 20 ++++++ 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/hooks/useActiveThreadRef.test.tsx create mode 100644 apps/web/src/hooks/useActiveThreadRef.ts diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cc0c7dd35753..bf8d32f1daf3 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1786,6 +1786,26 @@ describe("proactive completed diff guard", () => { }), ).toBe("ignore"); }); + + it("leaves an already open diff and its chosen scope alone", () => { + const largeCheckpoint = { + status: "ready", + files: Array.from({ length: 3 }, (_, index) => ({ + path: `src/app-${index}.ts`, + kind: "modified" as const, + additions: 20, + deletions: 0, + })), + } satisfies Pick; + + expect( + resolveProactiveTurnDiffAction({ + checkpoint: largeCheckpoint, + isGitRepo: true, + activeSurfaceKind: "diff", + }), + ).toBe("ignore"); + }); }); describe("shouldRefocusComposerOnWindowFocus", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index dcbd4ab2f343..f36e54d334fe 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -185,7 +185,10 @@ export function resolveProactiveTurnDiffAction(input: { isGitRepo: boolean | undefined; activeSurfaceKind: RightPanelSurface["kind"] | null; }): "defer" | "ignore" | "open" { - if (input.activeSurfaceKind === "pull-request") return "ignore"; + // An open diff already shows the work; reopening it would reset the chosen scope. + if (input.activeSurfaceKind === "pull-request" || input.activeSurfaceKind === "diff") { + return "ignore"; + } if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 068b69ce0632..89b74c063da4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -173,6 +173,7 @@ import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; +import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { type ComposerSubmissionIntent, collapseExpandedComposerCursor, @@ -2192,10 +2193,7 @@ export default function ChatView(props: ChatViewProps) { } return labels; }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), - [activeThread], - ); + const activeThreadRef = useActiveThreadRef(activeThread); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser( activeThreadRef?.environmentId ?? null, diff --git a/apps/web/src/hooks/useActiveThreadRef.test.tsx b/apps/web/src/hooks/useActiveThreadRef.test.tsx new file mode 100644 index 000000000000..4afc4071f1bd --- /dev/null +++ b/apps/web/src/hooks/useActiveThreadRef.test.tsx @@ -0,0 +1,61 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useActiveThreadRef } from "./useActiveThreadRef"; + +type ThreadLike = { environmentId: EnvironmentId; id: ThreadId; status: string } | null; + +let renderer: ReactTestRenderer | null = null; +let observed: Array = []; + +function Probe({ thread }: { thread: ThreadLike }) { + observed.push(useActiveThreadRef(thread)); + return null; +} + +const thread = (id: string, status = "idle"): ThreadLike => ({ + environmentId: "env-1" as EnvironmentId, + id: id as ThreadId, + status, +}); + +async function render(value: ThreadLike) { + await act(() => { + if (renderer) renderer.update(); + else renderer = create(); + }); + return observed.at(-1); +} + +beforeEach(() => { + observed = []; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); +}); + +describe("useActiveThreadRef", () => { + it("keeps the same ref while the thread object is replaced during a run", async () => { + const first = await render(thread("t1", "running")); + const afterUpdate = await render(thread("t1", "waiting")); + + expect(first).toEqual({ environmentId: "env-1", threadId: "t1" }); + expect(afterUpdate).toBe(first); + }); + + it("returns a new ref when the thread changes or goes away", async () => { + const first = await render(thread("t1")); + const switched = await render(thread("t2")); + const gone = await render(null); + + expect(switched).not.toBe(first); + expect(switched).toEqual({ environmentId: "env-1", threadId: "t2" }); + expect(gone).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useActiveThreadRef.ts b/apps/web/src/hooks/useActiveThreadRef.ts new file mode 100644 index 000000000000..9333f6f1ea4d --- /dev/null +++ b/apps/web/src/hooks/useActiveThreadRef.ts @@ -0,0 +1,20 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +/** + * The scoped ref of the thread a view shows, stable for as long as it shows that thread. + * Takes the thread object but keys on its ids: the shell changes identity on every update + * during a run, and effects keyed on this ref must not re-run for that. + */ +export function useActiveThreadRef( + thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId } | null | undefined, +): ScopedThreadRef | null { + const environmentId = thread?.environmentId ?? null; + const threadId = thread?.id ?? null; + return useMemo( + () => + environmentId !== null && threadId !== null ? scopeThreadRef(environmentId, threadId) : null, + [environmentId, threadId], + ); +} From 78c9fcaafcbee9e2b941d8b0129a9f968373c6b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:55:06 -0700 Subject: [PATCH 10/59] fix(desktop): honor the telemetry opt-out from the shell profile (#16563) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/backend/DesktopBackendConfiguration.test.ts | 5 +++++ apps/desktop/src/backend/DesktopBackendConfiguration.ts | 1 + apps/desktop/src/shell/DesktopShellEnvironment.test.ts | 2 ++ apps/desktop/src/shell/DesktopShellEnvironment.ts | 3 +++ docs/user/telemetry.md | 3 +++ 5 files changed, 14 insertions(+) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 66fffb84f109..3b282c1c1db9 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -979,9 +979,11 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; + const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; + process.env.T3CODE_TELEMETRY_ENABLED = "false"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -989,6 +991,8 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.env.OTEL_SDK_DISABLED, "true"); assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); + assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -1009,6 +1013,7 @@ describe("DesktopBackendConfiguration", () => { } finally { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); + restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index a19e4ef7179e..289e3fb321f0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -101,6 +101,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "T3CODE_TELEMETRY_ENABLED", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 5111ea4ee533..bf0fd1f6da70 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -268,11 +268,13 @@ describe("DesktopShellEnvironment", () => { envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", + T3CODE_TELEMETRY_ENABLED: "false", }), }); assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + assert.equal(env.T3CODE_TELEMETRY_ENABLED, "false"); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 1143ed057b2b..01f194ea0544 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -85,6 +85,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", "WAYLAND_DISPLAY", + "T3CODE_TELEMETRY_ENABLED", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; @@ -450,6 +451,8 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", + // The telemetry opt-out is documented as a shell variable; GUI launches never see it. + "T3CODE_TELEMETRY_ENABLED", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 5fd4f91c9327..2f7e44825dc6 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -9,3 +9,6 @@ raw provider events, or child-agent output. Child-agent token use is excluded fr To disable collection, set `T3CODE_TELEMETRY_ENABLED=false` in the server's environment before starting it. This stops product events from being recorded or sent. + +The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and +Linux, so export it there and restart the app. On Windows, set it as a user environment variable. From 3b44698d07c722471279d6a933499d6108148632 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:55:07 -0700 Subject: [PATCH 11/59] docs(marketing): disclose product usage data in the privacy policy (#16562) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/marketing/src/pages/privacy-policy.astro | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/marketing/src/pages/privacy-policy.astro b/apps/marketing/src/pages/privacy-policy.astro index b85bc0b12b44..52e97084d8c2 100644 --- a/apps/marketing/src/pages/privacy-policy.astro +++ b/apps/marketing/src/pages/privacy-policy.astro @@ -21,8 +21,8 @@ const sections = [ description="How T3 Tools, Inc. handles information when you use T3 Code, app.t3.codes, and t3.codes." heading="T3 Code Privacy Policy" lede="This Privacy Policy describes how T3 Tools, Inc. collects, uses, and shares personal information when you use the T3 Code desktop, mobile, and hosted web applications, visit the t3.codes marketing site, or use the optional T3 Connect service." - effectiveDate="2026-07-14" - lastUpdated="2026-07-14" + effectiveDate="2026-10-06" + lastUpdated="2026-10-06" sections={sections} >
@@ -81,6 +81,12 @@ const sections = [ those features, such as project and thread titles, model name, status, and activity headline. +
  • + Product usage data. By default, T3 Code sends us anonymous usage + data, such as which provider and model a turn used, whether it succeeded, and your app + version and operating system. We use it to improve the product. You can turn this off + at any time; see “Product Usage Data” under Your Choices below. +
  • Information you provide to us. We collect information that you choose to provide when you request support, send feedback, or otherwise communicate with us. @@ -157,8 +163,8 @@ const sections = [
  • To monitor reliability, prevent abuse, and investigate security incidents;
  • To comply with applicable law and enforce the terms governing the Services; and
  • - To analyze and improve T3 Code using aggregated, de-identified, or other anonymous - operational information. + To analyze and improve T3 Code using product usage data and aggregated, + de-identified, or other anonymous operational information.
  • @@ -239,6 +245,13 @@ const sections = [ >https://vercel.com/legal/privacy-notice
    . +
  • + PostHog (PostHog, Inc.).
    + We use PostHog to receive and analyze product usage data.
    + You can view PostHog's privacy policy here:{" "}https://posthog.com/privacy. +
  • Axiom (Axiom, Inc.).
    We use Axiom for operational diagnostics that help us maintain and troubleshoot the @@ -264,7 +277,8 @@ const sections = [
  • Google (Google LLC).
    - We use Google services for app distribution and optional authentication.
    + We use Google services for app distribution, optional authentication, and Android + notifications.
    You can view Google's privacy policy here:{" "}https://policies.google.com/privacy. @@ -328,6 +342,13 @@ const sections = [ and notification permissions in your device settings.

    +

    Product Usage Data

    +

    + To stop sending product usage data, set the environment variable{" "} + T3CODE_TELEMETRY_ENABLED=false{" "}where T3 Code runs, for example in your + shell profile, and restart T3 Code. +

    +

    Cookies and Local Data

    You can use your browser settings to stop accepting or to delete cookies, although some From 115640ae49924c9e4e6215bd8d911445ad932bff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 13:55:07 -0700 Subject: [PATCH 12/59] feat(web): note anonymous usage data in onboarding and link the privacy policy (#16564) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/components/onboarding/WelcomeWizard.tsx | 17 ++++++++++++++++- .../src/components/settings/SettingsPanels.tsx | 14 ++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++++++ apps/web/src/legalLinks.ts | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/legalLinks.ts diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 638416103ddd..f8f37c603a4b 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -35,6 +35,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { @@ -413,8 +414,22 @@ function ConnectionStep({ -

    +
    +

    + T3 Code collects anonymous usage data to help us improve it. To read more about how your + data is used and how to opt out, see our{" "} + + privacy policy + + . +

    + } + /> Date: Tue, 6 Oct 2026 22:56:33 +0200 Subject: [PATCH 13/59] perf(web): diff panel no longer re-renders every file header each time a patch arrives (#16033) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/DiffPanel.tsx | 157 ++++++++++++------ .../components/diffs/AnnotatableCodeView.tsx | 12 +- .../components/diffs/useReviewFilePatches.ts | 55 +++--- 3 files changed, 145 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 50df89109e05..975f493c28f3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -21,7 +21,7 @@ import { import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { useFileContextMenuHandler } from "../fileContextMenu"; @@ -118,6 +118,74 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); +/** Collapse control for one file header; re-renders only when its own file changes. */ +function DiffFileCollapseToggle({ + filePath, + fileKey, + collapsed, + unavailable, + iconClassName, + onToggle, +}: { + filePath: string; + fileKey: string; + collapsed: boolean; + unavailable: boolean; + iconClassName: string; + onToggle: (fileKey: string) => void; +}) { + return ( + + { + event.stopPropagation(); + onToggle(fileKey); + }} + /> + } + > + + + {collapsed ? "Expand diff" : "Collapse diff"} + + ); +} + +/** Copy and status controls for one file header; re-renders only when its own file changes. */ +function DiffFileHeaderSuffix({ + filePath, + hasStat, + error, + truncated, + onRetry, +}: { + filePath: string; + hasStat: boolean; + error: boolean; + truncated: boolean; + onRetry: (path: string) => void; +}) { + return ( + <> + + {hasStat ? ( + onRetry(filePath)} /> + ) : null} + + ); +} + interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -598,22 +666,24 @@ export default function DiffPanel({ }, [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); - const toggleDiffFileCollapsed = useCallback( - (fileKey: string) => { - setCollapsedDiffFiles((current) => { - const next = new Set( - current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, - ); - if (next.has(fileKey)) { - next.delete(fileKey); - } else { - next.add(fileKey); - } - return { scopeKey: collapseScopeKey, fileKeys: next }; - }); - }, - [collapseScopeKey, defaultCollapsedDiffFileKeys], - ); + const collapseDefaultsRef = useRef({ collapseScopeKey, defaultCollapsedDiffFileKeys }); + useLayoutEffect(() => { + collapseDefaultsRef.current = { collapseScopeKey, defaultCollapsedDiffFileKeys }; + }, [collapseScopeKey, defaultCollapsedDiffFileKeys]); + const toggleDiffFileCollapsed = useCallback((fileKey: string) => { + const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current; + setCollapsedDiffFiles((current) => { + const next = new Set( + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, + ); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, []); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); @@ -1082,14 +1152,15 @@ export default function DiffPanel({ composerDraftTarget={composerDraftTarget} renderHeaderFilenameSuffix={(fileDiff) => { const path = resolveFileDiffPath(fileDiff); - const stat = fileStats.get(path); + const state = fileStates.get(path); return ( - <> - - {stat ? ( - retry(path)} /> - ) : null} - + ); }} {...(lazySource @@ -1109,37 +1180,15 @@ export default function DiffPanel({ : {})} renderHeaderPrefix={(fileDiff, fileKey) => { const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; - const collapsed = unavailable || collapsedDiffFileKeys.has(fileKey); - const filePath = resolveFileDiffPath(fileDiff); return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + ); }} options={{ diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 253a0f3d215e..52b538f92b0f 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -11,7 +11,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { fnv1a32 } from "~/lib/diffRendering"; +import { fnv1a32, resolveFileDiffPath } from "~/lib/diffRendering"; import { buildDiffReviewComment, restoreDiffReviewCommentRange, @@ -217,15 +217,15 @@ export function AnnotatableCodeView({ if (!range) return; const item = context.item; if (item.type !== "diff") return; - const file = filesByKey.get(item.id); - if (!file) return; + // Read from the item, not the file list, so this callback keeps its identity as + // patches arrive; the viewer re-applies its options whenever it changes. const id = nextFileCommentId(); const comment = buildDiffReviewComment({ id, sectionId, sectionTitle, - filePath: file.filePath, - fileDiff: file.fileDiff, + filePath: resolveFileDiffPath(item.fileDiff), + fileDiff: item.fileDiff, range, text: "", }); @@ -242,7 +242,7 @@ export function AnnotatableCodeView({ }, }); }, - [filesByKey, sectionId, sectionTitle], + [sectionId, sectionTitle], ); const hasOpenComment = draft !== null; diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index 60536fdd5d79..ad36f75c595c 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -3,7 +3,15 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import type { EnvironmentId, ReviewDiffPreviewSource } from "@t3tools/contracts"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { getRenderablePatch, resolveFileDiffPath, type RenderablePatch } from "~/lib/diffRendering"; import { reviewEnvironment } from "~/state/review"; @@ -146,12 +154,17 @@ export function useReviewFilePatches({ [requestFiles, settledFileCount], ); const requestFile = useCallback((index: number) => requestFiles([index]), [requestFiles]); + const retryInputsRef = useRef({ queries, files }); + useLayoutEffect(() => { + retryInputsRef.current = { queries, files }; + }, [queries, files]); const retry = useCallback( (path: string) => { + const { queries, files } = retryInputsRef.current; const query = queries.find(({ index }) => files[index]?.path === path)?.query; if (query) registry.refresh(query); }, - [queries, files, registry], + [registry], ); const renderableFiles = useMemo( () => @@ -185,23 +198,27 @@ export function useReviewFilePatches({ ), [source, files, patches, scope, preview], ); - const fileStates = new Map( - files.map((file, index) => { - const patch = patches.get(index); - return [ - file.path, - { - error: - patch?._tag === "Failure" || - (patch?._tag === "Success" && - (patch.value.patch?.kind !== "files" || - !patch.value.patch.files.some( - (candidate) => resolveFileDiffPath(candidate) === file.path, - ))), - truncated: patch?._tag === "Success" && patch.value.source.truncated, - }, - ] as const; - }), + const fileStates = useMemo( + () => + new Map( + files.map((file, index) => { + const patch = patches.get(index); + return [ + file.path, + { + error: + patch?._tag === "Failure" || + (patch?._tag === "Success" && + (patch.value.patch?.kind !== "files" || + !patch.value.patch.files.some( + (candidate) => resolveFileDiffPath(candidate) === file.path, + ))), + truncated: patch?._tag === "Success" && patch.value.source.truncated, + }, + ] as const; + }), + ), + [files, patches], ); const readyFilePaths = useMemo( () => From 2f85686d9dca5ec98c892a2a26c321e09d746d71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 14:09:35 -0700 Subject: [PATCH 14/59] feat(server): every T3 MCP tool declares who may call it (#16335) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 3 + apps/server/src/mcp/McpHttpServer.test.ts | 3 + apps/server/src/mcp/McpHttpServer.ts | 104 ++-- .../server/src/mcp/McpToolAccess.race.test.ts | 200 +++++++ apps/server/src/mcp/McpToolAccess.test.ts | 306 +++++++++++ apps/server/src/mcp/McpToolAccess.testkit.ts | 115 ++++ apps/server/src/mcp/McpToolAccess.ts | 295 +++++++++++ .../src/mcp/OrchestratorMcpService.test.ts | 495 +++++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 149 +++++- .../src/mcp/ThreadMetadataMcpService.test.ts | 88 ---- .../src/mcp/ThreadMetadataMcpService.ts | 37 -- apps/server/src/mcp/threadAccess.ts | 59 +-- .../src/mcp/toolkits/attachment/handlers.ts | 110 ++-- apps/server/src/mcp/toolkits/core.test.ts | 15 +- .../src/mcp/toolkits/device/handlers.ts | 19 +- apps/server/src/mcp/toolkits/device/tools.ts | 19 +- .../mcp/toolkits/environment/handlers.test.ts | 100 ++++ .../src/mcp/toolkits/environment/handlers.ts | 44 +- apps/server/src/mcp/toolkits/html/handlers.ts | 27 +- apps/server/src/mcp/toolkits/html/tools.ts | 6 +- .../src/mcp/toolkits/orchestrator/handlers.ts | 105 ++-- .../src/mcp/toolkits/orchestrator/tools.ts | 3 + .../src/mcp/toolkits/preview/handlers.ts | 71 ++- apps/server/src/mcp/toolkits/preview/tools.ts | 44 +- .../toolkits/previewControls/handlers.test.ts | 9 +- .../mcp/toolkits/previewControls/handlers.ts | 9 +- .../src/mcp/toolkits/previewControls/tools.ts | 2 + .../src/mcp/toolkits/project/handlers.test.ts | 108 +++- .../src/mcp/toolkits/project/handlers.ts | 268 +++++----- apps/server/src/mcp/toolkits/project/tools.ts | 6 +- .../toolkits/pullRequests/handlers.test.ts | 9 +- .../src/mcp/toolkits/pullRequests/handlers.ts | 74 +-- .../src/mcp/toolkits/pullRequests/tools.ts | 14 +- .../src/mcp/toolkits/thread/handlers.ts | 123 +++-- .../src/mcp/toolkits/worktree/handlers.ts | 14 +- .../server/src/mcp/toolkits/worktree/tools.ts | 8 +- .../DispatchModeLimit.test.ts | 167 ++++++ .../src/orchestration-v2/DispatchModeLimit.ts | 52 ++ .../src/orchestration-v2/Orchestrator.ts | 69 +++ .../orchestration-v2/ThreadMessageIntake.ts | 1 + .../provider/T3OrchestrationInstructions.ts | 2 +- apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.ts | 21 + .../orchestrator-mcp-server.md | 4 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-raw-mcp-registration.test.ts | 185 +++++++ .../rules/no-raw-mcp-registration.ts | 148 ++++++ vite.config.ts | 6 + 48 files changed, 3043 insertions(+), 680 deletions(-) create mode 100644 apps/server/src/mcp/McpToolAccess.race.test.ts create mode 100644 apps/server/src/mcp/McpToolAccess.test.ts create mode 100644 apps/server/src/mcp/McpToolAccess.testkit.ts create mode 100644 apps/server/src/mcp/McpToolAccess.ts create mode 100644 apps/server/src/mcp/toolkits/environment/handlers.test.ts create mode 100644 apps/server/src/orchestration-v2/DispatchModeLimit.test.ts create mode 100644 apps/server/src/orchestration-v2/DispatchModeLimit.ts create mode 100644 oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 60b90ca84f1f..3c2f4e5905eb 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -14,6 +14,7 @@ import * as ServerConfig from "../config.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; const environmentId = EnvironmentId.make("environment-device-test"); const threadId = ThreadId.make("thread-device-test"); @@ -102,6 +103,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ const layerTest = McpHttpServer.layerDeviceToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provideMerge(layerDeviceServiceMock), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })), Layer.provide(NodeServices.layer), @@ -172,6 +174,7 @@ it.effect("rejects unavailable agent access before booting or opening a device", Effect.provide( McpHttpServer.layerDeviceToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provide(layerUnavailable), Layer.provide(NodeServices.layer), ), diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 591b36548b2e..e7bcbdea8e5e 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -17,6 +17,7 @@ import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/htt import * as ProjectService from "../project/ProjectService.ts"; import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -51,12 +52,14 @@ const client = McpSchema.McpServerClient.of({ }); const layerTest = McpHttpServer.layerPreviewToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provideMerge(PreviewAutomationBroker.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), Layer.provideMerge(NodeServices.layer), ); const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provide( Layer.mergeAll( Layer.mock(ProjectService.ProjectService)({}), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 24f719c224ba..79f7f6079dad 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; -import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/ai"; +import { AiError, McpProtocol, McpSchema, McpServer, Tool, type Toolkit } from "effect/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { OrchestratorMcpFailure, PreviewAutomationError } from "@t3tools/contracts"; @@ -19,7 +19,9 @@ import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DeviceService from "../device/DeviceService.ts"; import * as HtmlRender from "../htmlRender/HtmlRender.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; @@ -396,6 +398,7 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const threads = yield* ThreadManagementService.ThreadManagementService; // The MCP tool runner only supplies the client, so hand the save path its services here. const saveServices = yield* Effect.context< ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto @@ -430,6 +433,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.flatMap(({ encodedResult }) => Effect.gen(function* () { @@ -655,6 +659,7 @@ const registerImageTool = ( const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () { const devices = yield* DeviceService.DeviceService; + const threads = yield* ThreadManagementService.ThreadManagementService; const built = yield* DeviceScreenshotToolkit; yield* registerImageTool( DeviceScreenshotTool, @@ -662,7 +667,11 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh built .handle("device_screenshot", payload) .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), - (effect) => effect.pipe(Effect.provideService(DeviceService.DeviceService, devices)), + (effect) => + effect.pipe( + Effect.provideService(DeviceService.DeviceService, devices), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + ), "screenshot", "Device screenshot failed.", ); @@ -672,6 +681,7 @@ const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { const htmlRender = yield* HtmlRender.HtmlRender; + const threads = yield* ThreadManagementService.ThreadManagementService; const built = yield* HtmlPreviewToolkit; yield* registerImageTool( HtmlPreviewTool, @@ -679,7 +689,11 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct built .handle("html_preview", payload) .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), - (effect) => effect.pipe(Effect.provideService(HtmlRender.HtmlRender, htmlRender)), + (effect) => + effect.pipe( + Effect.provideService(HtmlRender.HtmlRender, htmlRender), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + ), "preview", // Parameter errors and HTML render errors are both written by the server for the agent. (error) => @@ -689,17 +703,34 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct ); }); +/** + * `McpServer.toolkit` for handlers that declared their access (see + * `McpToolAccess`). Every toolkit on `/mcp` registers through this. + */ +export const toolkitRegistration = , EX, RX>( + toolkit: Toolkit.Toolkit, + handlers: McpToolAccess.HandlersLayer, +) => McpServer.toolkit(toolkit).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); + +/** A hand-registered tool, also only with handlers that declared their access. */ +const imageToolRegistration = , A, E, R, EX, RX>( + register: Effect.Effect, + handlers: McpToolAccess.HandlersLayer, +) => Layer.effectDiscard(register).pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); + export const layerHtmlToolkit = Layer.mergeAll( - McpServer.toolkit(HtmlRenderToolkit).pipe(Layer.provide(HtmlHandlers.layerRender)), - Layer.effectDiscard(registerHtmlPreview()).pipe(Layer.provide(HtmlHandlers.layerPreview)), + toolkitRegistration(HtmlRenderToolkit, HtmlHandlers.layerRender), + imageToolRegistration(registerHtmlPreview(), HtmlHandlers.layerPreview), ).pipe(Layer.provide(HtmlRender.layer)); -const layerPreviewStandardToolkitRegistration = McpServer.toolkit(PreviewStandardToolkit).pipe( - Layer.provide(PreviewHandlers.layerStandard), +const layerPreviewStandardToolkitRegistration = toolkitRegistration( + PreviewStandardToolkit, + PreviewHandlers.layerStandard, ); -const layerPreviewSnapshotRegistration = Layer.effectDiscard(registerPreviewSnapshot()).pipe( - Layer.provide(PreviewHandlers.layerSnapshot), +const layerPreviewSnapshotRegistration = imageToolRegistration( + registerPreviewSnapshot(), + PreviewHandlers.layerSnapshot, ); export const layerPreviewToolkit = Layer.mergeAll( @@ -707,47 +738,48 @@ export const layerPreviewToolkit = Layer.mergeAll( layerPreviewSnapshotRegistration, ); -export const layerOrchestratorToolkit = McpServer.toolkit(OrchestratorToolkit).pipe( - Layer.provide(OrchestratorHandlers.layer), - Layer.provide(OrchestratorMcpService.layer), - Layer.provide(ThreadMetadataMcpService.layer), -); +export const layerOrchestratorToolkit = toolkitRegistration( + OrchestratorToolkit, + OrchestratorHandlers.layer, +).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer)); -export const layerThreadToolkit = McpServer.toolkit(ThreadToolkit).pipe( - Layer.provide(ThreadHandlers.layer), -); +export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer); -const layerWorktreeToolkitRegistration = McpServer.toolkit(WorktreeToolkit).pipe( - Layer.provide(WorktreeHandlers.layer), - Layer.provide(WorktreeMcpService.layer), -); +const layerWorktreeToolkitRegistration = toolkitRegistration( + WorktreeToolkit, + WorktreeHandlers.layer, +).pipe(Layer.provide(WorktreeMcpService.layer)); -const layerPreviewControlsRegistration = McpServer.toolkit(PreviewControlsToolkit).pipe( - Layer.provide(PreviewControlsHandlers.layer), +const layerPreviewControlsRegistration = toolkitRegistration( + PreviewControlsToolkit, + PreviewControlsHandlers.layer, ); -const layerEnvironmentRegistration = McpServer.toolkit(EnvironmentToolkit).pipe( - Layer.provide(EnvironmentHandlers.layer), +const layerEnvironmentRegistration = toolkitRegistration( + EnvironmentToolkit, + EnvironmentHandlers.layer, ); -const layerProjectRegistration = McpServer.toolkit(ProjectToolkit).pipe( - Layer.provide(ProjectHandlers.layer), -); +const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); -const layerAttachmentRegistration = McpServer.toolkit(AttachmentToolkit).pipe( - Layer.provide(AttachmentHandlers.layer), +const layerAttachmentRegistration = toolkitRegistration( + AttachmentToolkit, + AttachmentHandlers.layer, ); -export const layerPullRequestsToolkit = McpServer.toolkit(PullRequestsToolkit).pipe( - Layer.provide(PullRequestsHandlers.layer), +export const layerPullRequestsToolkit = toolkitRegistration( + PullRequestsToolkit, + PullRequestsHandlers.layer, ); -const layerDeviceStandardToolkitRegistration = McpServer.toolkit(DeviceStandardToolkit).pipe( - Layer.provide(DeviceHandlers.layerStandard), +const layerDeviceStandardToolkitRegistration = toolkitRegistration( + DeviceStandardToolkit, + DeviceHandlers.layerStandard, ); -const layerDeviceScreenshotRegistration = Layer.effectDiscard(registerDeviceScreenshot()).pipe( - Layer.provide(DeviceHandlers.layerScreenshot), +const layerDeviceScreenshotRegistration = imageToolRegistration( + registerDeviceScreenshot(), + DeviceHandlers.layerScreenshot, ); export const layerDeviceToolkit = Layer.mergeAll( diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts new file mode 100644 index 000000000000..baffc00659eb --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -0,0 +1,200 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + OrchestratorMcpFailure, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; +import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; + +// A Supervised outside agent renames a thread through a `writesThreads` tool. +// The thread's user raises it to full access after the tool's check but +// before its write: the race the orchestrator closes under the thread's lock. + +const instanceId = ProviderInstanceId.make("codex"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for metadata commands"), +} as ProviderAdapterV2Shape; +const layerDatabase = SqlitePersistence.layerMemory; +const layerOrchestrator = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "mcp-mode-race" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); + +const RenameTool = Tool.make("rename", { + parameters: Schema.Struct({ threadId: ThreadId }), + success: Schema.Struct({ renamed: Schema.Boolean }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagement.ThreadManagementService, + ], +}); +const RenameToolkit = Toolkit.make(RenameTool); + +const supervisedClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:race", + thread: undefined, + client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}; + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-race", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-race", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([ + Schema.Struct({ renamed: Schema.Boolean }), + Schema.Struct({ code: Schema.String }), + ]), + ), +); + +/** Renames `threadId` as the Supervised client; the user may raise it in between. */ +const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const raised = yield* Ref.make(false); + const handlers = McpToolAccess.toLayer(RenameToolkit, { + rename: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + // The tool's own check has passed; the user acts before the write. + if (userRaises) { + // The user's own command carries no limit, so it lands. + yield* orchestrator + .dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("user-raise"), + threadId: input.threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.provideService(DispatchModeLimit, undefined), Effect.orDie); + yield* Ref.set(raised, true); + } + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`agent-rename:${userRaises}`), + threadId: input.threadId, + title: "Renamed by the agent", + }) + .pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The rename failed.", + }), + ), + ); + return { renamed: true }; + }), + ), + }); + const outcome = yield* McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name: "rename", arguments: { threadId } })), + Effect.flatMap(({ content }) => + decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, supervisedClient), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + Effect.provide( + McpHttpServer.toolkitRegistration(RenameToolkit, handlers).pipe( + Layer.provideMerge(McpServer.McpServer.layer), + ), + ), + ); + assert.equal(yield* Ref.get(raised), userRaises); + return outcome; + }); + +const createSupervisedThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId: ProjectId.make("project:mcp-race"), + title: "Before", + modelSelection: { instanceId, model: "gpt-5" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { + it.effect("refuses the write when the user raises the thread after the check", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:race-raised"); + yield* createSupervisedThread(threadId); + const outcome = yield* renameRacingTheUser(threadId, true); + assert.deepEqual(outcome, { code: "runtime_mode_escalation_denied" }); + const shell = yield* projections.getThreadShell(threadId); + assert.equal(shell?.runtimeMode, "full-access"); + assert.equal(shell?.title, "Before"); + }).pipe(Effect.provide(ThreadManagement.layer)), + ); + + it.effect("lets the write through when the thread stays within the caller's modes", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:race-steady"); + yield* createSupervisedThread(threadId); + const outcome = yield* renameRacingTheUser(threadId, false); + assert.deepEqual(outcome, { renamed: true }); + assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); + }).pipe(Effect.provide(ThreadManagement.layer)), + ); +}); diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts new file mode 100644 index 000000000000..3139faf27d54 --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -0,0 +1,306 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + OrchestratorMcpFailure, + ProviderInstanceId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; + +import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; +import { liveThreadShell } from "./McpToolAccess.testkit.ts"; + +const supervisedThreadId = ThreadId.make("thread:supervised"); +const planThreadId = ThreadId.make("thread:plan"); +const fullAccessThreadId = ThreadId.make("thread:full-access"); +const endedThreadId = ThreadId.make("thread:ended"); + +const shells = new Map([ + [supervisedThreadId, liveThreadShell(supervisedThreadId, { runtimeMode: "approval-required" })], + [ + planThreadId, + liveThreadShell(planThreadId, { runtimeMode: "approval-required", interactionMode: "plan" }), + ], + [fullAccessThreadId, liveThreadShell(fullAccessThreadId)], + [endedThreadId, liveThreadShell(endedThreadId, { activeRunId: null })], +]); + +const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment"), + requestNamespace: `provider:${threadId}`, + thread: { + threadId, + providerSessionId: `provider:${threadId}`, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}); + +const clientCaller = ( + runtimeModeCeiling: RuntimeMode, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session", + thread: undefined, + client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +// One tool per declaration, each answering with the modes it was handed, so +// the table below reads what the gate let through. +const probe = { + success: Schema.Struct({ ran: Schema.String }), + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagement.ThreadManagementService, + ], +}; +const ProbeToolkit = Toolkit.make( + Tool.make("reads", probe), + Tool.make("reads_as_caller", probe), + Tool.make("acts_as_caller", probe), + Tool.make("writes", probe), + Tool.make("writes_threads", { + ...probe, + parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + }), + Tool.make("starts_threads", { + ...probe, + parameters: Schema.Struct({ + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), + }), + }), + Tool.make("writes_environment", probe), +); +const ran = Effect.succeed({ ran: "ran" }); +const probeHandlers: McpToolAccess.Handlers = { + reads: McpToolAccess.reads(() => ran), + reads_as_caller: McpToolAccess.readsAsCaller(() => ran), + acts_as_caller: McpToolAccess.actsAsCaller(() => ran), + writes: McpToolAccess.writes(() => ran), + writes_threads: McpToolAccess.writesThreads( + (input) => [input.threadId], + () => ran, + ), + starts_threads: McpToolAccess.startsThreads( + (input) => input, + (_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }), + ), + writes_environment: McpToolAccess.writesEnvironment(() => ran), +}; +const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers); + +const unchecked = () => ran; + +// What the compiler refuses: each line below must fail to typecheck, and its +// `@ts-expect-error` fails the build if one ever compiles. Never called. +export const refusedAtCompileTime = () => { + McpToolAccess.toLayer(ProbeToolkit, { + ...probeHandlers, + // @ts-expect-error a handler that skips its declaration + reads: unchecked, + }); + McpToolAccess.toLayer(ProbeToolkit, { + ...probeHandlers, + // @ts-expect-error a declaration's fields copied onto an unchecked handler + reads: Object.assign( + unchecked, + McpToolAccess.reads(() => ran), + ), + }); + // @ts-expect-error a declaration built outside McpToolAccess + McpToolAccess.Declaration.make(unchecked); + // @ts-expect-error a declaration constructed directly + new McpToolAccess.Declaration(unchecked); + // @ts-expect-error a handlers layer built outside McpToolAccess + McpToolAccess.HandlersLayer.make(Layer.empty); + // The refused layer below types its error and services as unknown, which is fine here. + // @effect-diagnostics-next-line anyUnknownInErrorContext:off + McpHttpServer.toolkitRegistration( + ProbeToolkit, + // @ts-expect-error a handlers layer that skipped `McpToolAccess.toLayer` + ProbeToolkit.toLayer({ + reads: unchecked, + reads_as_caller: unchecked, + acts_as_caller: unchecked, + writes: unchecked, + writes_threads: unchecked, + starts_threads: unchecked, + writes_environment: unchecked, + }), + ); +}; + +const probeServer = (threads: Layer.Layer) => + McpHttpServer.toolkitRegistration(ProbeToolkit, layerProbeHandlers).pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(threads), + ); + +/** A probe's answer, or the gate's refusal, as the result's JSON text. */ +const decodeOutcome = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Union([Schema.Struct({ ran: Schema.String }), Schema.Struct({ code: Schema.String })]), + ), +); + +const call = ( + tool: string, + scope: McpInvocationContext.McpInvocationScope, + args: Record = {}, +) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name: tool, arguments: args })), + Effect.flatMap(({ content }) => + decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")), + ), + Effect.map((outcome) => ("code" in outcome ? outcome.code : outcome.ran)), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +const supervised = threadCaller(supervisedThreadId); +const plan = threadCaller(planThreadId); +const fullAccess = threadCaller(fullAccessThreadId); +const ended = threadCaller(endedThreadId); +const supervisedClient = clientCaller("approval-required"); +const fullAccessClient = clientCaller("full-access"); +// A live full-access thread whose credential may use its browser but not control threads. +const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; + +it.effect.each([ + // Reads are open to every caller, even one whose turn ended. + ["reads", ended, {}, "ran"], + ["reads", supervisedClient, {}, "ran"], + + // What belongs to the calling thread needs one; changing it needs its live turn. + ["reads_as_caller", ended, {}, "ran"], + ["reads_as_caller", fullAccessClient, {}, "thread_credential_required"], + ["acts_as_caller", supervised, {}, "ran"], + ["acts_as_caller", ended, {}, "parent_not_active"], + ["acts_as_caller", fullAccessClient, {}, "thread_credential_required"], + + // Any change needs a thread caller's live turn; a client has none to lose. + ["writes", supervised, {}, "ran"], + ["writes", ended, {}, "parent_not_active"], + ["writes", supervisedClient, {}, "ran"], + // Uploads, scheduled tasks, projects and settings need a credential that controls threads. + ["writes", previewOnly, {}, "capability_denied"], + ["writes_environment", previewOnly, {}, "capability_denied"], + + // Changing a thread: only one that runs within the caller's modes. + ["writes_threads", supervised, {}, "ran"], + ["writes_threads", supervised, { threadId: planThreadId }, "ran"], + [ + "writes_threads", + supervised, + { threadId: fullAccessThreadId }, + "runtime_mode_escalation_denied", + ], + ["writes_threads", plan, { threadId: supervisedThreadId }, "interaction_mode_escalation_denied"], + ["writes_threads", fullAccess, { threadId: supervisedThreadId }, "ran"], + ["writes_threads", ended, { threadId: supervisedThreadId }, "parent_not_active"], + ["writes_threads", supervisedClient, { threadId: supervisedThreadId }, "ran"], + [ + "writes_threads", + supervisedClient, + { threadId: fullAccessThreadId }, + "runtime_mode_escalation_denied", + ], + // A thread that does not exist is the tool's to report. + ["writes_threads", supervised, { threadId: "thread:missing" }, "ran"], + + // Starting a thread: the caller's own modes or narrower. + ["starts_threads", supervised, {}, "approval-required/default"], + ["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"], + ["starts_threads", plan, {}, "approval-required/plan"], + ["starts_threads", plan, { interactionMode: "default" }, "interaction_mode_escalation_denied"], + ["starts_threads", fullAccess, { runtimeMode: "auto", interactionMode: "plan" }, "auto/plan"], + ["starts_threads", ended, {}, "parent_not_active"], + ["starts_threads", supervisedClient, {}, "approval-required/default"], + ["starts_threads", supervisedClient, { runtimeMode: "auto" }, "runtime_mode_escalation_denied"], + ["starts_threads", fullAccessClient, { runtimeMode: "full-access" }, "full-access/default"], + + // Projects and environment settings need full access. + ["writes_environment", fullAccess, {}, "ran"], + ["writes_environment", supervised, {}, "capability_denied"], + ["writes_environment", plan, {}, "capability_denied"], + ["writes_environment", fullAccessClient, {}, "ran"], + ["writes_environment", supervisedClient, {}, "capability_denied"], +] as const)("%s from %o with %o: %s", ([tool, scope, args, expected]) => + call(tool, scope, args).pipe( + Effect.map((outcome) => expect(outcome).toBe(expected)), + Effect.provide( + probeServer( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null), + }), + ), + ), + ), +); + +it("refuses a declaration or handlers layer built outside McpToolAccess", () => { + // Private constructors only bind the type checker; Reflect.construct reaches them anyway. + expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError); + expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError); +}); + +it("refuses a declaration or handlers layer that only wears another's fields", () => { + // The types already refuse these; at runtime the private fields are missing too. + const copiedDeclaration = Object.assign( + unchecked, + McpToolAccess.reads(() => ran), + ); + expect(() => + McpToolAccess.toLayer(ProbeToolkit, { + ...probeHandlers, + // @ts-expect-error a declaration's fields copied onto an unchecked handler + reads: copiedDeclaration, + }), + ).toThrow(TypeError); + const copiedLayer = Object.assign(Layer.empty, layerProbeHandlers); + expect(() => McpToolAccess.HandlersLayer.layer(copiedLayer)).toThrow(TypeError); +}); + +it.effect("refuses a change when the calling thread cannot be read", () => + call("writes", supervised).pipe( + Effect.map((outcome) => expect(outcome).toBe("orchestration_error")), + Effect.provide( + probeServer( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.fail(new OrchestratorProjectionError({ threadId, cause: "unreadable" })), + }), + ), + ), + ), +); diff --git a/apps/server/src/mcp/McpToolAccess.testkit.ts b/apps/server/src/mcp/McpToolAccess.testkit.ts new file mode 100644 index 000000000000..687b34cac2ff --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.testkit.ts @@ -0,0 +1,115 @@ +import { + type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, + ProjectId, + type ProviderInteractionMode, + ProviderInstanceId, + RunId, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; + +const EPOCH = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + +/** + * A thread in the middle of a turn on the `codex` instance: the shape the + * access gate reads for a thread caller, and for the threads a caller targets. + */ +export const liveThreadShell = ( + id: ThreadId, + modes: { + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly activeRunId?: RunId | null; + } = {}, +): OrchestrationV2ThreadShell => { + const providerInstanceId = ProviderInstanceId.make("codex"); + return { + id, + projectId: ProjectId.make("project:mcp-test"), + title: id, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + runtimeMode: modes.runtimeMode ?? "full-access", + interactionMode: modes.interactionMode ?? "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + activeProviderThreadId: null, + lineage: { rootThreadId: id, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + latestRunId: null, + activeRunId: modes.activeRunId === undefined ? RunId.make("run:mcp-test") : modes.activeRunId, + status: "running", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: EPOCH, + updatedAt: EPOCH, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +}; + +/** + * `ThreadManagementService` that answers only the access gate's lookups, + * every thread a live full-access one. For tests of a tool rather than of who + * may call it. + */ +export const liveThreadsLayer = Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), +}); + +/** + * The thread of `shell` with no runs, messages or other records: what a + * caller looks like once its turn has ended. + */ +export const idleThreadProjection = ( + shell: OrchestrationV2ThreadShell, +): OrchestrationV2ThreadProjection => { + const { + status: _status, + activeRunId: _activeRunId, + latestRunId: _latestRunId, + pendingRuntimeRequest: _pendingRuntimeRequest, + latestVisibleMessage: _latestVisibleMessage, + latestUserMessageAt: _latestUserMessageAt, + hasActionableProposedPlan: _hasActionableProposedPlan, + itemCount: _itemCount, + visibleItemCount: _visibleItemCount, + lastVisitedAt, + ...thread + } = shell; + return { + thread: { ...thread, lastVisitedAt: lastVisitedAt ?? null }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: shell.updatedAt, + }; +}; diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts new file mode 100644 index 000000000000..b6c623a11901 --- /dev/null +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -0,0 +1,295 @@ +import { + OrchestratorMcpFailure, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Struct from "effect/Struct"; +import type * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import type { Tool, Toolkit } from "effect/ai"; + +import { + DispatchModeLimit, + type DispatchModeRefusal, +} from "../orchestration-v2/DispatchModeLimit.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; +import { resolveInteractionMode, resolveRuntimeMode } from "./OrchestratorMcpService.ts"; +import { + assertFullAccess, + assertLiveCaller, + assertTargetWithinLimits, + type Caller, + loadCaller, + readCaller, + unavailable, +} from "./threadAccess.ts"; + +// Only the classes below assign these, from their static blocks, so nothing +// outside this module can build a declaration or a handlers layer. + +/** + * Both constructors demand this. Their private constructors only stop the type + * checker; this also stops `Reflect.construct` and friends at runtime. + */ +const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); + +const refuseOutsideConstruction = (token: symbol) => { + if (token !== builtHere) { + throw new TypeError("Only McpToolAccess builds MCP tool declarations and handler layers."); + } +}; + +/** Builds a declaration; only the declaration functions below call it. */ +let declare: ( + handle: (params: P) => Effect.Effect, +) => Declaration<(params: P) => Effect.Effect>; + +type CheckedHandlerOf = D extends Declaration ? Handler : never; + +/** Turns each declaration back into the handler it checks, keeping its type. */ +interface CheckedHandler extends Struct.Lambda { + (declaration: Declaration): Handler; + readonly "~lambda.out": CheckedHandlerOf; +} +let checkedHandler: CheckedHandler; + +/** Only `toLayer` below builds one. */ +let handlersLayer: , EX, RX>( + layer: Layer.Layer, EX, RX>, +) => HandlersLayer; + +/** + * Who may call a T3 MCP tool. Every handler is built by one of the + * declarations below, which say what the tool does. `toLayer` accepts only + * declarations, and `/mcp` registers only layers `toLayer` built, so a tool + * without a decision here does not compile. Both are nominal classes, so a + * handler or layer cannot pass for one by copying its fields. Effect's own + * registration functions accept any handler, so the + * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside + * McpHttpServer. + * + * Parameters choose the target; the caller sets the limits: a thread caller + * its own runtime and interaction modes, an outside client the ceiling it was + * approved with. Nothing a caller starts or changes may run with broader + * modes, and a thread caller changes things only while its own run is live. + * A refusal is an `OrchestratorMcpFailure`, so the compiler requires it in the + * tool's failure schema, and `ThreadManagementService` in its dependencies. + * + * The declaration checks the caller before the handler runs. Handlers still + * check what only they can see, such as a queued run belonging to its thread. + */ +export class Declaration { + // A private field makes the class nominal, and only this module can build + // one: its constructor is private and the static block hands the only way + // in to module-scoped functions. Copying a declaration's fields onto + // anything else fails to typecheck and, at runtime, to run. + readonly #handle: Handler; + private constructor(token: typeof builtHere, handle: Handler) { + refuseOutsideConstruction(token); + this.#handle = handle; + } + static { + declare = (handle) => new Declaration(builtHere, handle); + checkedHandler = Struct.lambda((declaration) => declaration.#handle); + } +} + +/** + * The caller of a tool that changes something. Tools that act for a + * capability of their own (preview, device, worktree, pull requests) check it + * themselves. + */ +const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller)); + +/** The same, for tools whose only capability is controlling threads. */ +const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller)); + +const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), +); + +/** Changes nothing, so every caller may call it. */ +export const reads = (handle: (params: P) => Effect.Effect) => + declare((params: P) => handle(params)); + +/** Reads what belongs to the calling T3 thread, such as its preview tabs or devices. */ +export const readsAsCaller = (handle: (params: P) => Effect.Effect) => + declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params)))); + +/** + * Acts as the calling T3 thread (its subagents, preview tabs, devices, + * worktree) while that thread's run is live. Only an agent running inside a + * T3 thread has one. + */ +export const actsAsCaller = (handle: (params: P) => Effect.Effect) => + declare((params: P) => + requireThreadCaller.pipe( + Effect.flatMap(() => writingCaller), + Effect.flatMap(() => handle(params)), + ), + ); + +/** Changes something that belongs to no thread, such as a pending upload or a scheduled task. */ +export const writes = (handle: (params: P) => Effect.Effect) => + declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); + +/** + * Changes the threads `threads` names. An omitted id is the caller's own + * thread; any other thread must run within the caller's modes, checked here + * and again by the orchestrator when it applies each command. A thread that + * does not exist is the handler's to report. + */ +export const writesThreads = ( + threads: (params: P) => ReadonlyArray, + handle: (params: P) => Effect.Effect, +) => + declare((params: P) => + Effect.gen(function* () { + const caller = yield* writingCaller; + for (const threadId of threads(params)) { + if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue; + const target = yield* caller.threads + .getThreadShell(threadId) + .pipe(Effect.mapError(unavailable)); + if (target !== null && target.deletedAt === null) { + yield* assertTargetWithinLimits(caller.limits, target); + } + } + // The target's user can raise its modes after the check above; the + // orchestrator checks again under the thread's lock and records its + // refusal here, since handlers wrap dispatch errors their own way. + const refused = yield* Ref.make(undefined); + return yield* handle(params).pipe( + Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), + Effect.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined ? error : escalationDenied(refusal), + ), + ), + ), + ); + }), + ); + +/** How an agent hears that a thread it targets was raised above its modes mid-call. */ +const escalationDenied = (refusal: DispatchModeRefusal) => + new OrchestratorMcpFailure({ + code: + refusal.mode === "runtime" + ? "runtime_mode_escalation_denied" + : "interaction_mode_escalation_denied", + message: `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this caller's. Its user changed it while this call ran.`, + }); + +/** The modes a started thread runs with: those requested, else the caller's own. */ +export interface StartedModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +/** Starts threads with the modes `modes` requests, which may not be broader than the caller's. */ +export const startsThreads = ( + modes: (params: P) => { + readonly runtimeMode?: RuntimeMode | undefined; + readonly interactionMode?: ProviderInteractionMode | undefined; + }, + handle: (params: P, modes: StartedModes) => Effect.Effect, +) => + declare((params: P) => + Effect.gen(function* () { + const { limits } = yield* writingCaller; + const requested = modes(params); + const started: StartedModes = { + runtimeMode: yield* resolveRuntimeMode(limits.runtimeMode, requested.runtimeMode), + interactionMode: yield* resolveInteractionMode( + limits.interactionMode, + requested.interactionMode, + ), + }; + return yield* handle(params, started); + }), + ); + +const fullAccessRequired = + "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; + +/** + * Changes projects or environment settings, which needs a full-access/default + * caller. `check` re-checks the caller wherever the handler waits before + * writing, such as for a lock, since the caller's modes can change meanwhile. + */ +export const writesEnvironment = ( + handle: ( + params: P, + check: Effect.Effect, + ) => Effect.Effect, +) => { + const check = orchestratingCaller.pipe( + Effect.tap((caller) => assertFullAccess(caller, fullAccessRequired)), + ); + return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check)))); +}; + +/** What a declaration's own check needs. */ +type CheckServices = Effect.Services; + +/** + * Each handler of `Handlers`, built by one of the declarations above. A + * declaration is never callable, which rules out a handler function wearing a + * declaration's fields. + */ +type Declarations = { + readonly [Name in keyof Handlers]: Declaration & NotCallable; +}; + +/** Anything but a function. */ +type NotCallable = { readonly call?: never } & { readonly apply?: never }; + +/** A toolkit's handlers, each built by one of the declarations above. */ +export type Handlers> = Declarations< + Toolkit.HandlersFrom +>; + +/** + * A toolkit's handler layer built by `toLayer`, the only kind `/mcp` + * registers. Like a declaration it is nominal, so nothing else passes for one. + */ +export class HandlersLayer, EX = never, RX = never> { + readonly #layer: Layer.Layer, EX, RX>; + private constructor( + token: typeof builtHere, + layer: Layer.Layer, EX, RX>, + ) { + refuseOutsideConstruction(token); + this.#layer = layer; + } + static { + handlersLayer = (layer) => new HandlersLayer(builtHere, layer); + } + /** The handlers, for registering this toolkit on the MCP server. */ + static layer, EX, RX>( + handlers: HandlersLayer, + ) { + return handlers.#layer; + } +} + +const checkedHandlers = (declarations: Declarations): Handlers => + Struct.map(declarations, checkedHandler); + +/** `Toolkit.toLayer` for handlers that all declare their access. */ +export const toLayer = , EX = never, RX = never>( + toolkit: Toolkit.Toolkit, + build: Handlers | Effect.Effect, EX, RX>, +): HandlersLayer> => + handlersLayer( + toolkit.toLayer( + Effect.isEffect(build) + ? Effect.map(build, checkedHandlers>) + : checkedHandlers(build), + ), + ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index a73e4599db12..dfad03f9974f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1,8 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { + CommandId, EnvironmentId, NodeId, + type OrchestrationV2ThreadShell, + type ScheduledTask, + ScheduledTaskId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -16,7 +20,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; +import { + DispatchModeLimit, + type DispatchModeRefusal, +} from "../orchestration-v2/DispatchModeLimit.ts"; +import { + OrchestratorProjectionError, + OrchestratorThreadAboveModeLimitError, +} from "../orchestration-v2/Orchestrator.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -26,6 +37,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; describe("OrchestratorMcpService", () => { @@ -283,6 +295,8 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // The child still runs within the parent's modes. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.as({} as never), @@ -357,6 +371,8 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // The child still runs within the parent's modes. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen(Effect.fail(new Error("simulated stop failure") as never)), @@ -434,6 +450,8 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // The child still runs within the parent's modes. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.andThen( @@ -479,6 +497,258 @@ describe("OrchestratorMcpService", () => { }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); }), ); + + it.effect("refuses to cancel a task whose child now runs above the parent's modes", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-parent"); + const childThreadId = ThreadId.make("thread:mcp-cancel-dispose-failed-child"); + const childRunId = RunId.make("run:mcp-cancel-dispose-failed-child"); + const taskId = NodeId.make("node:mcp-cancel-dispose-failed-task"); + const dispatched = yield* Ref.make>([]); + // The parent runs Supervised in plan mode. + const parentProjection = { + thread: { id: parentThreadId, runtimeMode: "approval-required", interactionMode: "plan" }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + result: null, + completionDelivery: { state: "pending" }, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + thread: { id: childThreadId }, + runs: [{ id: childRunId, status: "running" }], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + } as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + // Its user has since raised the child to full access. + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + command.type === "delegated_task.completion-delivery.dispose" + ? Effect.fail(new Error("simulated disposal failure") as never) + : Effect.succeed({} as never), + ), + ), + stopDelegatedTasks: () => Effect.void, + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-cancel-dispose-failed"), + requestNamespace: "provider-session:mcp-cancel-dispose-failed", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-dispose-failed", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .cancelTask(scope, { taskId, clientRequestId: "cancel-above-modes" }) + .pipe(Effect.flip); + assert.equal(error.code, "runtime_mode_escalation_denied"); + assert.deepEqual(yield* Ref.get(dispatched), []); + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }), + ); + + /** + * A Supervised parent cancels its Supervised child, under which a task + * now runs at full access. Reports what the cancel did. + */ + const cancelOverRaisedGrandchild = (child: { + readonly deleted: boolean; + /** The grandchild passes the check, and its user raises it before the stop reaches it. */ + readonly raisedDuringStop?: boolean; + }) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-cancel-grandchild-parent"); + const childThreadId = ThreadId.make("thread:mcp-cancel-grandchild-child"); + const grandchildThreadId = ThreadId.make("thread:mcp-cancel-grandchild-grandchild"); + const taskId = NodeId.make("node:mcp-cancel-grandchild-task"); + const dispatched = yield* Ref.make>([]); + const stoppedBelow = yield* Ref.make(false); + const appOwnedTask = (id: string, threadId: ThreadId, childId: ThreadId) => ({ + id: NodeId.make(id), + threadId, + origin: "app_owned", + childThreadId: childId, + driver: "codex", + model: "gpt-5.6-terra", + result: null, + completionDelivery: { state: "pending" }, + }); + // A Supervised parent delegated a Supervised child, which delegated a task of its own. + const projections = new Map([ + [ + parentThreadId, + { + thread: { + id: parentThreadId, + runtimeMode: "approval-required", + interactionMode: "default", + }, + runs: [], + contextTransfers: [], + subagents: [appOwnedTask(taskId, parentThreadId, childThreadId)], + }, + ], + [ + childThreadId, + { + thread: { id: childThreadId }, + runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], + contextTransfers: [], + messages: [], + subagents: [ + appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId), + ], + providerThreads: [], + }, + ], + [ + grandchildThreadId, + { + thread: { id: grandchildThreadId }, + runs: [], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + }, + ], + ]) as unknown as ReadonlyMap; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), + // The child still runs Supervised; its user has since raised the task under it + // to full access. + getThreadShell: (threadId) => + Effect.succeed( + threadId === grandchildThreadId && child.raisedDuringStop !== true + ? liveThreadShell(threadId) + : threadId === childThreadId && child.deleted + ? null + : liveThreadShell(threadId, { runtimeMode: "approval-required" }), + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.as({} as never), + ), + // Stands in for the orchestrator, which finds the grandchild raised + // under its lock, when the stop runs under the parent's limit. + stopDelegatedTasks: () => + Effect.gen(function* () { + const limit = yield* DispatchModeLimit; + if (child.raisedDuringStop === true && limit?.refused !== undefined) { + const refusal: DispatchModeRefusal = { + threadId: grandchildThreadId, + mode: "runtime", + runtimeMode: "full-access", + interactionMode: "default", + }; + yield* Ref.set(limit.refused, refusal); + return yield* new OrchestratorThreadAboveModeLimitError({ + commandId: CommandId.make("stop-grandchild"), + threadId: grandchildThreadId, + mode: "runtime", + runtimeMode: "full-access", + interactionMode: "default", + }); + } + yield* Ref.set(stoppedBelow, true); + }), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-cancel-grandchild"), + requestNamespace: "provider-session:mcp-cancel-grandchild", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-cancel-grandchild", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + return yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* service + .cancelTask(scope, { taskId, clientRequestId: "cancel-grandchild-above-modes" }) + .pipe(Effect.flip); + return { + code: error.code, + dispatched: yield* Ref.get(dispatched), + stoppedBelow: yield* Ref.get(stoppedBelow), + }; + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(layerDependencies)))); + }); + + it.effect("refuses to cancel a task when a task under it now runs above the parent's modes", () => + Effect.gen(function* () { + assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: false }), { + code: "runtime_mode_escalation_denied", + dispatched: [], + stoppedBelow: false, + }); + }), + ); + + it.effect("reports a task under the child that its user raises while it is being stopped", () => + Effect.gen(function* () { + const outcome = yield* cancelOverRaisedGrandchild({ deleted: false, raisedDuringStop: true }); + assert.equal(outcome.code, "runtime_mode_escalation_denied"); + assert.isFalse(outcome.stoppedBelow); + }), + ); + + it.effect("checks the tasks under a deleted child before cancelling it", () => + Effect.gen(function* () { + assert.deepEqual(yield* cancelOverRaisedGrandchild({ deleted: true }), { + code: "runtime_mode_escalation_denied", + dispatched: [], + stoppedBelow: false, + }); + }), + ); }); describe("OrchestratorMcpService provider resolution", () => { @@ -1294,4 +1564,227 @@ describe("OrchestratorMcpService provider resolution", () => { } }), ); + + describe("scheduled tasks at modes above the caller's", () => { + const projectId = ProjectId.make("project:scheduled"); + const boundThreadId = ThreadId.make("thread:scheduled-bound"); + const task = (overrides: Partial): ScheduledTask => ({ + id: ScheduledTaskId.make("scheduled-task:webhook"), + title: "On push", + prompt: "Do {{body.instruction}}", + enabled: true, + schedule: { type: "webhook", signature: null }, + projectId, + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "approval-required", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + createdAt: "2026-10-05T00:00:00.000Z", + updatedAt: "2026-10-05T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + webhook: { + path: "/hooks/scheduled-task/secret", + url: "https://t3.example/hooks/secret", + hasSecret: false, + }, + ...overrides, + }); + const supervisedClient: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:scheduled"), + requestNamespace: "client:scheduled", + thread: undefined, + client: { + sessionId: "scheduled", + label: "Claude Code", + runtimeModeCeiling: "approval-required", + }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const service = ( + tasks: ReadonlyArray, + boundThread: OrchestrationV2ThreadShell | null, + upserted: Ref.Ref, + ) => + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed(threadId === boundThreadId ? boundThread : null), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks }), + upsert: () => + Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: tasks[0]! })), + }), + ), + ), + ); + + it.effect("hides a webhook URL from a caller below the task's modes", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((mcp) => mcp.listScheduledTasks(supervisedClient, { projectId })), + Effect.provide( + service( + [ + task({ runtimeMode: "full-access" }), + task({ id: ScheduledTaskId.make("scheduled-task:supervised") }), + ], + null, + upserted, + ), + ), + ); + assert.deepEqual( + listed.tasks.map((summary) => summary.webhookUrl), + [undefined, "https://t3.example/hooks/secret"], + ); + }), + ); + + it.effect("hides a webhook URL from a thread whose turn has ended", () => + Effect.gen(function* () { + const callerId = ThreadId.make("thread:scheduled-ended"); + const shell = liveThreadShell(callerId, { activeRunId: null }); + const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((mcp) => + mcp.listScheduledTasks( + { + ...supervisedClient, + requestNamespace: "provider:scheduled-ended", + thread: { + threadId: callerId, + providerSessionId: "provider:scheduled-ended", + providerInstanceId: shell.providerInstanceId, + }, + client: undefined, + }, + { projectId }, + ), + ), + Effect.provide( + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(null), + // Its turn ended: no run is active. + getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [task({})] }), + }), + ), + ), + ), + ), + ); + assert.equal(listed.tasks[0]?.webhookUrl, undefined); + }), + ); + + it.effect("checks a bound task against its thread's current modes", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + // Scheduled while the thread was Supervised; the thread now runs in full access. + const bound = task({ threadId: boundThreadId }); + const layer = service( + [bound], + liveThreadShell(boundThreadId, { runtimeMode: "full-access" }), + upserted, + ); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide(layer), + ); + const listed = yield* mcp.listScheduledTasks(supervisedClient, { projectId }); + assert.equal(listed.tasks[0]?.webhookUrl, undefined); + const error = yield* mcp + .updateScheduledTask(supervisedClient, { + scheduledTaskId: bound.id, + prompt: "Something else", + }) + .pipe(Effect.flip); + assert.equal(error.code, "runtime_mode_escalation_denied"); + assert.equal(yield* Ref.get(upserted), 0); + }), + ); + + it.effect("reports a saved task even when its bound thread cannot be read", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + const bound = task({ threadId: boundThreadId }); + const lookups = yield* Ref.make(0); + // The edit's own check reads the thread; the read after the save fails. + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + OrchestratorMcpService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Ref.getAndUpdate(lookups, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 0 + ? Effect.succeed( + liveThreadShell(threadId, { runtimeMode: "approval-required" }), + ) + : Effect.fail(new OrchestratorProjectionError({ threadId })), + ), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({ + list: () => Effect.succeed({ tasks: [bound] }), + upsert: () => + Ref.update(upserted, (count) => count + 1).pipe(Effect.as({ task: bound })), + }), + ), + ), + ), + ), + ); + const updated = yield* mcp.updateScheduledTask(supervisedClient, { + scheduledTaskId: bound.id, + prompt: "Something else", + }); + assert.equal(yield* Ref.get(upserted), 1); + assert.equal(updated.scheduledTaskId, bound.id); + assert.equal(updated.webhookUrl, undefined); + }), + ); + }); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index fc91a19203a9..dc826af06864 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -66,6 +66,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -74,6 +75,10 @@ import { subagentResultForRun, delegatedTaskProgress, } from "../orchestration-v2/SubagentProjection.ts"; +import { + DispatchModeLimit, + type DispatchModeRefusal, +} from "../orchestration-v2/DispatchModeLimit.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; @@ -225,7 +230,12 @@ function scheduledTaskWorkspaceStrategy( : { type: "worktree", baseRef: "main", startFromOrigin: true }; } -function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask { +/** + * A scheduled task as an agent sees it. `mayRun` says whether the caller may + * run it: a webhook's URL carries the secret that starts the task's runs, so + * only such a caller sees it. + */ +function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask { return { scheduledTaskId: task.id, title: task.title, @@ -237,7 +247,7 @@ function scheduledTaskSummary(task: ScheduledTask): OrchestratorMcpScheduledTask nextRunAt: task.nextRunAt, lastRunStatus: task.lastRunStatus, // A bare path is not a URL anyone can call, so agents never get one to share. - ...(task.webhook?.url == null ? {} : { webhookUrl: task.webhook.url }), + ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }), ...(task.webhook === undefined ? {} : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }), @@ -1364,9 +1374,65 @@ const make = Effect.gen(function* () { }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)); /** - * A scheduled task the caller may change: one whose modes are no broader - * than the caller's own, so editing its prompt cannot run work above the - * caller's limits. + * The modes a task's runs execute at: its own, or for a task bound to a + * thread, also that thread's modes as they are now, since its runs are + * messages to that thread. + */ + const scheduledTaskRunModes = (task: ScheduledTask) => + Effect.gen(function* () { + const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }]; + if (task.threadId === null) return modes; + const bound = yield* threadManagement + .getThreadShell(task.threadId) + .pipe(Effect.mapError(threadManagementFailure)); + return bound === null || bound.deletedAt !== null ? modes : [...modes, bound]; + }); + + const withinLimits = ( + limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, + modes: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, + ) => + runtimeModeRank(modes.runtimeMode) <= runtimeModeRank(limits.runtimeMode) && + interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode); + + /** + * A task as the caller may see it. Its webhook URL starts runs, so only a + * caller that may start one sees it: a thread caller with a live turn, at + * modes covering every mode the task runs at. + */ + const summarizeScheduledTask = ( + scope: McpInvocationScope, + caller: { + readonly parent: Pick | undefined; + readonly limits: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }; + }, + task: ScheduledTask, + ) => + Effect.gen(function* () { + const live = + caller.parent === undefined || + Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); + // This runs after a save, so a failed lookup of the bound thread hides + // the webhook URL instead of reporting a saved task as an error. + const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); + return scheduledTaskSummary( + task, + live && + Option.isSome(modes) && + modes.value.every((mode) => withinLimits(caller.limits, mode)), + ); + }); + + /** + * A scheduled task the caller may change: one whose runs execute at modes no + * broader than the caller's own, so editing its prompt cannot run work above + * the caller's limits. */ const loadScheduledTask = ( scheduledTaskId: ScheduledTask["id"], @@ -1387,8 +1453,10 @@ const make = Effect.gen(function* () { if (task === undefined) { return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`); } - yield* resolveRuntimeMode(limits.runtimeMode, task.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, task.interactionMode); + for (const modes of yield* scheduledTaskRunModes(task)) { + yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode); + yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode); + } return task; }); @@ -1451,11 +1519,11 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not schedule task: ${error.message}`), ), ); - return scheduledTaskSummary(task); + return yield* summarizeScheduledTask(scope, { parent, limits }, task); }), listScheduledTasks: (scope, input) => Effect.gen(function* () { - const { parent } = yield* loadCaller(scope); + const { parent, limits } = yield* loadCaller(scope); const projectId = input.projectId ?? parent?.thread.projectId; const { tasks } = yield* scheduledTasks .list() @@ -1465,9 +1533,10 @@ const make = Effect.gen(function* () { ), ); return { - tasks: tasks - .filter((task) => projectId === undefined || task.projectId === projectId) - .map(scheduledTaskSummary), + tasks: yield* Effect.forEach( + tasks.filter((task) => projectId === undefined || task.projectId === projectId), + (task) => summarizeScheduledTask(scope, { parent, limits }, task), + ), }; }), updateScheduledTask: (scope, input) => @@ -1521,7 +1590,7 @@ const make = Effect.gen(function* () { failure("orchestration_error", `Could not update scheduled task: ${error.message}`), ), ); - return scheduledTaskSummary(task); + return yield* summarizeScheduledTask(scope, { parent, limits }, task); }), deleteScheduledTask: (scope, input) => Effect.gen(function* () { @@ -1851,6 +1920,32 @@ const make = Effect.gen(function* () { const current = yield* readTask(scope, input.taskId); const key = yield* requestKey(input.clientRequestId); const parentProjection = yield* loadProjection(scope.thread.threadId); + // Cancelling stops the child and every task under it, each a write to a + // thread its user may have raised above the parent's modes since it was + // delegated. All of them are checked before anything is stopped. + const assertStoppable = (threadId: ThreadId): Effect.Effect => + Effect.gen(function* () { + const shell = yield* threadManagement + .getThreadShell(threadId) + .pipe(Effect.mapError(threadManagementFailure)); + // A deleted thread takes no stop, but the tasks under it still do. + if (shell !== null && shell.deletedAt === null) { + yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode); + yield* resolveInteractionMode( + parentProjection.thread.interactionMode, + shell.interactionMode, + ); + } + const { subagents } = yield* threadManagement + .getThreadRecords(threadId, ["subagents"]) + .pipe(Effect.mapError(threadManagementFailure)); + for (const task of subagents) { + if (task.origin === "app_owned" && task.childThreadId !== null) { + yield* assertStoppable(task.childThreadId); + } + } + }); + yield* assertStoppable(current.childThreadId); const parentTask = parentProjection.subagents.find( (task) => task.id === input.taskId && task.origin === "app_owned", ); @@ -1910,8 +2005,32 @@ const make = Effect.gen(function* () { ), ); }); + // Checked above; a user raising one of these threads meanwhile is + // caught again under that thread's lock, which leaves it running. + const refused = yield* Ref.make(undefined); + const stopWithinLimit = stopChild.pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: parentProjection.thread.runtimeMode, + interactionMode: parentProjection.thread.interactionMode, + refused, + }), + Effect.catch((error) => + Effect.flatMap(Ref.get(refused), (refusal) => + Effect.fail( + refusal === undefined + ? error + : failure( + refusal.mode === "runtime" + ? "runtime_mode_escalation_denied" + : "interaction_mode_escalation_denied", + `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this thread's; its user changed it while the task was being cancelled.`, + ), + ), + ), + ), + ); if (isTerminalTaskStatus(current.status)) { - yield* stopChild; + yield* stopWithinLimit; yield* disposeCompletionDelivery; return { taskId: input.taskId, @@ -1930,7 +2049,7 @@ const make = Effect.gen(function* () { `Delegated task ${input.taskId} has no interruptible child run.`, ); } - yield* stopChild; + yield* stopWithinLimit; yield* disposeCompletionDelivery.pipe( Effect.catchCause((cause) => Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts index 5735008f6e8e..411568f75745 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts @@ -78,91 +78,3 @@ it.effect("keeps calling-thread storage failures as orchestration errors", () => expect(error.code).toBe("orchestration_error"); }), ); - -it.effect("refuses to change a thread that runs above the caller's modes", () => - Effect.gen(function* () { - const fullAccessThread = ThreadId.make("thread:metadata-full-access"); - const shells = new Map([ - [ - threadId, - { - id: threadId, - projectId: "project", - runtimeMode: "auto", - interactionMode: "default", - activeRunId: "run-live", - archivedAt: null, - providerInstanceId: "codex", - deletedAt: null, - }, - ], - [ - fullAccessThread, - { - id: fullAccessThread, - projectId: "project", - runtimeMode: "full-access", - interactionMode: "default", - deletedAt: null, - }, - ], - ]); - const error = yield* Effect.gen(function* () { - const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; - return yield* service.update(scope, { - threadId: fullAccessThread, - action: "rename", - title: "Renamed from a narrower thread", - }); - }).pipe( - Effect.provide(layerService((id) => Effect.succeed((shells.get(id) ?? null) as never))), - Effect.flip, - ); - - expect(error.code).toBe("runtime_mode_escalation_denied"); - }), -); - -it.effect("refuses another thread's metadata to a thread caller whose run has ended", () => - Effect.gen(function* () { - const otherThread = ThreadId.make("thread:metadata-other"); - const shells = new Map([ - [ - threadId, - { - id: threadId, - projectId: "project", - runtimeMode: "full-access", - interactionMode: "default", - activeRunId: null, - archivedAt: null, - providerInstanceId: "codex", - deletedAt: null, - }, - ], - [ - otherThread, - { - id: otherThread, - projectId: "project", - runtimeMode: "approval-required", - interactionMode: "default", - deletedAt: null, - }, - ], - ]); - const error = yield* Effect.gen(function* () { - const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; - return yield* service.update(scope, { - threadId: otherThread, - action: "rename", - title: "Renamed after the run ended", - }); - }).pipe( - Effect.provide(layerService((id) => Effect.succeed((shells.get(id) ?? null) as never))), - Effect.flip, - ); - - expect(error.code).toBe("parent_not_active"); - }), -); diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index 6c552511e722..22402b653946 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -16,7 +16,6 @@ import * as Layer from "effect/Layer"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; -import { assertTargetWithinLimits } from "./threadAccess.ts"; export class ThreadMetadataMcpService extends Context.Service< ThreadMetadataMcpService, @@ -167,42 +166,6 @@ const make = Effect.gen(function* () { if (shell === null || shell.deletedAt !== null) { return yield* failure("thread_not_found", `Thread ${threadId} was not found.`); } - // Another thread may only be changed if it runs within the caller's own modes. - if (threadId !== scope.thread?.threadId) { - const limits = - scope.thread === undefined - ? { - runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), - interactionMode: "default" as const, - } - : yield* threadManagement.getThreadShell(scope.thread.threadId).pipe( - Effect.mapError((error) => - failure( - "orchestration_error", - `Unable to locate calling thread: ${errorMessage(error)}`, - ), - ), - Effect.flatMap((caller) => - caller === null - ? Effect.fail(failure("thread_not_found", "The calling thread was not found.")) - : // Like every other cross-thread write, a thread caller needs its live run. - caller.archivedAt !== null || - caller.activeRunId === null || - caller.providerInstanceId !== scope.thread?.providerInstanceId - ? Effect.fail( - failure( - "parent_not_active", - "The calling provider no longer owns an active thread run.", - ), - ) - : Effect.succeed({ - runtimeMode: caller.runtimeMode, - interactionMode: caller.interactionMode, - }), - ), - ); - yield* assertTargetWithinLimits(limits, shell); - } const target = yield* threadManagement .getProjectThreadRecords({ projectId: shell.projectId, threadId }, []) .pipe(Effect.mapError(threadLookupFailure)); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index e0fb29b7e13a..115bf8b9f967 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -60,6 +60,15 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () { message: "This credential cannot control threads.", }); } + return yield* loadCaller(); +}); + +/** + * The caller and its limits, whichever tools its credential grants. Tools + * check their own capability; `McpToolAccess` uses this for every tool. + */ +export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; const threads = yield* ThreadManagement.ThreadManagementService; if (scope.thread === undefined) { return { @@ -104,7 +113,12 @@ export const assertTargetWithinLimits = ( Effect.asVoid, ); -function assertLiveCaller({ caller, scope }: Caller) { +/** + * A thread caller acts only while it owns a live run of a thread that is not + * archived, so a provider token that outlived its session cannot act. An + * OAuth client has no run; its session and ceiling are its authority. + */ +export function assertLiveCaller({ caller, scope }: Caller) { if (caller === undefined) return Effect.void; return caller.archivedAt !== null || caller.activeRunId === null || @@ -119,34 +133,14 @@ function assertLiveCaller({ caller, scope }: Caller) { } /** - * Mutations from a thread need that thread's live run, so an agent whose turn - * ended cannot keep acting. An OAuth client has no run; its session and - * ceiling are its authority. - */ -export const readMutationCaller = Effect.fn("mcp.readMutationCaller")(function* () { - const context = yield* readCaller(); - yield* assertLiveCaller(context); - return context; -}); - -/** - * Actions that change the environment itself (projects, preferences, launching - * outside a project) need full access: a thread caller in full-access/default - * mode, or a client approved with a full-access ceiling. + * Actions that change the environment itself (projects, preferences) need full + * access: a thread caller in full-access/default mode, or a client approved + * with a full-access ceiling. */ -export const readFullAccessCaller = Effect.fn("mcp.readFullAccessCaller")(function* ( - message: string, -) { - const context = yield* readMutationCaller(); - if ( - (context.caller !== undefined && context.caller.archivedAt !== null) || - context.limits.runtimeMode !== "full-access" || - context.limits.interactionMode !== "default" - ) { - return yield* new OrchestratorMcpFailure({ code: "capability_denied", message }); - } - return context; -}); +export const assertFullAccess = (context: Caller, message: string) => + context.limits.runtimeMode === "full-access" && context.limits.interactionMode === "default" + ? Effect.void + : Effect.fail(new OrchestratorMcpFailure({ code: "capability_denied", message })); /** A target project: the one passed, else the calling thread's. */ export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) => @@ -204,15 +198,6 @@ export const readThread = Effect.fn("mcp.readThread")(function* < return { ...context, projection }; }); -export const readWritableThread = Effect.fn("mcp.readWritableThread")(function* < - K extends ProjectionRecordField = never, ->(threadId?: ThreadId, fields: ReadonlyArray = []) { - const context = yield* readThread(threadId, fields); - yield* assertLiveCaller(context); - yield* assertTargetWithinLimits(context.limits, context.projection.thread); - return context; -}); - export const newCommandId = Effect.fn("mcp.newCommandId")(function* () { const crypto = yield* Crypto.Crypto; return CommandId.make(`mcp:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`); diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 412cdc5a60ce..9b9a6015d232 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -3,12 +3,8 @@ import * as Effect from "effect/Effect"; import * as Upload from "../../../assets/AttachmentUpload.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; -import { - newCommandId, - readMutationCaller, - readWritableThread, - unavailable, -} from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { newCommandId, readThread, unavailable } from "../../threadAccess.ts"; import { AttachmentToolkit } from "./tools.ts"; export function resolveAttachmentReferences( @@ -31,57 +27,53 @@ export function resolveAttachmentReferences( }); } -export const layer = AttachmentToolkit.toLayer({ - t3_attachment_prepare_upload: (input) => - Effect.gen(function* () { - yield* readMutationCaller(); - return yield* Upload.issueAttachmentUploadUrl(input.upload).pipe( - Effect.mapError(unavailable), - ); - }), - t3_attachment_discard: (input) => - Effect.gen(function* () { - yield* readMutationCaller(); - yield* Upload.deletePendingAttachment(input.attachmentId); - return {}; - }), - t3_thread_send_attachments: (input) => - Effect.gen(function* () { - const { caller, projection } = yield* readWritableThread(input.threadId, ["messages"]); - if (projection.thread.archivedAt !== null) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "Unarchive the target thread before sending attachments.", - }); - const attachments = yield* resolveAttachmentReferences( - input.attachments, - projection.messages.flatMap((message) => message.attachments), - ); - const commandId = yield* newCommandId(); - const messageId = MessageId.make(commandId); - const result = yield* ThreadMessageIntake.sendToThread({ - projectId: projection.thread.projectId, - threadId: projection.thread.id, - commandId, - messageId, - ...(caller === undefined ? {} : { senderThreadId: caller.id }), - text: input.message ?? "", - attachments, - mode: "auto", - createdBy: "agent", - creationSource: "mcp", - }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), - ); - return { - threadId: projection.thread.id, - messageId, - runId: result.run.id, - status: result.run.status, - }; - }), +export const layer = McpToolAccess.toLayer(AttachmentToolkit, { + t3_attachment_prepare_upload: McpToolAccess.writes((input) => + Upload.issueAttachmentUploadUrl(input.upload).pipe(Effect.mapError(unavailable)), + ), + t3_attachment_discard: McpToolAccess.writes((input) => + Upload.deletePendingAttachment(input.attachmentId).pipe(Effect.as({})), + ), + t3_thread_send_attachments: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const { caller, projection } = yield* readThread(input.threadId, ["messages"]); + if (projection.thread.archivedAt !== null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "Unarchive the target thread before sending attachments.", + }); + const attachments = yield* resolveAttachmentReferences( + input.attachments, + projection.messages.flatMap((message) => message.attachments), + ); + const commandId = yield* newCommandId(); + const messageId = MessageId.make(commandId); + const result = yield* ThreadMessageIntake.sendToThread({ + projectId: projection.thread.projectId, + threadId: projection.thread.id, + commandId, + messageId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), + text: input.message ?? "", + attachments, + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }).pipe( + Effect.mapError((error) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) + : unavailable(), + ), + ); + return { + threadId: projection.thread.id, + messageId, + runId: result.run.id, + status: result.run.status, + }; + }), + ), }); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 15b5ee3b80c6..f5674a06c374 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -36,6 +36,7 @@ import * as SecretRequests from "../../secrets/SecretRequests.ts"; import * as ScheduledTaskService from "../../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; +import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; import { dispatchFailure } from "../threadAccess.ts"; import { OrchestratorToolkit } from "./orchestrator/tools.ts"; import { PreviewToolkit } from "./preview/tools.ts"; @@ -131,7 +132,7 @@ const client = McpSchema.McpServerClient.of({ getClient: Effect.die("unused"), }); -it.effect("checks capability before accessing services through the production registration", () => +it.effect("checks capability through the production registration", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; expect(server.tools.some(({ tool }) => tool.name === "t3_thread_organize")).toBe(true); @@ -152,7 +153,7 @@ it.effect("checks capability before accessing services through the production re McpHttpServer.layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), - Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), ), ), ), @@ -453,12 +454,8 @@ it.effect("a client caller targets any thread within its ceiling and cannot act Layer.provide(NodeCrypto.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.succeed({ - id: ThreadId.make("other-project-thread"), - projectId: "other-project", - deletedAt: null, - } as never), + getThreadShell: (id) => + Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })), getProjectThreadRecords: () => Effect.succeed({ thread: { @@ -469,7 +466,7 @@ it.effect("a client caller targets any thread within its ceiling and cannot act deletedAt: null, }, } as never), - dispatch: () => Effect.succeed({ sequence: 7 } as never), + dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), }), ), ), diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index abc06354f19f..bf697229718c 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -16,6 +16,7 @@ import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "../../../device/DeviceService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts"; /** The flags that pin every agent-device command to one device. */ @@ -119,7 +120,7 @@ const pickDevice = ( const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; const handlers = { - device_list: (input) => + device_list: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -145,7 +146,8 @@ const handlers = { open, }; }).pipe(Effect.mapError(toolError)), - device_open: (input) => + ), + device_open: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -203,7 +205,8 @@ const handlers = { quickStart: agentDeviceQuickStart(device, targetArgs, command), }; }).pipe(Effect.mapError(toolError)), - device_screenshot: (input) => + ), + device_screenshot: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -229,7 +232,8 @@ const handlers = { }, }; }).pipe(Effect.mapError(toolError)), - device_close: (input) => + ), + device_close: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; @@ -241,7 +245,8 @@ const handlers = { }); return {}; }).pipe(Effect.mapError(toolError)), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; /** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */ export function pngDimensions(png: Uint8Array): { width: number; height: number } { @@ -258,8 +263,8 @@ export function pngDimensions(png: Uint8Array): { width: number; height: number const { device_screenshot, ...standardHandlers } = handlers; -export const layerStandard = DeviceStandardToolkit.toLayer(standardHandlers); +export const layerStandard = McpToolAccess.toLayer(DeviceStandardToolkit, standardHandlers); -export const layerScreenshot = DeviceScreenshotToolkit.toLayer({ +export const layerScreenshot = McpToolAccess.toLayer(DeviceScreenshotToolkit, { device_screenshot, }); diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 90e75ceb25c7..da6828e5c712 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpFailure, DeviceToolCloseInput, DeviceToolError, DeviceToolListResult, @@ -15,8 +16,16 @@ import { Tool, Toolkit } from "effect/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as DeviceService from "../../../device/DeviceService.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; -const dependencies = [McpInvocationContext.McpInvocationContext, DeviceService.DeviceService]; +const dependencies = [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + DeviceService.DeviceService, +]; + +/** What a device tool fails with, including the access gate's refusal. */ +const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure]); /** * Deliberately a small surface: lifecycle, visibility for the user, and one @@ -36,7 +45,7 @@ const DeviceListTool = Tool.make("device_list", { ), }), success: DeviceToolListResult, - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies, }) .annotate(Tool.Title, "List devices") @@ -50,7 +59,7 @@ const DeviceOpenTool = Tool.make("device_open", { "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig.ServerConfig], }) .annotate(Tool.Title, "Open device") @@ -64,7 +73,7 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", { "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", parameters: DeviceToolTargetInput, success: DeviceToolScreenshotResult, - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies, }) .annotate(Tool.Title, "Screenshot device") @@ -80,7 +89,7 @@ const DeviceCloseTool = Tool.make("device_close", { success: Schema.Record(Schema.String, Schema.Never).annotate({ description: "The device was closed.", }), - failure: DeviceToolError, + failure: DeviceToolFailure, dependencies, }) .annotate(Tool.Title, "Close device") diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts new file mode 100644 index 000000000000..e4ae660cc004 --- /dev/null +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as Environment from "../../../environment/ServerEnvironment.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as Settings from "../../../serverSettings.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as EnvironmentHandlers from "./handlers.ts"; +import { EnvironmentToolkit } from "./tools.ts"; + +const environmentId = EnvironmentId.make("environment:preferences"); +const threadId = ThreadId.make("thread:preferences"); + +it.effect("refuses a preferences update when the caller's turn ends while it waits", () => + Effect.gen(function* () { + const caller = yield* Ref.make(liveThreadShell(threadId)); + const updates = yield* Ref.make(0); + // Completes once the declaration's own check has read the caller. + const checked = yield* Deferred.make(); + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:preferences", + thread: { + threadId, + providerSessionId: "provider:preferences", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Ref.get(caller).pipe(Effect.tap(() => Deferred.succeed(checked, undefined))), + }), + Layer.mock(Environment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId, + label: "Test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(Settings.ServerSettingsService)({ + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettings: () => + Ref.update(updates, (count) => count + 1).pipe(Effect.as(DEFAULT_SERVER_SETTINGS)), + }), + ); + yield* Effect.gen(function* () { + const toolkit = yield* EnvironmentToolkit; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + // A turn-completion command holds the thread's lock. + const holder = yield* executor + .withLock( + threadId, + Deferred.succeed(held, undefined).pipe(Effect.andThen(Deferred.await(release))), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(held); + const update = yield* toolkit + .handle("t3_environment_preferences_update", { newWorktreesStartFromOrigin: true }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.forkChild); + // The update passed its first check and waits for the lock; the turn then ends. + yield* Deferred.await(checked); + yield* Ref.update(caller, (shell) => ({ ...shell, activeRunId: null })); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + const result = yield* Fiber.join(update); + expect(result.at(-1)?.result).toMatchObject({ code: "parent_not_active" }); + expect(yield* Ref.get(updates)).toBe(0); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(EnvironmentHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index 1aa3851111f9..d7b84ca0599c 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -4,7 +4,8 @@ import * as Environment from "../../../environment/ServerEnvironment.ts"; import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; import * as Settings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { readCaller, readFullAccessCaller, unavailable } from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { readCaller, unavailable } from "../../threadAccess.ts"; import { EnvironmentToolkit } from "./tools.ts"; export function preferences(settings: ServerSettings) { @@ -28,26 +29,21 @@ export function preferences(settings: ServerSettings) { }, }; } -const access = (writable = false) => - Effect.gen(function* () { - const context = yield* writable - ? readFullAccessCaller( - "Preference updates require a live full-access/default thread or a full-access client.", - ) - : readCaller(); - const environment = yield* Environment.ServerEnvironment; - const descriptor = yield* environment.getDescriptor; - if (descriptor.environmentId !== context.scope.environmentId) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "This credential belongs to another environment.", - }); - return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; - }); -export const layer = EnvironmentToolkit.toLayer({ - t3_environment_read: () => +const access = Effect.gen(function* () { + const context = yield* readCaller(); + const environment = yield* Environment.ServerEnvironment; + const descriptor = yield* environment.getDescriptor; + if (descriptor.environmentId !== context.scope.environmentId) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "This credential belongs to another environment.", + }); + return { ...context, descriptor, settings: yield* Settings.ServerSettingsService }; +}); +export const layer = McpToolAccess.toLayer(EnvironmentToolkit, { + t3_environment_read: McpToolAccess.reads(() => Effect.gen(function* () { - const { descriptor, settings } = yield* access(); + const { descriptor, settings } = yield* access; const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); return { environmentId: descriptor.environmentId, @@ -57,12 +53,15 @@ export const layer = EnvironmentToolkit.toLayer({ preferences: preferences(current), }; }), - t3_environment_preferences_update: (patch) => + ), + t3_environment_preferences_update: McpToolAccess.writesEnvironment((patch, check) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; const update = Effect.gen(function* () { - const { settings } = yield* access(true); + // The turn may have ended, or the thread's modes changed, while this waited for the lock. + yield* check; + const { settings } = yield* access; return preferences( yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), ); @@ -72,4 +71,5 @@ export const layer = EnvironmentToolkit.toLayer({ ? update : executor.withLock(scope.thread.threadId, update); }), + ), }); diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index 3e3d0226ed9c..8daa9bd8d229 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as HtmlRender from "../../../htmlRender/HtmlRender.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { readMutationCaller } from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { HtmlPreviewToolkit, HtmlRenderToolkit, type HtmlToolkit } from "./tools.ts"; const INVALID_PAGE_ERRORS = new Set([ @@ -20,14 +20,10 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = }); const handlers = { - html_preview: (input) => + // The headless browser runs on the host and can open local files, so only + // agents T3 launched, which already work on this machine, get it. + html_preview: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { - // The headless browser runs on the host and can open local files, so - // only agents T3 launched, which already work on this machine, get it. - yield* McpInvocationContext.requireThreadScope( - yield* McpInvocationContext.McpInvocationContext, - "html_preview", - ); const htmlRender = yield* HtmlRender.HtmlRender; const { png, ...preview } = yield* htmlRender.preview(input).pipe(Effect.mapError(toFailure)); return { @@ -40,11 +36,11 @@ const handlers = { }, }; }), - html_render: (input) => + ), + // The page is stored in the calling thread, so it needs that thread's live run. + html_render: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { - // The page is stored in the calling thread, so it needs that thread's - // live run, like any other write. - const { scope } = yield* readMutationCaller(); + const scope = yield* McpInvocationContext.McpInvocationContext; const { thread } = yield* McpInvocationContext.requireThreadScope(scope, "html_render"); const htmlRender = yield* HtmlRender.HtmlRender; const reference = yield* htmlRender @@ -56,12 +52,13 @@ const handlers = { "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say.", }; }), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; -export const layerPreview = HtmlPreviewToolkit.toLayer({ +export const layerPreview = McpToolAccess.toLayer(HtmlPreviewToolkit, { html_preview: handlers.html_preview, }); -export const layerRender = HtmlRenderToolkit.toLayer({ +export const layerRender = McpToolAccess.toLayer(HtmlRenderToolkit, { html_render: handlers.html_render, }); diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index 6160b9b1a3ab..c92e2e466f02 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -56,7 +56,11 @@ export const HtmlPreviewTool = Tool.make("html_preview", { }), }), failure: OrchestratorMcpFailure, - dependencies: [McpInvocationContext.McpInvocationContext, HtmlRender.HtmlRender], + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + HtmlRender.HtmlRender, + ], }) .annotate(Tool.Title, "Preview HTML") .annotate(Tool.Readonly, true) diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 197b06097def..e71b3d13888a 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -2,106 +2,135 @@ import { OrchestratorToolkit } from "./tools.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const handlers = { - orchestrator_capabilities: () => + orchestrator_capabilities: McpToolAccess.reads(() => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.capabilities(scope); }), - delegate_task: (input) => + ), + delegate_task: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.delegateTask(scope, input); }), - task_status: ({ taskId }) => + ), + task_status: McpToolAccess.actsAsCaller(({ taskId }) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.taskStatus(scope, taskId); }), - task_cancel: (input) => + ), + task_cancel: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.cancelTask(scope, input); }), - schedule_task: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.scheduleTask(scope, input); - }), - list_scheduled_tasks: (input) => + ), + schedule_task: McpToolAccess.startsThreads( + // A scheduled task runs with the caller's own modes. + () => ({}), + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.scheduleTask(scope, input); + }), + ), + list_scheduled_tasks: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.listScheduledTasks(scope, input); }), - update_scheduled_task: (input) => + ), + update_scheduled_task: McpToolAccess.writes((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.updateScheduledTask(scope, input); }), - delete_scheduled_task: (input) => + ), + delete_scheduled_task: McpToolAccess.writes((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.deleteScheduledTask(scope, input); }), - request_secret: (input) => + ), + request_secret: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.requestSecret(scope, input); }), - create_threads: (input) => + ), + create_threads: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.createThreads(scope, input); }), - t3_thread_list: (input) => + ), + t3_thread_list: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.listThreads(scope, input); }), - t3_thread_read: (input) => + ), + // Reading a child's finished result also acknowledges its delivery to the + // reader's own thread. That is bookkeeping on the caller's own subagent, not + // a change to anything it reads, so this stays a read. + t3_thread_read: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.readThread(scope, input); }), - t3_thread_update: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService; - return yield* service.update(scope, input); - }), - t3_thread_send: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.sendToThread(scope, input); - }), - t3_thread_wait: (input) => + ), + t3_thread_update: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* ThreadMetadataMcpService.ThreadMetadataMcpService; + return yield* service.update(scope, input); + }), + ), + t3_thread_send: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.sendToThread(scope, input); + }), + ), + t3_thread_wait: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* OrchestratorMcpService.OrchestratorMcpService; return yield* service.waitForThread(scope, input); }), - t3_thread_interrupt: (input) => - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - return yield* service.interruptThread(scope, input); - }), -} satisfies Parameters[0]; + ), + t3_thread_interrupt: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service.interruptThread(scope, input); + }), + ), +} satisfies McpToolAccess.Handlers; -export const layer = OrchestratorToolkit.toLayer(handlers); +export const layer = McpToolAccess.toLayer(OrchestratorToolkit, handlers); diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 6d4f0366d816..2779281fe583 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -32,16 +32,19 @@ import { } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, OrchestratorMcpService.OrchestratorMcpService, ]; const threadMetadataDependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, ThreadMetadataMcpService.ThreadMetadataMcpService, ]; diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index e56e22668dc5..70b20677a502 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -29,6 +29,7 @@ import { import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts"; import * as ServerConfig from "../../../config.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -189,41 +190,64 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( }); const handlers = { - preview_dialog: (input) => invokeTargeted("dialog", input), - preview_status: (input) => invokeTargeted("status", input ?? {}), - preview_open: (input) => + preview_dialog: McpToolAccess.actsAsCaller((input) => + invokeTargeted("dialog", input), + ), + preview_status: McpToolAccess.readsAsCaller((input) => + invokeTargeted("status", input ?? {}), + ), + preview_open: McpToolAccess.actsAsCaller((input) => invokeTargeted("open", normalizePreviewOpenInput(input)), - preview_navigate: (input) => + ), + preview_navigate: McpToolAccess.actsAsCaller((input) => invokeTargeted("navigate", input, input.timeoutMs), - preview_resize: (input) => + ), + preview_resize: McpToolAccess.actsAsCaller((input) => invokeTargeted("resize", input, input.timeoutMs), - preview_set_appearance: (input) => + ), + preview_set_appearance: McpToolAccess.actsAsCaller((input) => invokeTargeted("setColorScheme", input), - preview_snapshot: (input) => { + ), + preview_snapshot: McpToolAccess.readsAsCaller((input) => { // Output selection and saving are MCP-only; the browser still produces a complete snapshot. const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); - }, - preview_click: (input) => invokeTargeted("click", input, input.timeoutMs), - preview_type: (input) => invokeTargeted("type", input, input.timeoutMs), - preview_hover: (input) => invokeTargeted("hover", input, input.timeoutMs), - preview_select: (input) => + }), + preview_click: McpToolAccess.actsAsCaller((input) => + invokeTargeted("click", input, input.timeoutMs), + ), + preview_type: McpToolAccess.actsAsCaller((input) => + invokeTargeted("type", input, input.timeoutMs), + ), + preview_hover: McpToolAccess.actsAsCaller((input) => + invokeTargeted("hover", input, input.timeoutMs), + ), + preview_select: McpToolAccess.actsAsCaller((input) => invokeTargeted("select", input, input.timeoutMs), - preview_drag: (input) => invokeTargeted("drag", input, input.timeoutMs), - preview_upload: (input) => invokeTargeted("upload", input, input.timeoutMs), - preview_press: (input) => invokeTargeted("press", input), - preview_scroll: (input) => invokeTargeted("scroll", input), - preview_evaluate: ({ tabId, ...input }) => + ), + preview_drag: McpToolAccess.actsAsCaller((input) => + invokeTargeted("drag", input, input.timeoutMs), + ), + preview_upload: McpToolAccess.actsAsCaller((input) => + invokeTargeted("upload", input, input.timeoutMs), + ), + preview_press: McpToolAccess.actsAsCaller((input) => invokeTargeted("press", input)), + preview_scroll: McpToolAccess.actsAsCaller((input) => invokeTargeted("scroll", input)), + preview_evaluate: McpToolAccess.actsAsCaller(({ tabId, ...input }) => invoke("evaluate", input, undefined, tabId).pipe( Effect.map(({ result, toolIcon }) => ({ value: result ?? null, ...(toolIcon ? { toolIcon } : {}), })), ), - preview_wait_for: (input) => invokeTargeted("waitFor", input, input.timeoutMs), - preview_recording_start: (input) => + ), + preview_wait_for: McpToolAccess.readsAsCaller((input) => + invokeTargeted("waitFor", input, input.timeoutMs), + ), + preview_recording_start: McpToolAccess.actsAsCaller((input) => invokeTargeted("recordingStart", input ?? {}), - preview_recording_stop: (input) => + ), + preview_recording_stop: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.requireThreadMcpCapability("preview"); const { tabId, ...operationInput } = input; @@ -236,12 +260,13 @@ const handlers = { const artifact = yield* claimPreviewRecording(scope.thread.threadId, response.result); return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; }), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; const { preview_snapshot, ...standardHandlers } = handlers; -export const layerStandard = PreviewStandardToolkit.toLayer(standardHandlers); +export const layerStandard = McpToolAccess.toLayer(PreviewStandardToolkit, standardHandlers); -export const layerSnapshot = PreviewSnapshotToolkit.toLayer({ +export const layerSnapshot = McpToolAccess.toLayer(PreviewSnapshotToolkit, { preview_snapshot, }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 83e78adec151..9c94a1d446c6 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -1,4 +1,5 @@ import { + OrchestratorMcpFailure, ToolActivityIcon, PreviewAutomationClickInput, PreviewAutomationDialogInput, @@ -32,12 +33,17 @@ import { Tool, Toolkit } from "effect/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, PreviewAutomationBroker.PreviewAutomationBroker, ]; +/** What a browser action fails with, including the access gate's refusal. */ +const PreviewToolFailure = Schema.Union([PreviewAutomationError, OrchestratorMcpFailure]); + const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; const PreviewActionResult = Schema.Struct(presentationFields).annotate({ @@ -61,7 +67,7 @@ const PreviewStatusTool = Tool.make("preview_status", { "Report whether a collaborative browser tab is automation-capable, including its control owner, pending dialog, URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Get preview status") @@ -75,7 +81,7 @@ const PreviewOpenTool = browserTool( "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab. Parallel subagents sharing a provider session must each open with reuseExistingTab=false and pass their returned tabId on every call. Server tabs use isolated storage and cannot be operated by a different agent session.", parameters: PreviewAutomationOpenInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Open browser preview") @@ -88,7 +94,7 @@ const PreviewDialogTool = browserTool( "Accept or dismiss the server browser dialog reported by preview_status. For a prompt, supply promptText when accepting. Requires this agent to own the tab. Desktop hosts may not support this operation.", parameters: PreviewAutomationDialogInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Resolve browser dialog"), ); @@ -99,7 +105,7 @@ const PreviewNavigateTool = safeBrowserTool( "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", parameters: PreviewAutomationNavigateInput, success: PreviewAutomationStatus, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Navigate browser preview"), ); @@ -110,7 +116,7 @@ const PreviewResizeTool = safeBrowserTool( "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", parameters: PreviewAutomationResizeInput, success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Resize browser viewport") @@ -126,7 +132,7 @@ const PreviewSetAppearanceTool = safeBrowserTool( ...PreviewAutomationSetColorSchemeResult.fields, ...presentationFields, }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }) .annotate(Tool.Title, "Set preview appearance") @@ -153,7 +159,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( ), }), success: PreviewAutomationSnapshot, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Inspect browser page"), ); @@ -164,7 +170,7 @@ const PreviewClickTool = browserTool( "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.", parameters: PreviewAutomationClickInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Click preview page"), ); @@ -175,7 +181,7 @@ const PreviewTypeTool = browserTool( "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Type into preview page"), ); @@ -186,7 +192,7 @@ const PreviewHoverTool = safeBrowserTool( "Move the mouse over exactly one target in the tab selected by tabId, or this agent session's current tab when omitted, to reveal hover menus and tooltips. Server browser tabs only.", parameters: PreviewAutomationHoverInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Hover preview page"), ); @@ -200,7 +206,7 @@ const PreviewSelectTool = browserTool( ...PreviewAutomationSelectResult.fields, ...presentationFields, }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Select preview option"), ); @@ -211,7 +217,7 @@ const PreviewDragTool = browserTool( "Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.", parameters: PreviewAutomationDragInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Drag in preview page"), ); @@ -222,7 +228,7 @@ const PreviewUploadTool = browserTool( "Give files on the environment to the page in the tab selected by tabId, or this agent session's current tab when omitted. After clicking an upload control, preview_status reports the open fileChooser; call this with absolute paths to answer it, or with an empty list to cancel. Pass a locator for an to set its files without a picker. Server browser tabs only.", parameters: PreviewAutomationUploadInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Upload files to preview page"), ); @@ -233,7 +239,7 @@ const PreviewPressTool = browserTool( "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", parameters: PreviewAutomationPressInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Press key in preview page"), ); @@ -244,7 +250,7 @@ const PreviewScrollTool = safeBrowserTool( "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", parameters: PreviewAutomationScrollInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Scroll preview page"), ); @@ -267,7 +273,7 @@ const PreviewEvaluateTool = browserTool( "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, success: PreviewEvaluateResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); @@ -278,7 +284,7 @@ const PreviewWaitForTool = readonlyBrowserTool( "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", parameters: PreviewAutomationWaitForInput, success: PreviewActionResult, - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Wait for preview page condition"), ); @@ -289,7 +295,7 @@ const PreviewRecordingStartTool = safeBrowserTool( "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", parameters: PreviewAutomationTabTargetInput, success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies, }).annotate(Tool.Title, "Start browser recording"), ); @@ -300,7 +306,7 @@ const PreviewRecordingStopTool = safeBrowserTool( "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), - failure: PreviewAutomationError, + failure: PreviewToolFailure, dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], }).annotate(Tool.Title, "Stop browser recording"), ); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 00cc641e5a62..4cd06bc4233b 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -17,6 +17,8 @@ import * as ServerConfig from "../../../config.ts"; import * as Preview from "../../../preview/Manager.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as PreviewControlsHandlers from "./handlers.ts"; import { PreviewControlsToolkit } from "./tools.ts"; @@ -64,12 +66,17 @@ it.effect.each([ PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer)), Layer.succeed(Preview.PreviewManager, manager), Layer.succeed(McpInvocationContext.McpInvocationContext, scope), + McpToolAccessTestkit.liveThreadsLayer, Layer.mock(ServerSettings.ServerSettingsService)({ getSettings: Effect.succeed(settings), }), ); const toolkit = yield* PreviewControlsToolkit.pipe( - Effect.provide(PreviewControlsHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const listed = yield* toolkit .handle("t3_preview_list", {}) diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index 2f77a30c2bcd..bdb2bad2ad6e 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as Preview from "../../../preview/Manager.ts"; import { requireThreadMcpCapability } from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { unavailable } from "../../threadAccess.ts"; import { PreviewControlsToolkit } from "./tools.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -10,8 +11,8 @@ const access = Effect.gen(function* () { const scope = yield* requireThreadMcpCapability("preview"); return { scope, manager: yield* Preview.PreviewManager }; }); -export const layer = PreviewControlsToolkit.toLayer({ - t3_preview_list: (input) => +export const layer = McpToolAccess.toLayer(PreviewControlsToolkit, { + t3_preview_list: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const { scope, manager } = yield* access; const result = yield* manager.list({ threadId: scope.thread.threadId }); @@ -23,7 +24,8 @@ export const layer = PreviewControlsToolkit.toLayer({ nextCursor: end < result.sessions.length ? end : null, }; }), - t3_preview_close: (input) => + ), + t3_preview_close: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const { scope, manager } = yield* access; const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); @@ -41,4 +43,5 @@ export const layer = PreviewControlsToolkit.toLayer({ .pipe(Effect.mapError(unavailable)); return {}; }), + ), }); diff --git a/apps/server/src/mcp/toolkits/previewControls/tools.ts b/apps/server/src/mcp/toolkits/previewControls/tools.ts index 33db4a522eb5..2a14ee7b5e64 100644 --- a/apps/server/src/mcp/toolkits/previewControls/tools.ts +++ b/apps/server/src/mcp/toolkits/previewControls/tools.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as PreviewManager from "../../../preview/Manager.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -16,6 +17,7 @@ const shared = { failureMode: "return" as const, dependencies: [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, PreviewManager.PreviewManager, PreviewAutomationBroker.PreviewAutomationBroker, ], diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index cf7074136edf..64e96d949b7f 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -10,6 +10,8 @@ import { import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; @@ -19,7 +21,10 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer import * as ServerConfig from "../../../config.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as ProjectHandlers from "./handlers.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -73,13 +78,18 @@ it.effect("attributes a launched thread's first message to the calling thread", }), Layer.mock(Project.ProjectService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe( Layer.provide(NodeServices.layer), ), ); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) @@ -142,13 +152,18 @@ it.effect("launches a scratch thread into the Scratch project", () => namedProjectsRoot: "/projects", ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe( Layer.provide(NodeServices.layer), ), ); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const handle = (params: Parameters>[1]) => toolkit @@ -240,13 +255,18 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () }; }), }), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), NodeServices.layer, ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe( Layer.provide(NodeServices.layer), ), ); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const handle = (params: Parameters>[1]) => toolkit @@ -280,6 +300,7 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () const clientLaunchHarness = (input: { readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; readonly launched: Array; + readonly workspaceRoot?: string; }) => { const projectId = ProjectId.make("project:client-target"); const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; @@ -319,15 +340,25 @@ const clientLaunchHarness = (input: { getById: (id) => Effect.succeed( id === projectId - ? Option.some({ id, defaultModelSelection: modelSelection } as unknown as ProjectRecord) + ? Option.some({ + id, + workspaceRoot: input.workspaceRoot ?? "/projects/client-target", + defaultModelSelection: modelSelection, + } as unknown as ProjectRecord) : Option.none(), ), }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), NodeServices.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( - Layer.provide(NodeServices.layer), + ).pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe( + Layer.provide(NodeServices.layer), + ), ), + Layer.provideMerge(NodeServices.layer), ); return { projectId, modelSelection, dependencies: layerDependencies }; }; @@ -340,7 +371,9 @@ it.effect("a client launches at its ceiling with the project's default model", ( launched, }); const toolkit = yield* ProjectToolkit.pipe( - Effect.provide(ProjectHandlers.layer.pipe(Layer.provide(dependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), ); const handle = (params: Parameters>[1]) => toolkit @@ -360,3 +393,64 @@ it.effect("a client launches at its ceiling with the project's default model", ( expect(launched).toHaveLength(1); }), ); + +it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-launch-worktree-" }); + const repo = path.join(root, "repo"); + const worktree = path.join(root, "feature"); + const outside = path.join(root, "outside"); + yield* fileSystem.makeDirectory(repo); + yield* fileSystem.makeDirectory(outside); + + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + workspaceRoot: repo, + }); + const git = yield* GitVcsDriver.GitVcsDriver.pipe(Effect.provide(dependencies)); + for (const args of [ + ["init", "-b", "main"], + ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], + ["worktree", "add", "-b", "feature", worktree], + ]) { + yield* git.execute({ operation: "test.setupRepo", cwd: repo, args }); + } + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launchInto = (worktreePath: string) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + workspaceStrategy: { type: "existing_worktree", worktreePath }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ projectId }); + expect((yield* launchInto(repo)).at(-1)?.result).toMatchObject({ projectId }); + for (const elsewhere of [outside, path.join(worktree, "..", "outside"), "/"]) { + expect((yield* launchInto(elsewhere)).at(-1)?.result).toMatchObject({ + code: "invalid_request", + }); + } + expect(launched.map((launch) => launch.workspaceStrategy)).toEqual([ + { type: "existing_worktree", worktreePath: worktree }, + { type: "existing_worktree", worktreePath: repo }, + ]); + + // A removed worktree stays listed as prunable until `git worktree prune`; + // whatever directory is later made at its path is not one of the project's. + yield* fileSystem.remove(worktree, { recursive: true }); + yield* fileSystem.makeDirectory(worktree); + expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ + code: "invalid_request", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index a854be3f362a..9a7d7024111e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,20 +1,15 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; -import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; -import { - newCommandId, - readCaller, - readFullAccessCaller, - readMutationCaller, - resolveProjectId, - unavailable, -} from "../../threadAccess.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; function projectFailure(error: Project.ProjectServiceError) { @@ -28,121 +23,137 @@ function projectFailure(error: Project.ProjectServiceError) { return new OrchestratorMcpFailure({ code: "invalid_request", message }); } +/** + * An existing checkout a launch may bind: one of the project's own git + * worktrees. Without this check a launch could point an agent at any directory + * on the machine. + */ +const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* ( + workspaceRoot: string, + worktreePath: string, +) { + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const real = (path: string) => fileSystem.realPath(path).pipe(Effect.orElseSucceed(() => path)); + const worktrees = yield* git.listWorktreePaths(workspaceRoot).pipe( + Effect.flatMap((paths) => Effect.forEach(paths, real)), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + if (!worktrees.includes(yield* real(worktreePath))) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "worktreePath must be one of the project's git worktrees. t3_worktree_list shows them.", + }); +}); + const access = Effect.gen(function* () { yield* readCaller(); return yield* Project.ProjectService; }); -const mutation = Effect.gen(function* () { - yield* readFullAccessCaller( - "Project changes require a live full-access/default calling thread or a full-access client.", - ); - return yield* Project.ProjectService; -}); -export const layer = ProjectToolkit.toLayer({ - t3_thread_launch: (input) => - Effect.gen(function* () { - const context = yield* readMutationCaller(); - const { caller, limits } = context; - // A thread caller launches only as itself (full-access/default), as before. A client - // launches anything up to its ceiling. - if ( - caller !== undefined && - (caller.runtimeMode !== "full-access" || caller.interactionMode !== "default") - ) - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: "Project launches require a full-access/default calling thread.", - }); - const runtimeMode = yield* resolveRuntimeMode( - limits.runtimeMode, - input.runtimeMode ?? caller?.runtimeMode, - ); - const commandId = yield* newCommandId(); - const threadId = ThreadId.make(commandId); - const messageId = MessageId.make(commandId); - const attachments = input.attachments ?? []; - if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment))) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: "A new thread accepts only pending attachment uploads.", - }); - if ( - input.scratch === true && - (input.projectId !== undefined || input.workspaceStrategy !== undefined) - ) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: - "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.", - }); - const projectId = - input.scratch === true - ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe( - Effect.flatMap((folders) => folders.ensureScratchProject), - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ - code: "orchestration_error", - message: error.message, - }), - ), - )).projectId - : yield* resolveProjectId(context, input.projectId); - const modelSelection = - input.modelSelection ?? - caller?.modelSelection ?? - (yield* Project.ProjectService.pipe( +export const layer = McpToolAccess.toLayer(ProjectToolkit, { + t3_thread_launch: McpToolAccess.startsThreads( + (input) => input, + (input, { runtimeMode, interactionMode }) => + Effect.gen(function* () { + const context = yield* readCaller(); + const { caller } = context; + const commandId = yield* newCommandId(); + const threadId = ThreadId.make(commandId); + const messageId = MessageId.make(commandId); + const attachments = input.attachments ?? []; + if (attachments.some((attachment) => !Claims.attachmentIsPendingUpload(attachment))) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "A new thread accepts only pending attachment uploads.", + }); + if ( + input.scratch === true && + (input.projectId !== undefined || input.workspaceStrategy !== undefined) + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "scratch:true picks its own project and folder; omit projectId and workspaceStrategy.", + }); + const projectId = + input.scratch === true + ? (yield* ManagedProjectFolders.ManagedProjectFolders.pipe( + Effect.flatMap((folders) => folders.ensureScratchProject), + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: error.message, + }), + ), + )).projectId + : yield* resolveProjectId(context, input.projectId); + const readProject = Project.ProjectService.pipe( Effect.flatMap((projects) => projects.getById(projectId)), Effect.mapError(unavailable), - Effect.map((project) => - Option.getOrUndefined(Option.flatMapNullishOr(project, (p) => p.defaultModelSelection)), + Effect.map(Option.getOrUndefined), + ); + if (input.workspaceStrategy?.type === "existing_worktree") { + const project = yield* readProject; + if (project === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The project was not found.", + }); + yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); + } + const modelSelection = + input.modelSelection ?? + caller?.modelSelection ?? + (yield* readProject)?.defaultModelSelection ?? + undefined; + if (modelSelection === undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", + }); + const result = yield* ThreadMessageIntake.launchThread({ + commandId, + threadId, + projectId, + title: input.title, + modelSelection, + runtimeMode, + interactionMode, + workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, + ...(input.message === undefined && attachments.length === 0 + ? {} + : { + initialMessage: { + messageId, + ...(caller === undefined ? {} : { senderThreadId: caller.id }), + text: input.message ?? "", + attachments, + }, + }), + createdBy: "agent", + creationSource: "mcp", + }).pipe( + Effect.mapError((error) => + error._tag === "AttachmentClaimError" + ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) + : unavailable(), ), - )); - if (modelSelection === undefined) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: - "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", - }); - const result = yield* ThreadMessageIntake.launchThread({ - commandId, - threadId, - projectId, - title: input.title, - modelSelection, - runtimeMode, - interactionMode: input.interactionMode ?? caller?.interactionMode ?? "default", - workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, - ...(input.message === undefined && attachments.length === 0 - ? {} - : { - initialMessage: { - messageId, - ...(caller === undefined ? {} : { senderThreadId: caller.id }), - text: input.message ?? "", - attachments, - }, - }), - createdBy: "agent", - creationSource: "mcp", - }).pipe( - Effect.mapError((error) => - error._tag === "AttachmentClaimError" - ? new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }) - : unavailable(), - ), - ); - const thread = result.projection.thread; - const run = result.projection.runs.find((run) => run.userMessageId === messageId); - return { - threadId: thread.id, - projectId: thread.projectId, - modelSelection: thread.modelSelection, - runId: run?.id ?? null, - status: run?.status ?? null, - }; - }), - t3_project_list: (input) => + ); + const thread = result.projection.thread; + const run = result.projection.runs.find((run) => run.userMessageId === messageId); + return { + threadId: thread.id, + projectId: thread.projectId, + modelSelection: thread.modelSelection, + runId: run?.id ?? null, + status: run?.status ?? null, + }; + }), + ), + t3_project_list: McpToolAccess.reads((input) => Effect.gen(function* () { const projects = yield* access; const snapshot = yield* projects.snapshot.pipe(Effect.mapError(unavailable)); @@ -151,7 +162,8 @@ export const layer = ProjectToolkit.toLayer({ end = start + (input.limit ?? 20); return { projects: rows.slice(start, end), nextCursor: end < rows.length ? end : null }; }), - t3_project_read: (input) => + ), + t3_project_read: McpToolAccess.reads((input) => Effect.gen(function* () { const projects = yield* access; const result = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable)); @@ -162,9 +174,10 @@ export const layer = ProjectToolkit.toLayer({ }); return result.value; }), - t3_project_create: ({ workspaceRoot, ...input }) => + ), + t3_project_create: McpToolAccess.writesEnvironment(({ workspaceRoot, ...input }) => Effect.gen(function* () { - const projects = yield* mutation; + const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { // Project creation records no model default (only an update does), so // reject what this mode would otherwise drop silently. @@ -205,23 +218,25 @@ export const layer = ProjectToolkit.toLayer({ .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) .pipe(Effect.mapError(projectFailure)); }), - t3_project_update: (input) => + ), + t3_project_update: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - const projects = yield* mutation; + const projects = yield* Project.ProjectService; return yield* projects .update({ ...input, commandId: yield* newCommandId() }) .pipe(Effect.mapError(projectFailure)); }), - t3_project_delete: (input) => + ), + t3_project_delete: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - const projects = yield* mutation; + const projects = yield* Project.ProjectService; return yield* projects .delete({ ...input, commandId: yield* newCommandId() }) .pipe(Effect.mapError(projectFailure)); }), - t3_project_clone: (input) => + ), + t3_project_clone: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - yield* mutation; const repositories = yield* Repositories.SourceControlRepositoryService; return yield* repositories.cloneRepository(input).pipe( Effect.mapError( @@ -233,4 +248,5 @@ export const layer = ProjectToolkit.toLayer({ ), ); }), + ), }); diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 43d0a0866e4e..ab04bd5a195b 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -27,6 +27,7 @@ import * as ProjectService from "../../../project/ProjectService.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; +import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; const shared = { @@ -102,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. Requires a full-access/default calling thread; a caller outside a T3 thread launches up to its approved permission mode.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -118,7 +119,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { workspaceStrategy: Schema.optional( OrchestrationV2ThreadLaunchWorkspaceStrategy.annotate({ description: - "Choose where this thread runs before starting its agent: worktree creates and binds a new checkout from baseRef; existing_worktree binds worktreePath; root uses the project checkout. Omitted means root, not the caller's worktree. For a PR stack use the parent branch as baseRef and startFromOrigin:false. Uncommitted changes are not copied.", + "Choose where this thread runs before starting its agent: worktree creates and binds a new checkout from baseRef; existing_worktree binds worktreePath, which must be one of the project's git worktrees; root uses the project checkout. Omitted means root, not the caller's worktree. For a PR stack use the parent branch as baseRef and startFromOrigin:false. Uncommitted changes are not copied.", }), ), message: Schema.optional( @@ -140,6 +141,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared.dependencies, ThreadLaunchService.ThreadLaunchService, ManagedProjectFolders.ManagedProjectFolders, + GitVcsDriver.GitVcsDriver, FileSystem.FileSystem, ServerConfig.ServerConfig, ], diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 7ffd5f6fdc09..6101727cd89c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -23,6 +23,8 @@ import { } from "../../../orchestration-v2/testkit/pullRequestFixtures.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; import { listThreadPullRequests } from "./handlers.ts"; import * as PullRequestsHandlers from "./handlers.ts"; import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; @@ -163,9 +165,14 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( dispatch, }), Layer.succeed(Crypto.Crypto, testCrypto), + McpToolAccessTestkit.liveThreadsLayer, ); const toolkit = yield* PullRequestsToolkit.pipe( - Effect.provide(PullRequestsHandlers.layer.pipe(Layer.provide(layerDependencies))), + Effect.provide( + McpToolAccess.HandlersLayer.layer(PullRequestsHandlers.layer).pipe( + Layer.provide(layerDependencies), + ), + ), ); const call = ( name: Name, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index dd44c9d34065..cced33767054 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -26,7 +26,7 @@ import * as Option from "effect/Option"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import { assertTargetWithinLimits } from "../../threadAccess.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import { type ListThreadPullRequestsResult, PullRequestLinkFailedError, @@ -40,7 +40,6 @@ import { PullRequestWatchFailedError, PullRequestWatchFromSubagentError, PullRequestThreadNotFoundError, - PullRequestThreadAboveLimitsError, PullRequestThreadRequiredError, PullRequestsToolkit, type ThreadPullRequestEntry, @@ -189,47 +188,6 @@ const make = Effect.gen(function* () { return thread.value; }); - /** - * A thread whose pull requests the caller may change: its own, or one that - * runs within the caller's modes. - */ - const requireWritableThread = Effect.fn("PullRequestsToolkit.requireWritableThread")(function* ( - Failure: - | typeof PullRequestLinkFailedError - | typeof PullRequestUnlinkFailedError - | typeof PullRequestWatchFailedError, - requested: ThreadId | undefined, - ) { - const thread = yield* requireThread(Failure, requested); - const scope = yield* McpInvocationContext.McpInvocationContext; - if (thread.id === scope.thread?.threadId) return thread; - const limits = - scope.thread === undefined - ? { - runtimeMode: scope.client?.runtimeModeCeiling ?? ("approval-required" as const), - interactionMode: "default" as const, - } - : yield* engine.getThreadShell(scope.thread.threadId).pipe( - Effect.mapError((cause) => new Failure({ cause })), - Effect.map((caller) => - // A thread caller changes other threads only while its own run is live. - caller === null || - caller.archivedAt !== null || - caller.activeRunId === null || - caller.providerInstanceId !== scope.thread?.providerInstanceId - ? undefined - : { runtimeMode: caller.runtimeMode, interactionMode: caller.interactionMode }, - ), - ); - if (limits === undefined) { - return yield* new PullRequestThreadAboveLimitsError({ threadId: thread.id }); - } - yield* assertTargetWithinLimits(limits, thread).pipe( - Effect.mapError(() => new PullRequestThreadAboveLimitsError({ threadId: thread.id })), - ); - return thread; - }); - const projectOf = ( thread: OrchestrationV2ThreadShell, Failure: @@ -267,7 +225,7 @@ const make = Effect.gen(function* () { input: PullRequestTargetInput, watching: boolean, ) { - const thread = yield* requireWritableThread(PullRequestWatchFailedError, input.threadId); + const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestWatchFailedError); const target = yield* resolveTarget(input, project); const watchedLink = (shell: OrchestrationV2ThreadShell) => @@ -306,10 +264,15 @@ const make = Effect.gen(function* () { }; }); - return PullRequestsToolkit.of({ - link_pull_request: (input) => + /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ + const writesThread =

    ( + handle: (params: P) => Effect.Effect, + ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); + + return { + link_pull_request: writesThread((input) => Effect.gen(function* () { - const thread = yield* requireWritableThread(PullRequestLinkFailedError, input.threadId); + const thread = yield* requireThread(PullRequestLinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestLinkFailedError); const target = yield* resolveTarget(input, project); const existing = threadPullRequestsOf(thread).find((link) => @@ -337,9 +300,10 @@ const make = Effect.gen(function* () { ); return { ...target, alreadyLinked }; }), - unlink_pull_request: (input) => + ), + unlink_pull_request: writesThread((input) => Effect.gen(function* () { - const thread = yield* requireWritableThread(PullRequestUnlinkFailedError, input.threadId); + const thread = yield* requireThread(PullRequestUnlinkFailedError, input.threadId); const project = yield* projectOf(thread, PullRequestUnlinkFailedError); const target = yield* resolveTarget(input, project); if (!threadPullRequestsOf(thread).some((link) => threadPullRequestKeysEqual(link, target))) @@ -370,13 +334,15 @@ const make = Effect.gen(function* () { wasLinked, }; }), - list_thread_pull_requests: (input) => + ), + list_thread_pull_requests: McpToolAccess.reads((input) => requireThread(PullRequestListFailedError, input.threadId).pipe( Effect.map(listThreadPullRequests), ), - watch_pull_request: (input) => setWatching(input, true), - unwatch_pull_request: (input) => setWatching(input, false), - }); + ), + watch_pull_request: writesThread((input) => setWatching(input, true)), + unwatch_pull_request: writesThread((input) => setWatching(input, false)), + } satisfies McpToolAccess.Handlers; }); -export const layer = PullRequestsToolkit.toLayer(make); +export const layer = McpToolAccess.toLayer(PullRequestsToolkit, make); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 2a150617a405..e5c3d53503ff 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -1,5 +1,6 @@ import { McpCapabilityUnavailableError, + OrchestratorMcpFailure, PositiveInt, PullRequestState, ThreadPullRequestLinkSource, @@ -12,10 +13,12 @@ import * as Toolkit from "effect/ai/Toolkit"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, Orchestrator.OrchestratorV2, ProjectService.ProjectService, ]; @@ -96,15 +99,6 @@ export class PullRequestThreadRequiredError extends Schema.TaggedError()( - "PullRequestThreadAboveLimitsError", - { threadId: Schema.String }, -) { - override get message(): string { - return `Thread ${this.threadId} cannot be changed from here: it runs with broader permissions than this caller, or the calling thread has no active run.`; - } -} - export class PullRequestThreadNotFoundError extends Schema.TaggedError()( "PullRequestThreadNotFoundError", { threadId: Schema.String }, @@ -169,12 +163,12 @@ export class PullRequestListFailedError extends Schema.TaggedError OrchestrationV2Command, ) { - const { threads, projection } = yield* readWritableThread(threadId); + const { threads, projection } = yield* readThread(threadId); const result = yield* threads .dispatch(command({ commandId: yield* newCommandId(), threadId: projection.thread.id })) .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }); -const readQuestion = Effect.fn("mcp.readQuestion")(function* ( - input: { - threadId?: ThreadId | undefined; - requestId: RuntimeRequestId; - }, - writable = false, -) { - const context = yield* writable - ? readWritableThread(input.threadId, ["runtimeRequests", "turnItems"]) - : readThread(input.threadId, ["runtimeRequests", "turnItems"]); +const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { + threadId?: ThreadId | undefined; + requestId: RuntimeRequestId; +}) { + const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]); const request = context.projection.runtimeRequests.find( (request) => request.id === input.requestId && @@ -76,12 +70,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( }); return { ...context, request, item }; }); -export const layer = ThreadToolkit.toLayer({ - run_scheduled_task_now: (input) => +/** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ +const writesThread =

    ( + handle: (params: P) => Effect.Effect, +) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); + +export const layer = McpToolAccess.toLayer(ThreadToolkit, { + run_scheduled_task_now: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { - yield* readFullAccessCaller( - "Running a scheduled task requires a live full-access/default thread or a full-access client.", - ); const scheduler = yield* ScheduledTasks.ScheduledTaskService; const { tasks } = yield* scheduler.list().pipe(Effect.mapError(unavailable)); if (!tasks.some((task) => task.id === input.taskId)) @@ -100,7 +96,8 @@ export const layer = ThreadToolkit.toLayer({ nextRunAt: task.nextRunAt, }; }), - t3_thread_search: (input) => + ), + t3_thread_search: McpToolAccess.reads((input) => Effect.gen(function* () { const { caller } = yield* readCaller(); const { projectId: requested, ...query } = input; @@ -116,9 +113,10 @@ export const layer = ThreadToolkit.toLayer({ : result.matches.filter((match) => match.projectId === projectId), }; }), - t3_thread_fork: (input) => + ), + t3_thread_fork: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readWritableThread(input.threadId); + const { threads, projection } = yield* readThread(input.threadId); const commandId = yield* newCommandId(); const targetThreadId = ThreadId.make(`${commandId}:fork`); const result = yield* threads @@ -135,24 +133,28 @@ export const layer = ThreadToolkit.toLayer({ .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence, targetThreadId }; }), - t3_thread_merge_back: (input) => - Effect.gen(function* () { - const context = yield* readWritableThread(input.targetThreadId); - const source = yield* readWritableThread(input.sourceThreadId); - const result = yield* context.threads - .dispatch({ - type: "thread.merge_back", - commandId: yield* newCommandId(), - sourceThreadId: source.projection.thread.id, - targetThreadId: input.targetThreadId, - sourcePoint: input.sourcePoint, - createdBy: "agent", - creationSource: "mcp", - }) - .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence, targetThreadId: input.targetThreadId }; - }), - t3_thread_transfers: (input) => + ), + t3_thread_merge_back: McpToolAccess.writesThreads( + (input) => [input.targetThreadId, input.sourceThreadId], + (input) => + Effect.gen(function* () { + const context = yield* readThread(input.targetThreadId); + const source = yield* readThread(input.sourceThreadId); + const result = yield* context.threads + .dispatch({ + type: "thread.merge_back", + commandId: yield* newCommandId(), + sourceThreadId: source.projection.thread.id, + targetThreadId: input.targetThreadId, + sourcePoint: input.sourcePoint, + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence, targetThreadId: input.targetThreadId }; + }), + ), + t3_thread_transfers: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["contextTransfers"]); return { @@ -166,7 +168,8 @@ export const layer = ThreadToolkit.toLayer({ ), }; }), - t3_thread_configuration: (input) => + ), + t3_thread_configuration: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection: { thread }, @@ -178,12 +181,13 @@ export const layer = ThreadToolkit.toLayer({ interactionMode: thread.interactionMode, }; }), - t3_thread_configure: (input) => + ), + t3_thread_configure: writesThread((input) => Effect.gen(function* () { const { threads, projection: { thread }, - } = yield* readWritableThread(input.threadId); + } = yield* readThread(input.threadId); const type = modelSelectionCommandType(thread.providerInstanceId, input.modelSelection); const result = yield* threads .dispatch({ @@ -195,7 +199,8 @@ export const layer = ThreadToolkit.toLayer({ .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), - t3_pending_request_list: (input) => + ), + t3_pending_request_list: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runtimeRequests"]); return { @@ -204,14 +209,16 @@ export const layer = ThreadToolkit.toLayer({ .map((request) => request.id), }; }), - t3_pending_request_read: (input) => + ), + t3_pending_request_read: McpToolAccess.reads((input) => Effect.gen(function* () { const { item } = yield* readQuestion(input); return { requestId: input.requestId, questions: item.questions }; }), - t3_pending_request_respond: (input) => + ), + t3_pending_request_respond: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readQuestion(input, true); + const { threads, projection } = yield* readQuestion(input); const result = yield* threads .dispatch({ type: "runtime-request.respond", @@ -223,7 +230,8 @@ export const layer = ThreadToolkit.toLayer({ .pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), - t3_queue_list: (input) => + ), + t3_queue_list: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); const runs = queuedRunsInDeliveryOrder(projection); @@ -237,7 +245,8 @@ export const layer = ThreadToolkit.toLayer({ nextCursor: end < runs.length ? end : null, }; }), - t3_queue_read: (input) => + ), + t3_queue_read: McpToolAccess.reads((input) => Effect.gen(function* () { const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); const entry = queueEntry(projection, input.queuedRunId, 16000); @@ -249,36 +258,41 @@ export const layer = ThreadToolkit.toLayer({ })) ); }), - t3_queue_edit: (input) => + ), + t3_queue_edit: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-run.edit", runId: input.queuedRunId, text: input.text, })), - t3_queue_cancel: (input) => + ), + t3_queue_cancel: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-run.cancel", runId: input.queuedRunId, })), - t3_queue_reorder: (input) => + ), + t3_queue_reorder: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-run.reorder", runId: input.queuedRunId, beforeRunId: input.beforeRunId, })), - t3_queue_promote_to_steer: (input) => + ), + t3_queue_promote_to_steer: writesThread((input) => dispatch(input.threadId, (common) => ({ ...common, type: "queued-message.promote-to-steer", queuedRunId: input.queuedRunId, targetRunId: input.targetRunId, })), - t3_thread_organize: (input) => + ), + t3_thread_organize: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readWritableThread(input.threadId); + const { threads, projection } = yield* readThread(input.threadId); const common = { commandId: yield* newCommandId(), threadId: projection.thread.id }; let command: OrchestrationV2Command; switch (input.action) { @@ -304,4 +318,5 @@ export const layer = ThreadToolkit.toLayer({ const result = yield* threads.dispatch(command).pipe(Effect.mapError(dispatchFailure)); return { sequence: result.sequence }; }), + ), }); diff --git a/apps/server/src/mcp/toolkits/worktree/handlers.ts b/apps/server/src/mcp/toolkits/worktree/handlers.ts index b9539a9f3307..c6fabe18c6fb 100644 --- a/apps/server/src/mcp/toolkits/worktree/handlers.ts +++ b/apps/server/src/mcp/toolkits/worktree/handlers.ts @@ -6,11 +6,12 @@ import { readThread, unavailable } from "../../threadAccess.ts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; import { WorktreeToolkit } from "./tools.ts"; const handlers = { - t3_worktree_list: (input) => + t3_worktree_list: McpToolAccess.reads((input) => Effect.gen(function* () { const context = yield* McpInvocationContext.McpInvocationContext; if (!context.capabilities.has("worktree")) @@ -34,18 +35,21 @@ const handlers = { .listRefs({ ...refs, cwd: thread.worktreePath ?? project.value.workspaceRoot }) .pipe(Effect.mapError(unavailable)); }), - t3_worktree_handoff: (input) => + ), + t3_worktree_handoff: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* WorktreeMcpService.WorktreeMcpService; return yield* service.handoff(scope, input); }), - t3_worktree_status: () => + ), + t3_worktree_status: McpToolAccess.readsAsCaller(() => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const service = yield* WorktreeMcpService.WorktreeMcpService; return yield* service.status(scope); }), -} satisfies Parameters[0]; + ), +} satisfies McpToolAccess.Handlers; -export const layer = WorktreeToolkit.toLayer(handlers); +export const layer = McpToolAccess.toLayer(WorktreeToolkit, handlers); diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index 56e227aa14b6..fda1fc9fa3c8 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -19,15 +19,19 @@ import * as WorktreeMcpService from "../../WorktreeMcpService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, WorktreeMcpService.WorktreeMcpService, ]; +/** What handoff and status fail with, including the access gate's refusal. */ +const WorktreeToolFailure = Schema.Union([WorktreeMcpFailure, OrchestratorMcpFailure]); + const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { description: "Needs an agent running inside a T3 thread. Move this agent thread into a new git worktree. To launch a separate agent already bound to a new or existing worktree, use t3_thread_launch with workspaceStrategy instead. Creates the worktree branch (optionally from origin), re-points the thread at the worktree, and by default runs the project's setup script there. Changing the workspace detaches the live provider session, so the current turn ends shortly after the handoff is recorded; call this as the last action of the turn. To keep working after the handoff, pass continuationPrompt with the remaining work: it is queued as the thread's next message and starts a new turn inside the worktree with the conversation preserved. Without it the thread stays idle until the next message. The worktree is not removed automatically when the thread is deleted. Fails if the thread is already attached to a worktree.", parameters: WorktreeMcpHandoffInput, success: WorktreeMcpHandoffResult, - failure: WorktreeMcpFailure, + failure: WorktreeToolFailure, failureMode: "return", dependencies, }) @@ -45,7 +49,7 @@ const WorktreeStatusTool = Tool.make("t3_worktree_status", { // Schema.Struct({}) serializes to `anyOf: [object, array]`, which is not a // valid MCP tool input schema and makes clients reject the whole server. success: WorktreeMcpStatusResult, - failure: WorktreeMcpFailure, + failure: WorktreeToolFailure, failureMode: "return", dependencies, }) diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts new file mode 100644 index 000000000000..dca74400e821 --- /dev/null +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.test.ts @@ -0,0 +1,167 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { DispatchModeLimit } from "./DispatchModeLimit.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for metadata commands"), +} as ProviderAdapterV2Shape; +const layerDatabase = SqlitePersistence.layerMemory; +const layerTest = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "dispatch-mode-limit" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); + +const supervised = { runtimeMode: "approval-required", interactionMode: "default" } as const; + +const createThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create:${threadId}`), + threadId, + projectId: ProjectId.make("project:dispatch-mode-limit"), + title: "Before", + modelSelection: { instanceId, model: "gpt-5" }, + ...supervised, + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +it.layer(layerTest)("DispatchModeLimit", (it) => { + it.effect("refuses a limited command on a thread its user raised, and records nothing", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:limit-raised"); + yield* createThread(threadId); + // The user raises the thread; the user's own commands have no limit. + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("raise"), + threadId, + runtimeMode: "full-access", + }); + const rename = { + type: "thread.metadata.update", + commandId: CommandId.make("rename"), + threadId, + title: "After", + } as const; + const refused = yield* orchestrator + .dispatch(rename) + .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); + assert.equal(refused._tag, "OrchestratorThreadAboveModeLimitError"); + assert.equal((yield* projections.getThreadShell(threadId))?.title, "Before"); + + // Refused before planning, so no receipt holds the command id: once the + // user lowers the thread again, the same command goes through. + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("lower"), + threadId, + runtimeMode: "approval-required", + }); + yield* orchestrator + .dispatch(rename) + .pipe(Effect.provideService(DispatchModeLimit, supervised)); + assert.equal((yield* projections.getThreadShell(threadId))?.title, "After"); + }), + ); + + it.effect("names the interaction mode when that is what was raised", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("thread:limit-plan"); + yield* createThread(threadId); + const refused = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("rename-plan"), + threadId, + title: "After", + }) + .pipe( + Effect.provideService(DispatchModeLimit, { + runtimeMode: "full-access", + interactionMode: "plan", + }), + Effect.flip, + ); + assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); + assert.equal(refused.mode, "interaction"); + }), + ); + + it.effect("refuses to fork a source thread its user raised", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sourceThreadId = ThreadId.make("thread:limit-fork-source"); + const targetThreadId = ThreadId.make("thread:limit-fork-target"); + yield* createThread(sourceThreadId); + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("raise-source"), + threadId: sourceThreadId, + runtimeMode: "full-access", + }); + const fork = { + type: "thread.fork", + commandId: CommandId.make("fork"), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + createdBy: "agent", + creationSource: "mcp", + } as const; + const refused = yield* orchestrator + .dispatch(fork) + .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); + assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError"); + assert.equal(refused.threadId, sourceThreadId); + assert.isNull(yield* projections.getThreadShell(targetThreadId)); + + // The refusal records no receipt: once the source is lowered, the same + // command is planned again (and fails only for want of a finished run). + yield* orchestrator.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("lower-source"), + threadId: sourceThreadId, + runtimeMode: "approval-required", + }); + const retried = yield* orchestrator + .dispatch(fork) + .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip); + assert.equal(retried._tag, "OrchestratorDispatchError"); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts new file mode 100644 index 000000000000..a0a6fda2ee91 --- /dev/null +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -0,0 +1,52 @@ +import type { ProviderInteractionMode, RuntimeMode, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Ref from "effect/Ref"; + +export interface DispatchModes { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +/** A thread the orchestrator refused to touch because it ran above the limit. */ +export interface DispatchModeRefusal extends DispatchModes { + readonly threadId: ThreadId; + readonly mode: "runtime" | "interaction"; +} + +/** + * The broadest modes a command may touch, for commands an MCP caller sends. + * A caller checks its target's modes before dispatching, but the target's + * user can raise them meanwhile; the orchestrator re-checks this limit inside + * the thread's command lock, where nothing else can change them. Unset for + * the user's own commands, which have no limit. + * + * The orchestrator also records its refusal in `refused`, so the sender can + * report it however the code in between wrapped the dispatch error. + */ +export interface DispatchModeLimitValue extends DispatchModes { + readonly refused?: Ref.Ref; +} + +export const DispatchModeLimit = Context.Reference( + "t3/orchestration-v2/DispatchModeLimit", + { defaultValue: () => undefined }, +); + +const runtimeModeRank: Record = { + "approval-required": 0, + "auto-accept-edits": 1, + auto: 2, + "full-access": 3, +}; +const interactionModeRank: Record = { plan: 0, default: 1 }; + +/** Which of `modes` is broader than `limit`, if either. */ +export const exceededDispatchModeLimit = ( + limit: DispatchModes, + modes: DispatchModes, +): "runtime" | "interaction" | undefined => + runtimeModeRank[modes.runtimeMode] > runtimeModeRank[limit.runtimeMode] + ? "runtime" + : interactionModeRank[modes.interactionMode] > interactionModeRank[limit.interactionMode] + ? "interaction" + : undefined; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index de100d291b8f..5d4b1cfda15f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -46,8 +46,10 @@ import { latestProviderTurnForAttempt, orchestrationV2RunWorkStartedAt, ProviderInstanceId, + ProviderInteractionMode, type ProviderSessionId, RunId, + RuntimeMode, ThreadLinkedPullRequest, ThreadId, type TurnItemId, @@ -86,6 +88,7 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { applyToProjection, emptyProjection, @@ -187,6 +190,22 @@ export class OrchestratorSubagentThreadReadOnlyError extends Schema.TaggedError< } } +/** The command's thread runs above the modes its sender may touch (see `DispatchModeLimit`). */ +export class OrchestratorThreadAboveModeLimitError extends Schema.TaggedError()( + "OrchestratorThreadAboveModeLimitError", + { + commandId: CommandId, + threadId: ThreadId, + mode: Schema.Literals(["runtime", "interaction"]), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + }, +) { + override get message(): string { + return `Thread ${this.threadId} now runs in ${this.runtimeMode}/${this.interactionMode} mode, above what this caller may change.`; + } +} + export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError()( "OrchestratorCommandPreviouslyRejectedError", { @@ -236,6 +255,7 @@ export const OrchestratorV2Error = Schema.Union([ OrchestratorCommandPreviouslyRejectedError, OrchestratorCommandIdConflictError, OrchestratorSubagentThreadReadOnlyError, + OrchestratorThreadAboveModeLimitError, ]); export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; @@ -3418,6 +3438,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + /** Fails when a limited sender's command would touch a thread running above its limit. */ + const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")( + function* ( + command: OrchestrationV2ServerCommand, + threadId: ThreadId, + modes: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + }, + ) { + const limit = yield* DispatchModeLimit; + const exceeded = limit === undefined ? undefined : exceededDispatchModeLimit(limit, modes); + if (limit === undefined || exceeded === undefined) return; + const refusal = { + threadId, + mode: exceeded, + runtimeMode: modes.runtimeMode, + interactionMode: modes.interactionMode, + }; + if (limit.refused !== undefined) yield* Ref.set(limit.refused, refusal); + return yield* new OrchestratorThreadAboveModeLimitError({ + commandId: command.commandId, + ...refusal, + }); + }, + ); + const dispatchThreadFork = Effect.fn("orchestrationV2.dispatch.threadFork")(function* ( command: Extract, events: Ref.Ref>, @@ -3448,6 +3495,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // The source is not under this command's lock, so check the modes this + // command copies, not an earlier read the source's user could outrun. + yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); @@ -3536,6 +3586,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // The source is not under this command's lock, so check the modes this + // command copies, not an earlier read the source's user could outrun. + yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore .getThreadRecords(command.targetThreadId, ["contextTransfers"]) .pipe( @@ -10404,6 +10457,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; } + // A limited sender checked these modes before dispatching; the thread's + // user may have raised them since, and only here can they not change. + const limit = yield* DispatchModeLimit; + if (limit !== undefined) { + // A fork or merge-back source is not under this lock: its dispatch + // checks the copy it reads instead. + const threadId = commandThreadId(command); + const shell = yield* projectionStore + .getThreadShell(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); + } + const plan = yield* dispatchOnce(command).pipe( Effect.flatMap((planned) => // A settle that finds the provider already ended everything, or a @@ -10423,6 +10489,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), Effect.catch((cause) => Effect.gen(function* () { + // Refused like the check above: nothing recorded, so the same command + // can go through once the thread's user lowers it again. + if (cause._tag === "OrchestratorThreadAboveModeLimitError") return yield* cause; const rejectedAt = yield* DateTime.now; const receipt = yield* eventSink .commitRejectedCommand({ diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index b3c37e2fea83..c8f65fd0340a 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -23,6 +23,7 @@ function dispatchWasNotAccepted( case "OrchestratorCommandPreviouslyRejectedError": case "OrchestratorCommandIdConflictError": case "OrchestratorSubagentThreadReadOnlyError": + case "OrchestratorThreadAboveModeLimitError": return true; default: return false; diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index edf6093fa12c..fe700c0e2084 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -20,7 +20,7 @@ For independent implementation or a PR stack in its own worktree, use \`t3_threa - Existing worktree: \`{"title":"Continue cleanup","workspaceStrategy":{"type":"existing_worktree","worktreePath":"/absolute/path/to/worktree","branch":"feature/ui-cleanup"},"message":"Continue the cleanup."}\` - Project's main checkout: \`workspaceStrategy:{"type":"root"}\`. Omitting workspaceStrategy also selects root; it does not inherit the caller's worktree. -For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; launch requires a full-access/default caller. +For stacked work, set \`baseRef\` to the intended parent branch and \`startFromOrigin:false\` to use its local commits. Use \`startFromOrigin:true\` when you intend to fetch and start from origin. Uncommitted edits are not copied. Use \`t3_worktree_list\` to discover existing checkout paths. Project, model selection, and modes inherit unless supplied; a launched thread may not run with broader modes than yours. \`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree. diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 92d8e98313f3..6513d037c8dc 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -398,6 +398,11 @@ export class GitVcsDriver extends Context.Service< readonly pruneWorktrees: (input: { readonly cwd: string; }) => Effect.Effect; + /** + * Absolute paths of every live worktree of the repository at `cwd`, the + * main checkout included. Worktrees whose directory is gone are left out. + */ + readonly listWorktreePaths: (cwd: string) => Effect.Effect; readonly deleteLocalBranch: ( input: GitDeleteLocalBranchInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 92f7478fa34b..d07baa1727c1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3954,6 +3954,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => + runGitStdout("GitVcsDriver.listWorktreePaths", cwd, [ + "worktree", + "list", + "--porcelain", + "-z", + ]).pipe( + // One record per worktree, each ended by an empty field. A `prunable` + // record's directory is gone, and another checkout may now sit there. + Effect.map((stdout) => + stdout.split("\0\0").flatMap((record) => { + const fields = record.split("\0"); + const worktree = fields.find((field) => field.startsWith("worktree ")); + return worktree === undefined || fields.some((field) => field.startsWith("prunable")) + ? [] + : [path.resolve(cwd, worktree.slice("worktree ".length))]; + }), + ), + ); + const withListRefsInvalidation = ( cwd: string, effect: Effect.Effect, @@ -4024,5 +4044,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, + listWorktreePaths, }); }); diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 37f469dab371..fb79ac46fa07 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -325,8 +325,8 @@ this binding. Pass the task in `message`. Project, model, and modes inherit when omitted; workspace does not. `scratch: true` launches without a project, in a folder of its own under the environment's Scratch project. For stacked PRs, use the parent branch as `baseRef` with -`startFromOrigin: false`. Launch requires a full-access/default caller and has -no retry key, so inspect existing threads after a failed or lost response before +`startFromOrigin: false`. The new thread may not run with broader runtime or +interaction modes than the caller. Launch has no retry key, so inspect existing threads after a failed or lost response before launching again. `create_threads` remains the batch option for a shared checkout. ### `t3_thread_list` diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 475b991e0dde..f8c47dfccdf1 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -7,6 +7,7 @@ import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; @@ -24,6 +25,7 @@ export default definePlugin({ "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, + "no-raw-mcp-registration": noRawMcpRegistration, "no-test-in-loop": noTestInLoop, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, diff --git a/oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts new file mode 100644 index 000000000000..cc72f70a300a --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.test.ts @@ -0,0 +1,185 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-raw-mcp-registration"); +const testFile = createOxlintRuleHarness("t3code/no-raw-mcp-registration", { + filename: "server.test.ts", +}); + +describe("t3code/no-raw-mcp-registration", () => { + rule.valid( + "allows the rest of effect/ai and type-only McpServer imports", + ` + import { McpSchema, Tool, Toolkit } from "effect/ai"; + import type { McpServer } from "effect/ai"; + import { type McpServer as Server } from "effect/ai"; + export type Service = McpServer.McpServer | Server.McpServer; + export const used = [McpSchema, Tool, Toolkit]; + `, + ); + + rule.invalid( + "reports importing McpServer from effect/ai", + ` + import { McpServer } from "effect/ai"; + export const server = McpServer.McpServer; + `, + (output) => { + assert.match(output, /Only McpHttpServer may use Effect's McpServer/); + }, + ); + + rule.invalid( + "reports importing McpServer under another name", + ` + import { McpServer as Server } from "effect/ai"; + export const registration = Server.toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports a namespace import of effect/ai", + ` + import * as Ai from "effect/ai"; + export const registration = Ai.McpServer.toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports importing the McpServer module directly", + ` + import { toolkit } from "effect/ai/McpServer"; + export const registration = toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports re-exporting McpServer", + ` + export { McpServer as Server } from "effect/ai"; + `, + ); + + rule.invalid( + "reports re-exporting all of effect/ai", + ` + export * from "effect/ai"; + `, + ); + + rule.invalid( + "reports a dynamic import of the McpServer module", + ` + export const load = () => import("effect/ai/McpServer"); + `, + ); + + rule.invalid( + "reports a dynamic import written as a template", + ` + export const load = () => import(\`effect/ai/McpServer\`); + `, + ); + + rule.invalid( + "reports McpServer.toolkit", + ` + export const registration = McpServer.toolkit(SomeToolkit); + `, + (output) => { + assert.match(output, /McpServer\.toolkit registers on \/mcp/); + }, + ); + + rule.invalid( + "reports McpServer.resource and McpServer.prompt", + ` + export const resource = McpServer.resource({ uri: "t3://x", name: "x", content: "x" }); + export const prompt = McpServer.prompt({ name: "x", content: () => "x" }); + `, + ); + + rule.invalid( + "reports addTool on the McpServer service", + ` + import * as Effect from "effect/Effect"; + export const register = Effect.gen(function* () { + const server = yield* Service; + yield* server.addTool({ tool, annotations, handle: () => Effect.die("unchecked") }); + }); + `, + (output) => { + assert.match(output, /\.addTool registers on \/mcp/); + }, + ); + + rule.invalid( + "reports a registration method read without calling it", + ` + export const add = server.addTool; + `, + ); + + rule.invalid( + "reports a registration function taken by destructuring", + ` + const { toolkit } = McpServer; + export const registration = toolkit(SomeToolkit); + `, + ); + + rule.invalid( + "reports a registration method taken by destructuring", + ` + import * as Effect from "effect/Effect"; + export const register = Effect.gen(function* () { + const { addTool } = yield* Service; + yield* addTool({ tool, annotations, handle: () => Effect.die("unchecked") }); + }); + `, + ); + + testFile.valid( + "lets tests import McpServer to build a server", + ` + import { McpServer } from "effect/ai"; + export const layer = McpServer.McpServer.layer; + `, + ); + + testFile.invalid( + "reports a registration in a test", + ` + import { McpServer } from "effect/ai"; + export const registration = McpServer.toolkit(SomeToolkit); + `, + ); + + testFile.invalid( + "reports a test importing McpServer under another name", + ` + import { McpServer as Server } from "effect/ai"; + export const registration = Server.toolkit(SomeToolkit); + `, + (output) => { + assert.match(output, /Tests import McpServer only as/); + }, + ); + + testFile.invalid( + "reports a test importing effect/ai as a namespace", + ` + import * as Ai from "effect/ai"; + export const registration = Ai.McpServer.toolkit(SomeToolkit); + `, + ); + + testFile.invalid( + "reports a test importing the McpServer module directly", + ` + import { toolkit } from "effect/ai/McpServer"; + export const registration = toolkit(SomeToolkit); + `, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts new file mode 100644 index 000000000000..6f3b3df49f59 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts @@ -0,0 +1,148 @@ +import { defineRule } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; + +// Effect's MCP server, and the modules that re-export it. +const MCP_SERVER_MODULE = "effect/ai/McpServer"; +const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]); +// Registering anything straight on the MCP server skips McpToolAccess, where +// every T3 MCP tool declares who may call it. These are the module functions... +const MODULE_REGISTRATIONS = new Set([ + "toolkit", + "registerToolkit", + "resource", + "registerResource", + "prompt", + "registerPrompt", +]); +// ...and the methods on the McpServer service itself. +const SERVICE_REGISTRATIONS = new Set([ + "addTool", + "addResource", + "addResourceTemplate", + "addPrompt", +]); +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; + +const HOW = + "Build the handlers with McpToolAccess.toLayer and register them through McpHttpServer's toolkitRegistration."; +const registrationMessage = (name: string) => + `${name} registers on /mcp without the access checks McpToolAccess declares. ${HOW}`; +const importMessage = `Only McpHttpServer may use Effect's McpServer: a registration anywhere else skips the access checks McpToolAccess declares. ${HOW}`; +const testImportMessage = + 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.'; + +/** A module specifier: a string, or a template with nothing substituted. */ +const literalString = (node: unknown): Option.Option => + Option.flatMap(unwrapExpression(node), (expression) => { + if (expression.type === "Literal" && typeof expression.value === "string") { + return Option.some(expression.value); + } + if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) { + return Option.fromNullishOr(expression.quasis[0]?.value.cooked); + } + return Option.none(); + }); + +const namesMcpServer = (node: unknown) => + Option.getOrUndefined(getPropertyName(node)) === "McpServer"; + +const exposesMcpServer = (source: Option.Option) => + Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value)); + +/** + * Keeps every registration on `/mcp` inside McpHttpServer, whose helpers + * accept only handlers McpToolAccess built; the lint config exempts that file. + * Elsewhere it reports runtime imports of Effect's McpServer, under any name + * or path, and reading a registration method or function in any form. Tests + * may import McpServer to build a server, but only under its own name, so a + * registration on it is still reported. It guards against a registration that + * skips the checks by accident; deliberately working around it needs a + * reviewed change here. + */ +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow registering on the MCP server without McpToolAccess; tools must declare who may call them.", + }, + }, + create(context) { + const isTest = TEST_FILE_PATTERN.test(context.filename); + return { + ImportDeclaration(node) { + if (node.importKind === "type") return; + const source = literalString(node.source); + if (Option.isNone(source)) return; + const exposes = node.specifiers.some((specifier) => { + if (specifier.type !== "ImportSpecifier") return exposesMcpServer(source); + if (specifier.importKind === "type") return false; + if (source.value === MCP_SERVER_MODULE) return true; + return ( + AI_MODULES.has(source.value) && + namesMcpServer(specifier.imported) && + // A test's `McpServer.toolkit(...)` is reported below by name. + !(isTest && namesMcpServer(specifier.local)) + ); + }); + if (exposes) context.report({ node, message: isTest ? testImportMessage : importMessage }); + }, + ExportNamedDeclaration(node) { + if (node.source === null || node.exportKind === "type") return; + const source = literalString(node.source); + if (Option.isNone(source)) return; + const exposes = node.specifiers.some( + (specifier) => + specifier.exportKind !== "type" && + (source.value === MCP_SERVER_MODULE || + (AI_MODULES.has(source.value) && namesMcpServer(specifier.local))), + ); + if (exposes) context.report({ node, message: importMessage }); + }, + ExportAllDeclaration(node) { + if (node.exportKind === "type") return; + if (exposesMcpServer(literalString(node.source))) { + context.report({ node, message: importMessage }); + } + }, + ImportExpression(node) { + if (exposesMcpServer(literalString(node.source))) { + context.report({ node, message: importMessage }); + } + }, + MemberExpression(node) { + const name = getPropertyName(node.property); + if (Option.isNone(name)) return; + if (SERVICE_REGISTRATIONS.has(name.value)) { + context.report({ node, message: registrationMessage(`.${name.value}`) }); + } else if ( + MODULE_REGISTRATIONS.has(name.value) && + isIdentifier(unwrapExpression(node.object), "McpServer") + ) { + context.report({ node, message: registrationMessage(`McpServer.${name.value}`) }); + } + }, + // `const { addTool } = server`, `const { toolkit } = McpServer` + ObjectPattern(node) { + const fromMcpServer = + node.parent.type === "VariableDeclarator" && + isIdentifier(unwrapExpression(node.parent.init), "McpServer"); + for (const property of node.properties) { + if (property.type !== "Property") continue; + const name = getPropertyName(property.key); + if (Option.isNone(name)) continue; + if (SERVICE_REGISTRATIONS.has(name.value)) { + context.report({ node: property, message: registrationMessage(`.${name.value}`) }); + } else if (fromMcpServer && MODULE_REGISTRATIONS.has(name.value)) { + context.report({ + node: property, + message: registrationMessage(`McpServer.${name.value}`), + }); + } + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 5fdca6eeec5d..ca3ed94432cc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,7 @@ export default defineConfig({ "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", + "t3code/no-raw-mcp-registration": "error", "t3code/no-test-in-loop": "error", "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", @@ -179,6 +180,11 @@ export default defineConfig({ files: ["packages/shared/src/hostProcess.ts"], rules: { "t3code/no-global-process-runtime": "off" }, }, + { + // The registration helpers that only accept handlers built by McpToolAccess. + files: ["apps/server/src/mcp/McpHttpServer.ts"], + rules: { "t3code/no-raw-mcp-registration": "off" }, + }, { files: ["apps/web/src/**"], excludeFiles: ["apps/web/src/components/ui/**"], From 2c8be5893eb3754071162e344a8c8e8a67bef597 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 14:09:35 -0700 Subject: [PATCH 15/59] feat(server): outside agents sign in to the T3 MCP server with OAuth (#16336) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/auth/EnvironmentAuth.ts | 163 ++++- apps/server/src/auth/McpOAuth.test.ts | 505 +++++++++++++++ apps/server/src/auth/McpOAuth.ts | 603 ++++++++++++++++++ apps/server/src/auth/SessionStore.test.ts | 14 + apps/server/src/auth/SessionStore.ts | 7 + apps/server/src/auth/mcpOAuthHtml.ts | 46 ++ apps/server/src/auth/mcpOAuthHttp.ts | 159 +++++ apps/server/src/mcp/McpHttpServer.test.ts | 155 ++++- apps/server/src/mcp/McpHttpServer.ts | 133 ++-- .../src/mcp/McpInvocationContext.test.ts | 2 +- apps/server/src/mcp/McpInvocationContext.ts | 12 +- .../server/src/mcp/McpToolAccess.race.test.ts | 2 +- apps/server/src/mcp/McpToolAccess.test.ts | 12 +- apps/server/src/mcp/McpToolAccess.ts | 25 +- .../src/mcp/OrchestratorMcpService.test.ts | 21 +- apps/server/src/mcp/OrchestratorMcpService.ts | 13 +- apps/server/src/mcp/threadAccess.ts | 2 +- apps/server/src/mcp/toolkits/core.test.ts | 74 ++- .../src/mcp/toolkits/project/handlers.test.ts | 2 +- apps/server/src/server.ts | 4 + .../components/auth/ConnectAgentSurface.tsx | 317 +++++++++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 2 +- apps/web/src/routes/connect-agent.tsx | 7 + apps/web/vite.config.ts | 7 +- docs/internals/environment-auth.md | 31 +- packages/contracts/src/auth.ts | 180 ++++++ packages/contracts/src/environmentHttp.ts | 66 ++ packages/shared/src/devProxy.ts | 13 +- 29 files changed, 2531 insertions(+), 67 deletions(-) create mode 100644 apps/server/src/auth/McpOAuth.test.ts create mode 100644 apps/server/src/auth/McpOAuth.ts create mode 100644 apps/server/src/auth/mcpOAuthHtml.ts create mode 100644 apps/server/src/auth/mcpOAuthHttp.ts create mode 100644 apps/web/src/components/auth/ConnectAgentSurface.tsx create mode 100644 apps/web/src/routes/connect-agent.tsx diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 0dc0697532ca..516860cee3bf 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -2,6 +2,8 @@ import { AuthAccessTokenType, AuthAccessWriteScope, AuthAdministrativeScopes, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, AuthStandardClientScopes, type AuthAccessTokenResult, type AuthBrowserSessionResult, @@ -9,6 +11,7 @@ import { type AuthClientSession, type AuthCreatePairingCredentialInput, type AuthEnvironmentScope, + type AuthMcpClientAccess, type AuthPairingLink, type AuthPairingCredentialResult, type AuthSessionId, @@ -63,6 +66,31 @@ export interface IssuedBearerSession { readonly expiresAt: DateTime.Utc; } +/** + * Sessions an MCP client (an agent T3 Code did not launch) obtains through + * OAuth. They are accepted only by `/mcp`, where every action is capped by the + * access the user approved; the HTTP API and WebSocket reject them so an agent + * token cannot reach the full RPC surface around that cap. + * + * A read-only grant holds `orchestration:read` alone. Any other grant also + * holds `orchestration:operate` and carries its runtime-mode ceiling. + */ +const MCP_CLIENT_SUBJECT = "mcp-client"; +const MCP_CLIENT_SESSION_TTL = Duration.days(30); + +export const mcpClientScopes = ( + access: AuthMcpClientAccess, +): ReadonlyArray => + access === "read-only" + ? [AuthOrchestrationReadScope] + : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; + +export interface McpClientSession { + readonly sessionId: AuthSessionId; + readonly label: string; + readonly access: AuthMcpClientAccess; +} + export interface AuthenticatedSession { readonly sessionId: AuthSessionId; readonly subject: string; @@ -406,6 +434,19 @@ export const serverAuthInvalidRequestReason = ( ): "invalid_scope" | "scope_not_granted" => error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; +export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError()( + "ServerAuthMcpApprovalCodeError", + { reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) }, +) { + override get message(): string { + return this.reason === "insufficient_scope" + ? "That pairing code cannot grant this access, and it is now used up. Create one with the standard scopes, or choose Read only with a new code." + : this.reason === "not_a_pairing_code" + ? "That is not a one-time pairing code." + : "That pairing code is unknown, expired, or already used."; + } +} + export class ServerAuthForbiddenOperationError extends Schema.TaggedError()( "ServerAuthForbiddenOperationError", {}, @@ -502,6 +543,32 @@ export class EnvironmentAuth extends Context.Service< readonly issueStartupPairingUrl: ( baseUrl: string, ) => Effect.Effect; + /** Only bearer `mcp-client` sessions; never cookies or proof-bound tokens. */ + readonly authenticateMcpClient: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueMcpClientSession: (input: { + readonly label: string; + readonly access: AuthMcpClientAccess; + readonly client: AuthClientMetadata; + }) => Effect.Effect< + { readonly token: string; readonly expiresAt: DateTime.DateTime }, + ServerAuthInternalError + >; + /** + * Spends a one-time pairing code as approval for an MCP client with the + * given access; the code must hold every scope that access grants. + * Proof-bound codes (T3 Connect) are refused without being spent, and + * desktop bootstrap grants never qualify. + */ + readonly consumeMcpApprovalCode: ( + code: string, + access: AuthMcpClientAccess, + ) => Effect.Effect; + /** A browser cookie session only; a bearer header never counts as one. */ + readonly authenticateBrowserSession: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; } >()("t3/auth/EnvironmentAuth") {} @@ -540,6 +607,15 @@ export function toBootstrapExchangeError( }); } +const rejectMcpClientAudience = (session: S) => + session.subject === MCP_CLIENT_SUBJECT + ? Effect.fail( + new ServerAuthInvalidCredentialError({ + diagnostic: "MCP client sessions are only accepted by the MCP endpoint.", + }), + ).pipe(Effect.tap(() => Effect.logWarning("Rejected an MCP client session outside /mcp."))) + : Effect.succeed(session); + const mapSessionVerificationErrors = ( effect: Effect.Effect, ): Effect.Effect => @@ -624,6 +700,8 @@ export const make = Effect.gen(function* () { ) : Effect.void, ), + mapSessionVerificationErrors, + Effect.flatMap(rejectMcpClientAudience), Effect.map((session) => ({ sessionId: session.sessionId, subject: session.subject, @@ -632,7 +710,6 @@ export const make = Effect.gen(function* () { ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), - mapSessionVerificationErrors, ); const authenticateRequest = ( @@ -1079,6 +1156,8 @@ export const make = Effect.gen(function* () { const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); if (websocketTicket && websocketTicket.trim().length > 0) { return yield* sessions.verifyWebSocketToken(websocketTicket).pipe( + mapSessionVerificationErrors, + Effect.flatMap(rejectMcpClientAudience), Effect.map((session) => ({ sessionId: session.sessionId, subject: session.subject, @@ -1086,7 +1165,6 @@ export const make = Effect.gen(function* () { scopes: session.scopes, ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), - mapSessionVerificationErrors, ); } } @@ -1094,6 +1172,83 @@ export const make = Effect.gen(function* () { return yield* authenticateRequest(request); }); + const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { + const token = parseBearerToken(request); + if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); + return sessions.verify(token).pipe( + mapSessionVerificationErrors, + Effect.flatMap((session) => + session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token" + ? Effect.succeed({ + sessionId: session.sessionId, + label: session.client.label ?? "MCP client", + access: session.scopes.includes(AuthOrchestrationOperateScope) + ? (session.runtimeModeCeiling ?? "approval-required") + : "read-only", + } satisfies McpClientSession) + : Effect.fail( + new ServerAuthInvalidCredentialError({ + diagnostic: "Only MCP client sessions are accepted here.", + }), + ), + ), + Effect.withSpan("EnvironmentAuth.authenticateMcpClient"), + ); + }; + + const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) => + sessions + .issue({ + subject: MCP_CLIENT_SUBJECT, + method: "bearer-access-token", + scopes: mcpClientScopes(input.access), + ttl: MCP_CLIENT_SESSION_TTL, + ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), + client: { ...input.client, label: input.label, deviceType: "bot" }, + }) + .pipe( + Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })), + Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), + Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), + ); + + const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( + request, + ) => { + const token = + request.cookies[sessions.cookieName] ?? + (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ?? + (devAuth ? request.cookies[devAuth.cookieName] : undefined); + if (!token) return Effect.fail(new ServerAuthMissingCredentialError({})); + return authenticateToken(token).pipe( + Effect.filterOrFail( + (session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint, + () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }), + ), + Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"), + ); + }; + + const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = ( + code, + access, + ) => + // No proof key: a code bound to a T3 Connect client's key fails without being spent. + resolveBootstrapGrant(code.trim()).pipe( + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), + }), + Effect.flatMap((grant) => + grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" + ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) + : mcpClientScopes(access).every((scope) => grant.scopes.includes(scope)) + ? Effect.void + : Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), + ), + Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), + ); + return EnvironmentAuth.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), @@ -1116,6 +1271,10 @@ export const make = Effect.gen(function* () { authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, + authenticateMcpClient, + issueMcpClientSession, + consumeMcpApprovalCode, + authenticateBrowserSession, }); }); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts new file mode 100644 index 000000000000..7af90bff8076 --- /dev/null +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -0,0 +1,505 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthAdministrativeScopes, + type AuthEnvironmentScope, + EnvironmentHttpApi, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Etag from "effect/http/Etag"; +import * as HttpPlatform from "effect/http/HttpPlatform"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as McpOAuth from "./McpOAuth.ts"; +import * as McpOAuthHttp from "./mcpOAuthHttp.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import * as AuthHttp from "./http.ts"; + +class AuthTestApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.auth) + .add(EnvironmentHttpApi.groups.mcpOAuth) {} + +const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-oauth-test-" }); +const layerEnvironmentAuth = EnvironmentAuth.layer.pipe( + Layer.provide(Sqlite.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerEnvironment.layerIdentity), + Layer.provide(layerConfig), +); +// Each router gets its own database; the capture hands that router's EnvironmentAuth to its test. +const makeLayerRoutes = (capture: (auth: EnvironmentAuth.EnvironmentAuth["Service"]) => void) => + Layer.mergeAll( + Layer.effectDiscard( + EnvironmentAuth.EnvironmentAuth.pipe(Effect.tap((auth) => Effect.sync(() => capture(auth)))), + ), + HttpApiBuilder.layer(AuthTestApi).pipe( + Layer.provide(AuthHttp.layer), + Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), + Layer.provide(AuthHttp.layerAuthenticatedAuth), + ), + ).pipe( + Layer.provideMerge(layerEnvironmentAuth), + Layer.provide(layerConfig), + Layer.provideMerge( + HttpPlatform.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Etag.layerWeak), + ), + ), + Layer.provide(NodeServices.layer), + ); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const ORIGIN = "https://box.example.ts.net"; +const REDIRECT = "http://localhost/callback"; +const verifier = "a".repeat(43) + "-verifier-for-tests"; +/** base64url(SHA-256(verifier)), the S256 challenge for `verifier`. */ +const challenge = "DeB41nTVkPwpbbYecrnqtVq7VXLezustdHAK4SWt13c"; + +/** Requests as they arrive behind an https proxy: plain http with the public Host. */ +const at = (path: string, init?: RequestInit) => + new Request(`http://127.0.0.1${path}`, { + ...init, + headers: { host: "box.example.ts.net", "x-forwarded-proto": "https", ...init?.headers }, + }); +const form = (body: Record) => ({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), +}); + +type Handler = (request: Request) => Effect.Effect; + +const withRoutes = ( + use: (handler: Handler, auth: EnvironmentAuth.EnvironmentAuth["Service"]) => Effect.Effect, +) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const context = Context.make(Crypto.Crypto, crypto); + let routerAuth: EnvironmentAuth.EnvironmentAuth["Service"] | undefined; + const layerRoutes = makeLayerRoutes((auth) => { + routerAuth = auth; + }); + return yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })), + (web) => + Effect.gen(function* () { + const handler: Handler = (request) => Effect.promise(() => web.handler(request, context)); + // The router builds its layer on first request; a metadata read warms it. + yield* handler(at("/.well-known/oauth-authorization-server")); + return yield* use(handler, routerAuth!); + }), + (web) => Effect.promise(() => web.dispose()), + ); + }).pipe(Effect.provide(NodeServices.layer)); + +const json = (response: Response) => Effect.promise(() => response.json() as Promise); + +const postJson = (path: string, body: unknown, cookie?: string) => + at(path, { + method: "POST", + headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) }, + body: encodeJson(body), + }); + +/** What the approval page posts: the agent's request plus the user's choice. */ +const decide = ( + handler: Handler, + authorization: Record, + decision: Record, + cookie?: string, +) => + handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe( + Effect.flatMap((response) => + json<{ redirectTo?: string; message?: string }>(response).pipe( + Effect.map((payload) => ({ status: response.status, ...payload })), + ), + ), + ); + +const register = (handler: Handler, redirect = REDIRECT) => + handler( + at("/oauth/mcp/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: encodeJson({ + client_name: "Claude Code", + redirect_uris: [redirect], + grant_types: ["authorization_code", "refresh_token"], + token_endpoint_auth_method: "none", + }), + }), + ); + +const registeredClientId = (handler: Handler) => + register(handler).pipe( + Effect.flatMap((response) => json<{ client_id: string }>(response)), + Effect.map((body) => body.client_id), + ); + +const authorizeParams = (clientId: string, redirect = "http://localhost:51234/callback") => ({ + response_type: "code", + client_id: clientId, + redirect_uri: redirect, + code_challenge: challenge, + code_challenge_method: "S256", + state: "state-1", + resource: `${ORIGIN}/mcp`, +}); + +it.live("derives discovery metadata from the origin the client reached", () => + withRoutes((handler) => + Effect.gen(function* () { + const resource = yield* handler(at("/.well-known/oauth-protected-resource/mcp")); + expect(yield* json(resource)).toMatchObject({ + resource: `${ORIGIN}/mcp`, + authorization_servers: [ORIGIN], + }); + const server = yield* json( + yield* handler(at("/.well-known/oauth-authorization-server")), + ); + expect(server).toMatchObject({ + issuer: ORIGIN, + authorization_endpoint: `${ORIGIN}/oauth/mcp/authorize`, + token_endpoint: `${ORIGIN}/oauth/mcp/token`, + registration_endpoint: `${ORIGIN}/oauth/mcp/register`, + code_challenge_methods_supported: ["S256"], + }); + }), + ), +); + +it.live("registers only loopback clients and never redirects for an unverified client", () => + withRoutes((handler) => + Effect.gen(function* () { + const remote = yield* register(handler, "https://attacker.example/callback"); + expect(remote.status).toBe(400); + expect(yield* json(remote)).toMatchObject({ error: "invalid_redirect_uri" }); + + const registered = yield* register(handler); + expect(registered.status).toBe(201); + const { client_id: clientId } = (yield* json(registered)) as { client_id: string }; + + const forged = yield* handler( + at( + `/oauth/mcp/authorize?${new URLSearchParams(authorizeParams(`${clientId.split(".")[0]}.forged`))}`, + ), + ); + expect(forged.status).toBe(400); + expect(forged.headers.get("location")).toBeNull(); + + const unregistered = yield* handler( + at( + `/oauth/mcp/authorize?${new URLSearchParams( + authorizeParams(clientId, "http://localhost:51234/elsewhere"), + )}`, + ), + ); + expect(unregistered.status).toBe(400); + expect(unregistered.headers.get("location")).toBeNull(); + + // A valid request is handed to the web app's approval page, query intact. + const query = new URLSearchParams(authorizeParams(clientId)).toString(); + const handoff = yield* handler(at(`/oauth/mcp/authorize?${query}`)); + expect(handoff.status).toBe(302); + expect(handoff.headers.get("location")).toBe(`/connect-agent?${query}`); + const details = yield* handler( + postJson("/oauth/mcp/approval", authorizeParams(clientId)), + ).pipe(Effect.flatMap(json>)); + expect(details).toEqual({ + clientName: "Claude Code", + redirectHost: "localhost:51234", + environmentHost: "box.example.ts.net", + }); + + // The approval endpoints re-check the request: a forged client gets a message, not a URL. + const forgedDetails = yield* handler( + postJson("/oauth/mcp/approval", authorizeParams(`${clientId.split(".")[0]}.forged`)), + ); + expect(forgedDetails.status).toBe(400); + expect(yield* json>(forgedDetails)).not.toHaveProperty("redirectTo"); + }), + ), +); + +it.live("signs in with a pairing code and issues a token only /mcp accepts", () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const clientId = yield* registeredClientId(handler); + const pairing = yield* auth.issuePairingCredential(); + const params = authorizeParams(clientId); + + const wrongCode = yield* decide(handler, params, { + _tag: "pairing-code", + access: "auto", + code: "nope", + }); + expect(wrongCode.status).toBe(400); + expect(wrongCode.message).toContain("unknown, expired, or already used"); + + const approved = yield* decide(handler, params, { + _tag: "pairing-code", + access: "auto", + code: pairing.credential, + }); + expect(approved.status).toBe(200); + const callback = new URL(approved.redirectTo!); + expect(callback.origin).toBe("http://localhost:51234"); + expect(callback.searchParams.get("state")).toBe("state-1"); + expect(callback.searchParams.get("iss")).toBe(ORIGIN); + const code = callback.searchParams.get("code")!; + + const exchange = (codeVerifier: string) => + handler( + at( + "/oauth/mcp/token", + form({ + grant_type: "authorization_code", + code, + redirect_uri: params.redirect_uri, + client_id: clientId, + code_verifier: codeVerifier, + resource: `${ORIGIN}/mcp`, + }), + ), + ); + const tokenResponse = yield* exchange(verifier); + expect(tokenResponse.status).toBe(200); + const token = (yield* json(tokenResponse)) as { + access_token: string; + scope: string; + }; + expect(token.scope).toBe("orchestration:read orchestration:operate"); + + // Codes are single use. + expect((yield* exchange(verifier)).status).toBe(400); + + const client = yield* auth.authenticateMcpClient( + HttpServerRequest.fromWeb( + at("/mcp", { headers: { authorization: `Bearer ${token.access_token}` } }), + ), + ); + expect(client).toMatchObject({ label: "Claude Code", access: "auto" }); + + // The same token is refused by the rest of the environment. + const session = yield* handler( + at("/api/auth/session", { headers: { authorization: `Bearer ${token.access_token}` } }), + ); + expect(yield* json(session)).toMatchObject({ authenticated: false }); + const ticket = yield* handler( + at("/api/auth/websocket-ticket", { + method: "POST", + headers: { authorization: `Bearer ${token.access_token}` }, + }), + ); + expect(ticket.status).toBe(401); + }), + ), +); + +it.live("rejects a wrong PKCE verifier and spends the code", () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const clientId = yield* registeredClientId(handler); + const pairing = yield* auth.issuePairingCredential(); + const params = authorizeParams(clientId); + const approved = yield* decide(handler, params, { + _tag: "pairing-code", + access: "approval-required", + code: pairing.credential, + }); + const code = new URL(approved.redirectTo!).searchParams.get("code")!; + const exchange = (codeVerifier: string) => + handler( + at( + "/oauth/mcp/token", + form({ + grant_type: "authorization_code", + code, + redirect_uri: params.redirect_uri, + client_id: clientId, + code_verifier: codeVerifier, + }), + ), + ); + const wrong = yield* exchange("b".repeat(64)); + expect(wrong.status).toBe(400); + expect(yield* json(wrong)).toMatchObject({ error: "invalid_grant" }); + expect((yield* exchange(verifier)).status).toBe(400); + }), + ), +); + +it.live( + "denies and refuses codes bound to another client's key or without the scopes it grants", + () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const clientId = yield* registeredClientId(handler); + const params = authorizeParams(clientId); + + const denied = yield* decide(handler, params, { _tag: "deny" }); + const deniedUrl = new URL(denied.redirectTo!); + expect(deniedUrl.searchParams.get("error")).toBe("access_denied"); + expect(deniedUrl.searchParams.get("state")).toBe("state-1"); + + const approveWith = (code: string) => + decide(handler, params, { _tag: "pairing-code", access: "auto", code }); + + // A T3 Connect code is bound to a device key: refused, and still usable by its device. + const bound = yield* auth.createPairingLink({ + proofKeyThumbprint: "device-key-thumbprint", + }); + expect((yield* approveWith(bound.credential)).status).toBe(400); + const stillValid = yield* auth + .exchangeBootstrapCredentialForAccessToken( + bound.credential, + undefined, + { deviceType: "mobile" }, + { proofKeyThumbprint: "device-key-thumbprint" }, + ) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + expect(stillValid).toBe(true); + + const readOnly = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const readOnlyResponse = yield* approveWith(readOnly.credential); + expect(readOnlyResponse.status).toBe(400); + expect(readOnlyResponse.message).toContain("cannot grant this access"); + + // A read-only code can approve read-only access. + const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const readOnlyApproval = yield* decide(handler, params, { + _tag: "pairing-code", + access: "read-only", + code: readOnlyCode.credential, + }); + expect(readOnlyApproval.status).toBe(200); + const readOnlyToken = yield* handler( + at( + "/oauth/mcp/token", + form({ + grant_type: "authorization_code", + code: new URL(readOnlyApproval.redirectTo!).searchParams.get("code")!, + redirect_uri: params.redirect_uri, + client_id: clientId, + code_verifier: verifier, + resource: `${ORIGIN}/mcp`, + }), + ), + ).pipe(Effect.flatMap(json<{ access_token: string; scope: string }>)); + expect(readOnlyToken.scope).toBe("orchestration:read"); + const readOnlyClient = yield* auth.authenticateMcpClient( + HttpServerRequest.fromWeb( + at("/mcp", { headers: { authorization: `Bearer ${readOnlyToken.access_token}` } }), + ), + ); + expect(readOnlyClient.access).toBe("read-only"); + }), + ), +); + +it.live("one-click approves only access the browser session holds the scopes for", () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const clientId = yield* registeredClientId(handler); + const params = authorizeParams(clientId); + // Signs a browser in through the real route and returns its session cookie. + const browserCookie = (scopes: ReadonlyArray) => + Effect.gen(function* () { + const pairing = yield* auth.issuePairingCredential({ scopes }); + const response = yield* handler( + at("/api/auth/browser-session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: encodeJson({ credential: pairing.credential }), + }), + ); + return response.headers.getSetCookie()[0]!.split(";", 1)[0]!; + }); + const details = (cookie: string) => + handler(postJson("/oauth/mcp/approval", params, cookie)).pipe( + Effect.flatMap(json<{ csrfToken?: string; oneClickAccess?: ReadonlyArray }>), + ); + + const admin = yield* browserCookie([...AuthAdministrativeScopes]); + const adminDetails = yield* details(admin); + expect(adminDetails.csrfToken).toEqual(expect.any(String)); + expect(adminDetails.oneClickAccess).toEqual([ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); + const oneClick = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: adminDetails.csrfToken! }, + admin, + ); + expect(new URL(oneClick.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); + + // access:write alone cannot hand an agent thread control it does not hold. + const accessOnly = yield* browserCookie(["access:read", "access:write"]); + expect((yield* details(accessOnly)).csrfToken).toBeUndefined(); + const forged = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: "forged" }, + accessOnly, + ); + expect(forged.status).toBe(400); + expect(forged.message).toContain("Enter a pairing code instead"); + + // A session that can read but not operate threads may approve read-only access only. + const reader = yield* browserCookie(["access:write", "orchestration:read"]); + const readerDetails = yield* details(reader); + expect(readerDetails.csrfToken).toEqual(expect.any(String)); + // The page shows the pairing-code field for anything above read only. + expect(readerDetails.oneClickAccess).toEqual(["read-only"]); + const tooBroad = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: readerDetails.csrfToken! }, + reader, + ); + expect(tooBroad.status).toBe(400); + const readOnly = yield* decide( + handler, + params, + { _tag: "browser-session", access: "read-only", csrfToken: readerDetails.csrfToken! }, + reader, + ); + expect(new URL(readOnly.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); + }), + ), +); + +it("matches loopback redirects on everything but the port", () => { + expect( + McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://localhost:9/callback"), + ).toBe(true); + expect( + McpOAuth.loopbackRedirectMatches("http://127.0.0.1:1/callback", "http://127.0.0.1:2/callback"), + ).toBe(true); + expect( + McpOAuth.loopbackRedirectMatches("http://localhost/callback", "http://127.0.0.1/callback"), + ).toBe(false); + expect( + McpOAuth.loopbackRedirectMatches("http://localhost/callback", "https://localhost/callback"), + ).toBe(false); +}); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts new file mode 100644 index 000000000000..0ae7cdf888a0 --- /dev/null +++ b/apps/server/src/auth/McpOAuth.ts @@ -0,0 +1,603 @@ +import { + AuthAccessWriteScope, + type AuthMcpApprovalDecision, + type AuthMcpAuthorizationRequest, + type AuthMcpAuthorizationServerMetadata, + type AuthMcpClientRegistration, + type AuthMcpProtectedResourceMetadata, + AuthMcpRegistrationError, + AuthMcpTokenError, + type AuthMcpTokenRequest, + type AuthMcpTokenResult, + type AuthMcpClientAccess, + type AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, +} from "@t3tools/contracts"; +import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpServerRequest } from "effect/http"; + +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + deriveAuthClientMetadata, + signPayload, + timingSafeEqualBase64Url, +} from "./utils.ts"; + +/** + * The OAuth authorization server MCP clients (Claude Code, Codex, any agent + * T3 Code did not launch) use to sign in to this environment's `/mcp`. + * + * Every URL is derived from the request's own origin, so the same server + * answers correctly over loopback, Tailscale Serve and a T3 Connect tunnel. + * Client registration is stateless: a client id is its signed metadata, so + * an unauthenticated caller cannot grow server state. Only loopback redirect + * URIs are accepted; an https redirect would let anyone mail the owner an + * approval link that delivers the code to their own server. + */ + +const SIGNING_SECRET_NAME = "mcp-oauth-signing-key"; +const AUTHORIZATION_CODE_TTL_MS = 60_000; +const MAX_CLIENT_NAME_LENGTH = 100; +const MAX_REDIRECT_URIS = 5; +const MAX_REDIRECT_URI_LENGTH = 512; +const DEFAULT_CLIENT_NAME = "MCP client"; +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); +const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; + +const MCP_OAUTH_SCOPES = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; + +export interface McpOAuthUrls { + readonly issuer: string; + readonly resource: string; +} + +/** + * Issuer and MCP resource for the origin this request reached: its Host, and + * https when a proxy says so. A Host that is not a valid authority falls back + * to localhost, which no client will have asked for. + */ +export const requestUrls = (request: HttpServerRequest.HttpServerRequest): McpOAuthUrls => { + const origin = Option.match(HttpServerRequest.toURL(request), { + onNone: () => "http://localhost", + onSome: (url) => url.origin, + }); + return { issuer: origin, resource: `${origin}/mcp` }; +}; + +export const protectedResourceMetadata = ( + urls: McpOAuthUrls, +): AuthMcpProtectedResourceMetadata => ({ + resource: urls.resource, + authorization_servers: [urls.issuer], + scopes_supported: MCP_OAUTH_SCOPES, + bearer_methods_supported: ["header"], + resource_name: "T3 Code", +}); + +export const authorizationServerMetadata = ( + urls: McpOAuthUrls, +): AuthMcpAuthorizationServerMetadata => ({ + issuer: urls.issuer, + authorization_endpoint: `${urls.issuer}/oauth/mcp/authorize`, + token_endpoint: `${urls.issuer}/oauth/mcp/token`, + registration_endpoint: `${urls.issuer}/oauth/mcp/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: MCP_OAUTH_SCOPES, + authorization_response_iss_parameter_supported: true, +}); + +const parseLoopbackRedirect = (value: string): URL | undefined => { + if (value.length > MAX_REDIRECT_URI_LENGTH) return undefined; + try { + const url = new URL(value); + return url.protocol === "http:" && + LOOPBACK_HOSTNAMES.has(url.hostname) && + url.username === "" && + url.password === "" && + url.hash === "" + ? url + : undefined; + } catch { + return undefined; + } +}; + +/** RFC 8252 §7.3: loopback redirects match on everything but the port. */ +export const loopbackRedirectMatches = (registered: string, presented: string): boolean => { + const left = parseLoopbackRedirect(registered); + const right = parseLoopbackRedirect(presented); + return ( + left !== undefined && + right !== undefined && + left.hostname === right.hostname && + left.pathname === right.pathname && + left.search === right.search + ); +}; + +const sameResource = (urls: McpOAuthUrls, presented: string | undefined): boolean => { + if (presented === undefined) return true; + try { + const url = new URL(presented); + return url.hash === "" && `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource; + } catch { + return false; + } +}; + +const ClientIdPayload = Schema.Struct({ + v: Schema.Literal(1), + n: Schema.String, + r: Schema.Array(Schema.String), +}); +const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload)); + +export interface McpOAuthClient { + readonly clientId: string; + readonly name: string; + readonly redirectUris: ReadonlyArray; +} + +/** Problems the authorize page shows the user without redirecting anywhere. */ +export class McpOAuthPageError extends Schema.TaggedError()( + "McpOAuthPageError", + { description: Schema.String }, +) { + override get message(): string { + return this.description; + } +} + +/** Problems reported back to the client through its (validated) redirect URI. */ +export class McpOAuthRedirectError extends Schema.TaggedError()( + "McpOAuthRedirectError", + { + error: Schema.Literals(["invalid_request", "unsupported_response_type", "invalid_target"]), + description: Schema.String, + redirectUri: Schema.String, + state: Schema.optional(Schema.String), + }, +) {} + +export interface AuthorizationRequest { + readonly client: McpOAuthClient; + readonly redirectUri: string; + readonly codeChallenge: string; + readonly state: string | undefined; + readonly resource: string; + readonly issuer: string; +} + +interface PendingCode { + readonly clientId: string; + readonly clientName: string; + readonly redirectUri: string; + readonly codeChallenge: string; + readonly resource: string; + readonly access: AuthMcpClientAccess; + readonly expiresAtMs: number; +} + +export type ApprovalDecision = Exclude; + +export class McpOAuth extends Context.Service< + McpOAuth, + { + readonly register: ( + input: AuthMcpClientRegistration, + ) => Effect.Effect; + /** Validates an authorize request. Page errors must never redirect. */ + readonly validateAuthorization: (input: { + readonly urls: McpOAuthUrls; + readonly request: AuthMcpAuthorizationRequest; + }) => Effect.Effect; + /** + * The signed-in owner on this origin, when their browser session may + * approve at least read-only access. `approve` checks the chosen access + * against the session's scopes. + */ + readonly approvingBrowserSession: ( + request: HttpServerRequest.HttpServerRequest, + authorization: AuthorizationRequest, + ) => Effect.Effect< + | { readonly csrfToken: string; readonly scopes: ReadonlyArray } + | undefined + >; + /** Approves and returns the URL to send the browser to. */ + readonly approve: (input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly authorization: AuthorizationRequest; + readonly decision: ApprovalDecision; + }) => Effect.Effect; + readonly deny: (authorization: AuthorizationRequest) => string; + readonly exchangeCode: (input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly urls: McpOAuthUrls; + readonly token: AuthMcpTokenRequest; + }) => Effect.Effect; + } +>()("t3/auth/McpOAuth") {} + +/** Appends OAuth response parameters, keeping any the client put in its redirect URI. */ +const redirectWith = (redirectUri: string, params: Record) => { + const url = new URL(redirectUri); + for (const [name, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(name, value); + } + return url.toString(); +}; + +export const redirectForError = (error: McpOAuthRedirectError, issuer: string) => + redirectWith(error.redirectUri, { + error: error.error, + error_description: error.description, + state: error.state, + iss: issuer, + }); + +const make = Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const signingKey = yield* secretStore + .getOrCreateRandom(SIGNING_SECRET_NAME, 32) + .pipe(Effect.orDie); + const codes = yield* Ref.make>(new Map()); + + const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey); + + const signClientId = (name: string, redirectUris: ReadonlyArray) => { + const body = base64UrlEncode(JSON.stringify({ v: 1, n: name, r: redirectUris })); + return `${body}.${sign("mcp-client-id", body)}`; + }; + + const parseClientId = (clientId: string): McpOAuthClient | undefined => { + const [body, signature, extra] = clientId.split("."); + if (!body || !signature || extra !== undefined) return undefined; + if (!timingSafeEqualBase64Url(signature, sign("mcp-client-id", body))) return undefined; + let json: string; + try { + json = base64UrlDecodeUtf8(body); + } catch { + return undefined; + } + return Option.getOrUndefined( + decodeClientIdPayload(json).pipe( + Option.map((payload) => ({ clientId, name: payload.n, redirectUris: payload.r })), + ), + ); + }; + + const pkceVerifies = (verifier: string, challenge: string) => + CODE_VERIFIER_PATTERN.test(verifier) + ? crypto.digest("SHA-256", new TextEncoder().encode(verifier)).pipe( + Effect.orDie, + Effect.map((digest) => timingSafeEqualBase64Url(base64UrlEncode(digest), challenge)), + ) + : Effect.succeed(false); + + const csrfToken = (sessionId: string, authorization: AuthorizationRequest) => + sign( + "mcp-csrf", + JSON.stringify([ + sessionId, + authorization.client.clientId, + authorization.redirectUri, + authorization.codeChallenge, + ]), + ); + + const register: McpOAuth["Service"]["register"] = (metadata) => + Effect.gen(function* () { + const rawName = metadata.client_name?.trim() ?? ""; + const name = (rawName.length > 0 ? rawName : DEFAULT_CLIENT_NAME).slice( + 0, + MAX_CLIENT_NAME_LENGTH, + ); + const redirectUris = metadata.redirect_uris ?? []; + if (redirectUris.length === 0 || redirectUris.length > MAX_REDIRECT_URIS) { + return yield* new AuthMcpRegistrationError({ + error: "invalid_redirect_uri", + error_description: `Register between 1 and ${MAX_REDIRECT_URIS} redirect URIs.`, + }); + } + if (!redirectUris.every((uri) => parseLoopbackRedirect(uri) !== undefined)) { + return yield* new AuthMcpRegistrationError({ + error: "invalid_redirect_uri", + error_description: + "Only http://localhost, 127.0.0.1 or [::1] redirect URIs are accepted.", + }); + } + if ( + metadata.token_endpoint_auth_method !== undefined && + metadata.token_endpoint_auth_method !== "none" + ) { + return yield* new AuthMcpRegistrationError({ + error: "invalid_client_metadata", + error_description: "Only public clients (token_endpoint_auth_method none) are supported.", + }); + } + // Requested grant types and scopes are ignored rather than rejected: RFC 7591 lets + // the server answer with what it supports, and Claude Code asks for refresh_token. + return { clientId: signClientId(name, redirectUris), name, redirectUris }; + }); + + const validateAuthorization: McpOAuth["Service"]["validateAuthorization"] = ({ urls, request }) => + Effect.gen(function* () { + const client = parseClientId(request.client_id ?? ""); + if (client === undefined) { + return yield* new McpOAuthPageError({ + description: "This sign-in link names an unknown app. Start the sign-in again from it.", + }); + } + const redirectUri = request.redirect_uri; + if ( + redirectUri === undefined || + parseLoopbackRedirect(redirectUri) === undefined || + !client.redirectUris.some((registered) => loopbackRedirectMatches(registered, redirectUri)) + ) { + return yield* new McpOAuthPageError({ + description: "This sign-in link sends you to an address the app did not register.", + }); + } + const state = request.state; + const fail = (error: McpOAuthRedirectError["error"], description: string) => + new McpOAuthRedirectError({ + error, + description, + redirectUri, + ...(state === undefined ? {} : { state }), + }); + if (request.response_type !== "code") { + return yield* fail("unsupported_response_type", "Only response_type=code is supported."); + } + const codeChallenge = request.code_challenge; + if ( + request.code_challenge_method !== "S256" || + codeChallenge === undefined || + !CODE_CHALLENGE_PATTERN.test(codeChallenge) + ) { + return yield* fail("invalid_request", "PKCE with code_challenge_method=S256 is required."); + } + if (!sameResource(urls, request.resource)) { + return yield* fail( + "invalid_target", + `This server only issues tokens for ${urls.resource}.`, + ); + } + return { + client, + redirectUri, + codeChallenge, + state, + resource: urls.resource, + issuer: urls.issuer, + } satisfies AuthorizationRequest; + }); + + const approvingBrowserSession: McpOAuth["Service"]["approvingBrowserSession"] = ( + request, + authorization, + ) => + environmentAuth.authenticateBrowserSession(request).pipe( + Effect.map((session) => + // Approving manages access, and a session may only hand out scopes it holds. + session.scopes.includes(AuthAccessWriteScope) && + session.scopes.includes(AuthOrchestrationReadScope) + ? { csrfToken: csrfToken(session.sessionId, authorization), scopes: session.scopes } + : undefined, + ), + Effect.orElseSucceed(() => undefined), + ); + + const mintCode = (authorization: AuthorizationRequest, access: AuthMcpClientAccess) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const code = Buffer.from(yield* crypto.randomBytes(32).pipe(Effect.orDie)).toString( + "base64url", + ); + yield* Ref.update(codes, (current) => { + const next = new Map( + Array.from(current).filter(([, pending]) => pending.expiresAtMs > now), + ); + next.set(code, { + clientId: authorization.client.clientId, + clientName: authorization.client.name, + redirectUri: authorization.redirectUri, + codeChallenge: authorization.codeChallenge, + resource: authorization.resource, + access, + expiresAtMs: now + AUTHORIZATION_CODE_TTL_MS, + }); + return next; + }); + return code; + }); + + const approve: McpOAuth["Service"]["approve"] = (input) => + Effect.gen(function* () { + const { decision } = input; + if (decision._tag === "pairing-code") { + yield* environmentAuth.consumeMcpApprovalCode(decision.code, decision.access).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (cause) => + Effect.logError("MCP approval code check failed.", { cause }).pipe( + Effect.andThen( + Effect.fail( + new McpOAuthPageError({ + description: "The code could not be checked. Try again.", + }), + ), + ), + ), + ), + ); + } else { + const session = yield* approvingBrowserSession(input.request, input.authorization); + if ( + session === undefined || + !timingSafeEqualBase64Url(decision.csrfToken, session.csrfToken) || + !EnvironmentAuth.mcpClientScopes(decision.access).every((scope) => + session.scopes.includes(scope), + ) + ) { + return yield* new McpOAuthPageError({ + description: "Your session cannot approve this request. Enter a pairing code instead.", + }); + } + } + const code = yield* mintCode(input.authorization, decision.access); + yield* Effect.logInfo("Approved an MCP client sign-in.", { + client: input.authorization.client.name, + access: decision.access, + method: decision._tag, + }); + return redirectWith(input.authorization.redirectUri, { + code, + state: input.authorization.state, + iss: input.authorization.issuer, + }); + }); + + const deny: McpOAuth["Service"]["deny"] = (authorization) => + redirectWith(authorization.redirectUri, { + error: "access_denied", + state: authorization.state, + iss: authorization.issuer, + }); + + const exchangeCode: McpOAuth["Service"]["exchangeCode"] = ({ request, urls, token }) => + Effect.gen(function* () { + const fail = (error: AuthMcpTokenError["error"], description: string) => + new AuthMcpTokenError({ error, error_description: description }); + if (token.grant_type !== "authorization_code") { + return yield* fail("unsupported_grant_type", "Only authorization_code is supported."); + } + const { code, redirect_uri: redirectUri, client_id: clientId } = token; + const verifier = token.code_verifier; + if (!code || !redirectUri || !clientId || !verifier) { + return yield* fail( + "invalid_request", + "code, redirect_uri, client_id and code_verifier are required.", + ); + } + if (parseClientId(clientId) === undefined) { + return yield* fail("invalid_client", "The client is unknown."); + } + // A presented code is spent even when a later check fails (RFC 6749 §4.1.2). + const pending = yield* Ref.modify(codes, (current) => { + const found = current.get(code); + if (found === undefined) return [undefined, current] as const; + const next = new Map(current); + next.delete(code); + return [found, next] as const; + }); + const now = yield* Clock.currentTimeMillis; + if ( + pending === undefined || + pending.expiresAtMs <= now || + pending.clientId !== clientId || + pending.redirectUri !== redirectUri || + pending.resource !== urls.resource || + !sameResource(urls, token.resource) || + !(yield* pkceVerifies(verifier, pending.codeChallenge)) + ) { + return yield* fail("invalid_grant", "The authorization code is invalid or expired."); + } + const issued = yield* environmentAuth + .issueMcpClientSession({ + label: pending.clientName, + access: pending.access, + client: deriveAuthClientMetadata({ request }), + }) + .pipe( + Effect.catch((cause) => + Effect.logError("Could not issue an MCP client session.", { cause }).pipe( + Effect.andThen( + Effect.fail(fail("invalid_grant", "The session could not be issued.")), + ), + ), + ), + ); + return { + access_token: issued.token, + token_type: "Bearer", + expires_in: Math.max(0, Math.floor((issued.expiresAt.epochMilliseconds - now) / 1000)), + scope: encodeOAuthScope(EnvironmentAuth.mcpClientScopes(pending.access)), + }; + }); + + return McpOAuth.of({ + register, + validateAuthorization, + approvingBrowserSession, + approve, + deny, + exchangeCode, + }); +}); + +export const layer = Layer.effect(McpOAuth, make); + +/** + * Lets `/mcp` admit OAuth clients. Their scope has no calling thread, and + * never carries preview or device access: those tools act on the caller's + * own thread. + */ +export const layerMcpClientAuthenticator = Layer.effect( + McpHttpServer.McpClientAuthenticator, + Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const environment = yield* ServerEnvironment.ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + return McpHttpServer.McpClientAuthenticator.of({ + authenticate: (request) => + environmentAuth.authenticateMcpClient(request).pipe( + Effect.flatMap((client) => + Clock.currentTimeMillis.pipe( + Effect.map((issuedAt): McpInvocationContext.McpInvocationScope => ({ + environmentId, + requestNamespace: `client:${client.sessionId}`, + thread: undefined, + client: { + sessionId: client.sessionId, + label: client.label, + access: client.access, + }, + capabilities: new Set([ + "orchestration", + "worktree", + "pull-requests", + ]), + issuedAt, + })), + ), + ), + Effect.catch((error) => + (EnvironmentAuth.isServerAuthCredentialError(error) + ? Effect.void + : Effect.logWarning("MCP client authentication failed.", { cause: error }) + ).pipe(Effect.as(undefined)), + ), + ), + }); + }), +); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 0ca9fcfc9290..706e46120e59 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -276,6 +276,20 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); }).pipe(Effect.provide(layerSessionStore())), ); + it.effect("carries a runtime-mode ceiling only on sessions issued with one", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const capped = yield* sessions.issue({ + subject: "mcp-client", + method: "bearer-access-token", + runtimeModeCeiling: "auto", + }); + const uncapped = yield* sessions.issue({ method: "bearer-access-token" }); + + expect((yield* sessions.verify(capped.token)).runtimeModeCeiling).toBe("auto"); + expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined(); + }).pipe(Effect.provide(layerSessionStore())), + ); it.effect("rejects malformed session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 1526dd6705d7..a431b4406938 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -7,6 +7,7 @@ import { type AuthClientSession, type AuthEnvironmentScope, type ClientSurface, + RuntimeMode, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -58,6 +59,8 @@ export interface VerifiedSession { readonly subject: string; readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; + /** The most an MCP client approved through OAuth may hand to the threads it drives. */ + readonly runtimeModeCeiling?: RuntimeMode; } export type SessionCredentialChange = @@ -374,6 +377,7 @@ export class SessionStore extends Context.Service< readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; readonly proofKeyThumbprint?: string; + readonly runtimeModeCeiling?: RuntimeMode; /** * Atomically revoke active sessions with the same subject and method * before storing this session. @@ -430,6 +434,7 @@ const SessionClaims = Schema.Struct({ scopes: AuthEnvironmentScopes, method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), jkt: Schema.optionalKey(Schema.String), + rtc: Schema.optionalKey(RuntimeMode), iat: Schema.Number, exp: Schema.Number, }); @@ -665,6 +670,7 @@ export const make = Effect.gen(function* () { scopes: input?.scopes ?? AuthStandardClientScopes, method: input?.method ?? "browser-session-cookie", ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), + ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}), iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; @@ -839,6 +845,7 @@ export const make = Effect.gen(function* () { subject: claims.sub, scopes: claims.scopes, ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), + ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), } satisfies VerifiedSession; }, ); diff --git a/apps/server/src/auth/mcpOAuthHtml.ts b/apps/server/src/auth/mcpOAuthHtml.ts new file mode 100644 index 000000000000..39e79b66b0d7 --- /dev/null +++ b/apps/server/src/auth/mcpOAuthHtml.ts @@ -0,0 +1,46 @@ +/** + * The page for a sign-in link that cannot be trusted (unknown client or + * unregistered redirect). It is plain server HTML so it never depends on the + * web app, and it must not redirect anywhere. + */ +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/gu, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); + +const STYLES = ` + :root { color-scheme: light dark; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; background: #f6f7f9; color: #17191f; } + main { width: min(100%, 440px); } + h1 { margin: 0 0 8px; font-size: 20px; } + p { margin: 0 0 12px; line-height: 1.5; color: #4b5060; } + @media (prefers-color-scheme: dark) { + body { background: #0f1115; color: #e6e8ee; } + p { color: #a0a6b4; } + } +`; + +const shell = (title: string, body: string) => ` + + + + + + ${escapeHtml(title)} + + + +

    ${body}
    + +`; + +export function renderErrorPage(description: string): string { + return shell( + "Sign-in failed", + `

    This sign-in cannot continue

    +

    ${escapeHtml(description)}

    +

    Close this page and start the sign-in again from your agent.

    `, + ); +} diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts new file mode 100644 index 000000000000..c99a1cde2f38 --- /dev/null +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -0,0 +1,159 @@ +import { + AuthMcpApprovalError, + type AuthMcpAuthorizationRequest, + AuthMcpClientAccess, + EnvironmentHttpApi, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import { HttpServerRequest, HttpServerResponse } from "effect/http"; +import * as HttpEffect from "effect/http/HttpEffect"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as McpOAuth from "./McpOAuth.ts"; +import { renderErrorPage } from "./mcpOAuthHtml.ts"; + +/** Where the approval page lives in the web app. */ +const APPROVAL_PAGE_PATH = "/connect-agent"; + +const PAGE_HEADERS = { + "content-security-policy": + "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'", + "x-frame-options": "DENY", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", +}; + +/** OAuth responses carry codes, tokens and client ids, none of which may be cached. */ +const noStore = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed( + HttpServerResponse.setHeaders(response, { "cache-control": "no-store", pragma: "no-cache" }), + ), +); + +/** Issuer and resource for the origin this request reached. */ +const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.requestUrls); + +/** + * Validates the request the approval page forwards. An unverified client or + * redirect is reported, never redirected; any other problem goes back to the + * agent through its (verified) redirect URI. + */ +const forwardedAuthorization = ( + oauth: McpOAuth.McpOAuth["Service"], + request: AuthMcpAuthorizationRequest, +) => + Effect.gen(function* () { + const urls = yield* requestUrls; + return yield* oauth.validateAuthorization({ urls, request }).pipe( + Effect.catchTags({ + McpOAuthPageError: (error) => + Effect.fail(new AuthMcpApprovalError({ message: error.description })), + McpOAuthRedirectError: (error) => + Effect.succeed({ redirectTo: McpOAuth.redirectForError(error, urls.issuer) }), + }), + ); + }); + +export const layer = HttpApiBuilder.group( + EnvironmentHttpApi, + "mcpOAuth", + Effect.fnUntraced(function* (handlers) { + const oauth = yield* McpOAuth.McpOAuth; + const protectedResource = () => + requestUrls.pipe(Effect.map(McpOAuth.protectedResourceMetadata)); + + return ( + handlers + .handle("protectedResource", protectedResource) + .handle("mcpProtectedResource", protectedResource) + .handle("authorizationServer", () => + requestUrls.pipe(Effect.map(McpOAuth.authorizationServerMetadata)), + ) + .handle("register", ({ payload }) => + oauth.register(payload).pipe( + Effect.map((client) => ({ + client_id: client.clientId, + client_name: client.name, + redirect_uris: client.redirectUris, + grant_types: ["authorization_code" as const], + response_types: ["code" as const], + token_endpoint_auth_method: "none" as const, + })), + Effect.tap(() => noStore), + ), + ) + // The browser lands here from the agent. A valid request is handed to the + // web app's approval page with its query intact. + .handleRaw("authorize", ({ request }) => + Effect.gen(function* () { + const urls = yield* requestUrls; + const url = new URL(request.url, urls.issuer); + const authorization = yield* oauth + .validateAuthorization({ urls, request: Object.fromEntries(url.searchParams) }) + .pipe(Effect.result); + const headers = { "cache-control": "no-store", "referrer-policy": "no-referrer" }; + if (Result.isSuccess(authorization)) { + return HttpServerResponse.redirect(`${APPROVAL_PAGE_PATH}${url.search}`, { headers }); + } + const error = authorization.failure; + return error._tag === "McpOAuthPageError" + ? HttpServerResponse.text(renderErrorPage(error.description), { + status: 400, + contentType: "text/html; charset=utf-8", + headers: PAGE_HEADERS, + }) + : HttpServerResponse.redirect(McpOAuth.redirectForError(error, urls.issuer), { + headers, + }); + }), + ) + // What the approval page shows, and whether one click may approve. + .handle("approval", ({ payload, request }) => + Effect.gen(function* () { + const resolved = yield* forwardedAuthorization(oauth, payload); + if ("redirectTo" in resolved) return resolved; + const urls = yield* requestUrls; + const session = yield* oauth.approvingBrowserSession(request, resolved); + yield* noStore; + return { + clientName: resolved.client.name, + redirectHost: new URL(resolved.redirectUri).host, + environmentHost: new URL(urls.issuer).host, + ...(session === undefined + ? {} + : { + csrfToken: session.csrfToken, + oneClickAccess: AuthMcpClientAccess.literals.filter((access) => + EnvironmentAuth.mcpClientScopes(access).every((scope) => + session.scopes.includes(scope), + ), + ), + }), + }; + }), + ) + .handle("decision", ({ payload, request }) => + Effect.gen(function* () { + const resolved = yield* forwardedAuthorization(oauth, payload.authorization); + if ("redirectTo" in resolved) return resolved; + yield* noStore; + const { decision } = payload; + if (decision._tag === "deny") return { redirectTo: oauth.deny(resolved) }; + return yield* oauth.approve({ request, authorization: resolved, decision }).pipe( + Effect.map((redirectTo) => ({ redirectTo })), + Effect.mapError((error) => new AuthMcpApprovalError({ message: error.message })), + ); + }), + ) + .handle("token", ({ payload, request }) => + Effect.gen(function* () { + yield* noStore; + return yield* oauth.exchangeCode({ request, urls: yield* requestUrls, token: payload }); + }), + ) + ); + }), +); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index e7bcbdea8e5e..3505e4e58633 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -3,7 +3,13 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + OrchestratorMcpFailure, + PreviewTabId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -11,14 +17,16 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { McpProtocol, McpSchema, McpServer } from "effect/ai"; +import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/http"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpToolAccess from "./McpToolAccess.ts"; import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const environmentId = EnvironmentId.make("environment-mcp-test"); @@ -356,6 +364,28 @@ it.effect.each([ ).pipe(Effect.provide(layerTest)), ); +it.effect("refuses preview tools to a client outside a thread before they run", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const readOnly = { + ...invocation, + thread: undefined, + requestNamespace: "client:session-1", + client: { sessionId: "session-1", label: "Claude Code", access: "read-only" as const }, + }; + const click = yield* server + .callTool({ name: "preview_click", arguments: { locator: "text=Send" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, readOnly), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(click.isError).toBe(true); + expect(click.content).toEqual([ + { type: "text", text: expect.stringContaining("needs an agent running inside T3 Code") }, + ]); + }).pipe(Effect.provide(layerTest)), +); + it.effect("rejects non-boolean snapshot image options before selecting a browser host", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; @@ -916,3 +946,124 @@ it.effect("registers annotated tools and preserves authenticated request context }), ).pipe(Effect.provide(layerTest)), ); + +it.effect("admits provider and OAuth client credentials and points only clients at OAuth", () => + Effect.scoped( + Effect.gen(function* () { + const providerToken = "providerTokenWithoutDots"; + const clientToken = "client-payload.client-signature"; + const providerScope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "provider-session", + thread: { + threadId: ThreadId.make("thread-provider"), + providerSessionId: "provider-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const clientScope: McpInvocationContext.McpInvocationScope = { + environmentId, + requestNamespace: "client:session-1", + thread: undefined, + client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const seen: Array = []; + const ProbeToolkit = Toolkit.make( + Tool.make("probe", { + description: "Reports the caller.", + success: Schema.Struct({ ok: Schema.Boolean }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [McpInvocationContext.McpInvocationContext], + }), + ); + const serverLayer = McpHttpServer.toolkitRegistration( + ProbeToolkit, + McpToolAccess.toLayer(ProbeToolkit, { + probe: McpToolAccess.reads(() => + McpInvocationContext.McpInvocationContext.pipe( + Effect.tap((scope) => Effect.sync(() => seen.push(scope))), + Effect.as({ ok: true }), + ), + ), + }), + ).pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (token) => + Effect.succeed( + token === providerToken + ? (providerScope as McpInvocationContext.McpThreadInvocationScope) + : undefined, + ), + }), + ), + Layer.provide( + Layer.succeed(McpHttpServer.McpClientAuthenticator, { + authenticate: (request) => + Effect.succeed( + request.headers.authorization === `Bearer ${clientToken}` ? clientScope : undefined, + ), + }), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const call = (token: string | undefined) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + if (initialize.status !== 200) return initialize; + return yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"probe","arguments":{}}}`, + "application/json", + ), + }); + }); + + expect((yield* call(providerToken)).status).toBe(200); + expect((yield* call(clientToken)).status).toBe(200); + expect(seen.map((scope) => scope.requestNamespace)).toEqual([ + "provider-session", + "client:session-1", + ]); + + const missing = yield* call(undefined); + expect(missing.status).toBe(401); + expect(missing.headers["www-authenticate"]).toMatch( + /^Bearer resource_metadata="http:\/\/[^"]+\/\.well-known\/oauth-protected-resource\/mcp"$/, + ); + + const expiredClient = yield* call("stale-payload.stale-signature"); + expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata="); + expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"'); + + const deadProvider = yield* call("deadProviderToken"); + expect(deadProvider.status).toBe(401); + expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"'); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 79f7f6079dad..b63cd7b95d7e 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -58,19 +58,62 @@ import { import * as HtmlHandlers from "./toolkits/html/handlers.ts"; import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; -const unauthorized = HttpServerResponse.jsonUnsafe( - { - error: "invalid_mcp_credential", - message: "A valid provider-scoped MCP bearer credential is required.", - }, - { - status: 401, - headers: { - "cache-control": "no-store", - "www-authenticate": "Bearer", +/** Where an MCP client discovers how to sign in (RFC 9728), at this request's own origin. */ +const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => + HttpServerRequest.toURL(request).pipe( + Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), + ); + +/** + * Agents T3 Code launched carry a registry token and must never be sent into + * an OAuth flow when it dies: they cannot open a browser, and a sign-in + * would mint a credential that outlives their session. Only a request that + * does not look like a provider token is pointed at the OAuth metadata. + */ +const unauthorized = (input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly presentedToken: boolean; + readonly offerOAuth: boolean; +}) => { + const metadataUrl = input.offerOAuth + ? Option.getOrUndefined(mcpResourceMetadataUrl(input.request)) + : undefined; + const challenge = [ + "Bearer", + [ + ...(metadataUrl === undefined ? [] : [`resource_metadata="${metadataUrl}"`]), + ...(input.presentedToken ? ['error="invalid_token"'] : []), + ].join(", "), + ] + .filter((part) => part.length > 0) + .join(" "); + return HttpServerResponse.jsonUnsafe( + { + error: "invalid_mcp_credential", + message: "A valid T3 Code MCP credential is required.", }, - }, -); + { + status: 401, + headers: { + "cache-control": "no-store", + "www-authenticate": challenge, + }, + }, + ); +}; + +/** + * Resolves a bearer token that is not a provider-session token: an OAuth + * client signed in from outside T3. Undefined when the token is not one. + */ +export class McpClientAuthenticator extends Context.Service< + McpClientAuthenticator, + { + readonly authenticate: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + } +>()("t3/mcp/McpHttpServer/McpClientAuthenticator") {} type AuthenticatedHttpEffect = Effect.Effect< HttpServerResponse.HttpServerResponse, @@ -98,33 +141,43 @@ export const normalizeMcpHttpResponse = ( : response; }; -const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( - Effect.map((registry): McpAuthMiddleware => - Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { - const request = yield* HttpServerRequest.HttpServerRequest; - const authorization = request.headers.authorization; - const token = - authorization?.startsWith("Bearer ") === true - ? authorization.slice("Bearer ".length).trim() - : ""; - const invocation = yield* registry.resolve(token); - if (!invocation) { - // Without this the only symptom of a dead credential is the agent - // quietly losing the whole `t3-code` toolkit for the rest of its - // session, with nothing on the server to explain why. - yield* Effect.logWarning("rejected MCP request with an unusable credential", { - reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", - }); - return unauthorized; - } - return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.map(normalizeMcpHttpResponse), - ); - }), - ), - Effect.withSpan("McpHttpServer.makeAuthMiddleware"), -); +// Session tokens are `.`; registry tokens are a bare base64url secret. +const looksLikeProviderToken = (token: string) => token.length > 0 && !token.includes("."); + +const makeMcpAuthMiddleware = Effect.gen(function* () { + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const clients = yield* Effect.serviceOption(McpClientAuthenticator); + return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { + const request = yield* HttpServerRequest.HttpServerRequest; + const authorization = request.headers.authorization; + const token = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = + (yield* registry.resolve(token)) ?? + (Option.isSome(clients) && token.length > 0 + ? yield* clients.value.authenticate(request) + : undefined); + if (!invocation) { + // Without this the only symptom of a dead credential is the agent + // quietly losing the whole `t3-code` toolkit for the rest of its + // session, with nothing on the server to explain why. + yield* Effect.logWarning("rejected MCP request with an unusable credential", { + reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", + }); + return unauthorized({ + request, + presentedToken: token.length > 0, + offerOAuth: Option.isSome(clients) && !looksLikeProviderToken(token), + }); + } + return yield* httpEffect.pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.map(normalizeMcpHttpResponse), + ); + }) satisfies McpAuthMiddleware; +}).pipe(Effect.withSpan("McpHttpServer.makeAuthMiddleware")); const layerMcpAuthMiddleware = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; @@ -787,7 +840,7 @@ export const layerDeviceToolkit = Layer.mergeAll( layerDeviceScreenshotRegistration, ); -const layerMcpTransport = McpServer.layerHttp({ +export const layerMcpTransport = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, path: "/mcp", diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 1daa9740f019..e3bc3face411 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -81,7 +81,7 @@ it.effect("refuses thread-owned capabilities to a caller signed in from outside environmentId: EnvironmentId.make("environment-1"), requestNamespace: "client:session-1", thread: undefined, - client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling: "auto" }, + client: { sessionId: "session-1", label: "Claude Code", access: "auto" }, capabilities: new Set(["preview", "orchestration"]), issuedAt: 1, }; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 01906c1b44f6..c53dac6695c0 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,4 +1,5 @@ import { + type AuthMcpClientAccess, type EnvironmentId, McpCapabilityUnavailableError, OrchestratorMcpFailure, @@ -30,9 +31,18 @@ export interface McpThreadCaller { export interface McpClientCaller { readonly sessionId: string; readonly label: string; - readonly runtimeModeCeiling: RuntimeMode; + /** Read only, or the most the threads it starts or changes may run with. */ + readonly access: AuthMcpClientAccess; } +/** + * The runtime mode a client caller's writes are capped at. A read-only client + * never reaches a write (`McpToolAccess` refuses it first), so it maps to the + * lowest mode rather than to nothing. + */ +export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => + client === undefined || client.access === "read-only" ? "approval-required" : client.access; + /** * Who is calling and what they may do. Tool parameters choose the target * (thread, project); the caller sets the limits. A thread caller's omitted diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index baffc00659eb..b29d81a4948e 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -66,7 +66,7 @@ const supervisedClient: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment"), requestNamespace: "client:race", thread: undefined, - client: { sessionId: "race", label: "Claude Code", runtimeModeCeiling: "approval-required" }, + client: { sessionId: "race", label: "Claude Code", access: "approval-required" }, capabilities: new Set(["orchestration"]), issuedAt: 0, }; diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 3139faf27d54..04ac576b0679 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -48,12 +48,12 @@ const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationSco }); const clientCaller = ( - runtimeModeCeiling: RuntimeMode, + access: McpInvocationContext.McpClientCaller["access"], ): McpInvocationContext.McpInvocationScope => ({ environmentId: EnvironmentId.make("environment"), requestNamespace: "client:session", thread: undefined, - client: { sessionId: "session", label: "Claude Code", runtimeModeCeiling }, + client: { sessionId: "session", label: "Claude Code", access }, capabilities: new Set(["orchestration"]), issuedAt: 0, }); @@ -193,6 +193,7 @@ const fullAccess = threadCaller(fullAccessThreadId); const ended = threadCaller(endedThreadId); const supervisedClient = clientCaller("approval-required"); const fullAccessClient = clientCaller("full-access"); +const readOnlyClient = clientCaller("read-only"); // A live full-access thread whose credential may use its browser but not control threads. const previewOnly = { ...fullAccess, capabilities: new Set(["preview"] as const) }; @@ -200,6 +201,13 @@ it.effect.each([ // Reads are open to every caller, even one whose turn ended. ["reads", ended, {}, "ran"], ["reads", supervisedClient, {}, "ran"], + ["reads", readOnlyClient, {}, "ran"], + + // A client approved for read-only access changes nothing. + ["writes", readOnlyClient, {}, "capability_denied"], + ["writes_threads", readOnlyClient, { threadId: supervisedThreadId }, "capability_denied"], + ["starts_threads", readOnlyClient, {}, "capability_denied"], + ["writes_environment", readOnlyClient, {}, "capability_denied"], // What belongs to the calling thread needs one; changing it needs its live turn. ["reads_as_caller", ended, {}, "ran"], diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b6c623a11901..6e882629bbba 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -97,15 +97,36 @@ export class Declaration { } } +/** A client approved for read-only access changes nothing. */ +const refuseReadOnlyClient = McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => + scope.client?.access === "read-only" + ? Effect.fail( + new OrchestratorMcpFailure({ + code: "capability_denied", + message: + "This tool changes the environment, and this MCP client was approved for read-only access.", + }), + ) + : Effect.void, + ), +); + /** * The caller of a tool that changes something. Tools that act for a * capability of their own (preview, device, worktree, pull requests) check it * themselves. */ -const writingCaller = loadCaller().pipe(Effect.tap(assertLiveCaller)); +const writingCaller = refuseReadOnlyClient.pipe( + Effect.andThen(loadCaller()), + Effect.tap(assertLiveCaller), +); /** The same, for tools whose only capability is controlling threads. */ -const orchestratingCaller = readCaller().pipe(Effect.tap(assertLiveCaller)); +const orchestratingCaller = refuseReadOnlyClient.pipe( + Effect.andThen(readCaller()), + Effect.tap(assertLiveCaller), +); const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index dfad03f9974f..18b444b85177 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1603,7 +1603,7 @@ describe("OrchestratorMcpService provider resolution", () => { client: { sessionId: "scheduled", label: "Claude Code", - runtimeModeCeiling: "approval-required", + access: "approval-required", }, capabilities: new Set(["orchestration"]), issuedAt: 1, @@ -1709,6 +1709,25 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("never shows a read-only client a webhook URL", () => + Effect.gen(function* () { + const upserted = yield* Ref.make(0); + const listed = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.flatMap((mcp) => + mcp.listScheduledTasks( + { + ...supervisedClient, + client: { sessionId: "scheduled", label: "Claude Code", access: "read-only" }, + }, + { projectId }, + ), + ), + Effect.provide(service([task({})], null, upserted)), + ); + assert.equal(listed.tasks[0]?.webhookUrl, undefined); + }), + ); + it.effect("checks a bound task against its thread's current modes", () => Effect.gen(function* () { const upserted = yield* Ref.make(0); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index dc826af06864..3d4059d70cef 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -84,6 +84,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import { + clientRuntimeModeCeiling, type McpInvocationScope, type McpThreadInvocationScope, requireThreadScope, @@ -908,7 +909,7 @@ const make = Effect.gen(function* () { return { parent: undefined, limits: { - runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", + runtimeMode: clientRuntimeModeCeiling(scope.client), interactionMode: "default", } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode }, } as const; @@ -1400,8 +1401,9 @@ const make = Effect.gen(function* () { /** * A task as the caller may see it. Its webhook URL starts runs, so only a - * caller that may start one sees it: a thread caller with a live turn, at - * modes covering every mode the task runs at. + * caller that may start one sees it: never a client approved for read-only + * access, and a thread caller only with a live turn, at modes covering every + * mode the task runs at. */ const summarizeScheduledTask = ( scope: McpInvocationScope, @@ -1416,8 +1418,9 @@ const make = Effect.gen(function* () { ) => Effect.gen(function* () { const live = - caller.parent === undefined || - Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); + caller.parent === undefined + ? scope.client?.access !== "read-only" + : Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); // This runs after a save, so a failed lookup of the bound thread hides // the webhook URL instead of reporting a saved task as an error. const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 115bf8b9f967..239af1880a5a 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -76,7 +76,7 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { threads, caller: undefined, limits: { - runtimeMode: scope.client?.runtimeModeCeiling ?? "approval-required", + runtimeMode: McpInvocationContext.clientRuntimeModeCeiling(scope.client), interactionMode: "default", }, } satisfies Caller; diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index f5674a06c374..634133b62e9e 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -396,12 +396,12 @@ it.effect("resolves reused attachment references from stored metadata", () => ); const clientScope = ( - runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access", + access: McpInvocationContext.McpClientCaller["access"], ): McpInvocationContext.McpInvocationScope => ({ environmentId: EnvironmentId.make("mcp-core-environment"), requestNamespace: "client:session-1", thread: undefined, - client: { sessionId: "session-1", label: "Claude Code", runtimeModeCeiling }, + client: { sessionId: "session-1", label: "Claude Code", access }, issuedAt: 0, capabilities: new Set(["orchestration", "worktree", "pull-requests"]), }); @@ -474,6 +474,76 @@ it.effect("a client caller targets any thread within its ceiling and cannot act ), ); +it.effect("a read-only client reads threads and is refused every write before it runs", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (name: string, args: Record) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + clientScope("read-only"), + ), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const configuration = yield* call("t3_thread_configuration", { + threadId: "other-project-thread", + }); + expect(configuration.isError).toBe(false); + expect(configuration.structuredContent).toMatchObject({ runtimeMode: "auto" }); + + const pinned = yield* call("t3_thread_organize", { + action: "pin", + threadId: "other-project-thread", + }); + expect(declaredFailure(pinned)).toMatchObject({ code: "capability_denied" }); + expect(dispatched).toEqual([]); + + const configure = yield* call("t3_thread_configure", { + threadId: "other-project-thread", + modelSelection: { instanceId: "codex", model: "gpt-5" }, + }); + expect(declaredFailure(configure)).toMatchObject({ code: "capability_denied" }); + expect(dispatched).toEqual([]); + }).pipe( + Effect.provide( + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + deletedAt: null, + } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("other-project-thread"), + projectId: "other-project", + modelSelection: { instanceId: "codex", model: "gpt-5" }, + runtimeMode: "auto", + interactionMode: "default", + deletedAt: null, + }, + } as never), + dispatch: () => + Effect.sync(() => { + dispatched.push("dispatch"); + return { sequence: 7 } as never; + }), + }), + ), + ), + ), + ), +); +const dispatched: Array = []; + it.effect("refuses act-as-caller tools to a client caller", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 64e96d949b7f..d657938ff206 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -313,7 +313,7 @@ const clientLaunchHarness = (input: { client: { sessionId: "session-1", label: "Claude Code", - runtimeModeCeiling: input.runtimeModeCeiling, + access: input.runtimeModeCeiling, }, issuedAt: 0, capabilities: new Set(["orchestration" as const]), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2579f1203cd6..ebbc73758a43 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -116,6 +116,8 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as WebhookRoute from "./scheduledTasks/webhookRoute.ts"; import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts"; import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts"; +import * as McpOAuth from "./auth/McpOAuth.ts"; +import * as McpOAuthHttp from "./auth/mcpOAuthHttp.ts"; import { relayHookBaseUrl, ScheduledTaskWebhookOrigin, @@ -654,6 +656,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( Layer.provide(AuthHttp.layer), + Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), Layer.provide(CloudHttp.layer), Layer.provide(OrchestrationHttp.layer), Layer.provide(PullRequestHttp.layer), @@ -677,6 +680,7 @@ const layerMakeRoutes = Layer.mergeAll( // what dispatch can actually serve. McpHttpServer.layer.pipe( Layer.provide(ProviderAdapterRegistry.layerFromProviderInstanceRegistry), + Layer.provide(McpOAuth.layerMcpClientAuthenticator), ), ).pipe( // Both transports consume the same service instance, so caches single-flight across clients diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx new file mode 100644 index 000000000000..926a03b16351 --- /dev/null +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -0,0 +1,317 @@ +import { + type AuthMcpApprovalDecision, + type AuthMcpApprovalDetails, + AuthMcpApprovalError, + AuthMcpClientAccess, + type AuthMcpAuthorizationRequest, +} from "@t3tools/contracts"; +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { EyeIcon, type LucideIcon } from "lucide-react"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; + +import { PrimaryEnvironmentHttpClient } from "~/environments/primary/httpClient"; +import { runPrimaryHttp } from "~/lib/runtime"; +import { cn } from "~/lib/utils"; +import { runtimeModeConfig } from "../chat/runtimeModeConfig"; +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { RadioGroup } from "../ui/radio-group"; +import { Spinner } from "../ui/spinner"; +import { AuthSurfaceShell } from "./AuthSurfaceShell"; + +const accessConfig: Record< + AuthMcpClientAccess, + { readonly label: string; readonly description: string; readonly icon: LucideIcon } +> = { + "read-only": { + label: "Read only", + description: "Read projects and threads. Cannot start, message or change anything.", + icon: EyeIcon, + }, + ...runtimeModeConfig, +}; + +type Loaded = + | { readonly status: "loading" } + | { readonly status: "invalid"; readonly message: string } + | { readonly status: "ready"; readonly details: AuthMcpApprovalDetails }; + +const UNREACHABLE = "Could not reach this environment. Try again."; +const isApprovalError = Schema.is(AuthMcpApprovalError); + +type Answer
    = + | { readonly kind: "ok"; readonly value: A } + | { readonly kind: "redirect"; readonly url: string } + | { readonly kind: "error"; readonly message: string }; + +/** + * Runs an approval call. The server validates the agent's request again on + * every call and answers what the page asked for, a message to show, or a URL + * the browser must follow (an approval, a denial, or a protocol error the + * agent should receive). + */ +function runApproval( + call: ( + client: Context.Service.Shape, + ) => Effect.Effect, +): Promise> { + return runPrimaryHttp( + PrimaryEnvironmentHttpClient.pipe( + Effect.flatMap(call), + Effect.map((value): Answer => + typeof value === "object" && value !== null && "redirectTo" in value + ? { kind: "redirect", url: value.redirectTo } + : { kind: "ok", value: value as A }, + ), + Effect.catch((error) => + Effect.succeed>({ + kind: "error", + message: isApprovalError(error) ? error.message : UNREACHABLE, + }), + ), + ), + ).catch((): Answer => ({ kind: "error", message: UNREACHABLE })); +} + +function readAuthorizationRequest(): AuthMcpAuthorizationRequest { + return Object.fromEntries(new URL(window.location.href).searchParams); +} + +/** + * /connect-agent: where an outside agent's MCP sign-in lands after the server + * checks the request. The user picks the most the agent may allow, then + * approves with a pairing code, or in one click when this browser is already + * signed in to the environment as an administrator. + */ +/** + * Whether this browser's session may approve `access` without a pairing code. + * A session may only grant access whose scopes it holds itself. + */ +function oneClickApproves(details: AuthMcpApprovalDetails, access: AuthMcpClientAccess) { + return details.csrfToken !== undefined && (details.oneClickAccess ?? []).includes(access); +} + +export function ConnectAgentSurface() { + const [authorization] = useState(readAuthorizationRequest); + const [loaded, setLoaded] = useState({ status: "loading" }); + const [access, setAccess] = useState("read-only"); + const [pairingCode, setPairingCode] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [pending, setPending] = useState<"approve" | "deny" | null>(null); + + useEffect(() => { + let cancelled = false; + void runApproval((client) => client.mcpOAuth.approval({ payload: authorization })).then( + (answer) => { + if (cancelled) return; + if (answer.kind === "redirect") { + window.location.replace(answer.url); + return; + } + setLoaded( + answer.kind === "error" + ? { status: "invalid", message: answer.message } + : { status: "ready", details: answer.value }, + ); + }, + ); + return () => { + cancelled = true; + }; + }, [authorization]); + + const decide = useCallback( + async (choice: "approve" | "deny") => { + if (loaded.status !== "ready") return; + setPending(choice); + setErrorMessage(""); + const { csrfToken } = loaded.details; + const decision: AuthMcpApprovalDecision = + choice === "deny" + ? { _tag: "deny" } + : csrfToken !== undefined && oneClickApproves(loaded.details, access) + ? { _tag: "browser-session", access, csrfToken } + : { _tag: "pairing-code", access, code: pairingCode.trim() }; + const answer = await runApproval((client) => + client.mcpOAuth.decision({ payload: { authorization, decision } }), + ); + if (answer.kind === "redirect") { + window.location.replace(answer.url); + return; + } + setPending(null); + setErrorMessage(answer.kind === "error" ? answer.message : "The sign-in could not continue."); + }, + [access, authorization, loaded, pairingCode], + ); + + if (loaded.status === "loading") { + return ( + + + + + ); + } + + if (loaded.status === "invalid") { + return ( + + +

    + Close this page and start the sign-in again from your agent. +

    +
    + ); + } + + const { details } = loaded; + const oneClick = oneClickApproves(details, access); + const canApprove = pending === null && (oneClick || pairingCode.trim().length > 0); + + return ( + + + This agent wants to use the threads in every project on{" "} + {details.environmentHost}. + + } + /> +

    + The name is chosen by the agent. Approval returns to {details.redirectHost} on the computer + that opened this page. Only approve a sign-in you just started. +

    + +
    { + event.preventDefault(); + if (canApprove) void decide("approve"); + }} + > +
    + + What it may do + + setAccess(value as AuthMcpClientAccess)} + > + {AuthMcpClientAccess.literals.map((option) => ( + + ))} + +

    + Beyond read only, it can start, message and stop threads, and none of them can run with + more than the mode you pick. +

    +
    + + {oneClick ? null : ( +
    + + setPairingCode(event.currentTarget.value)} + placeholder="Paste a one-time pairing code" + spellCheck={false} + value={pairingCode} + /> +

    + Create one in Settings → Connections, or run t3 auth pairing create on + this machine. +

    +
    + )} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
    + + +
    +
    +
    + ); +} + +function ConnectAgentHeading({ + title, + description, +}: { + readonly title: string; + readonly description: ReactNode; +}) { + return ( + <> +

    Agent sign-in

    +

    {title}

    +

    {description}

    + + ); +} + +function AccessOption({ + access, + selected, +}: { + readonly access: AuthMcpClientAccess; + readonly selected: boolean; +}) { + const { label, description, icon: Icon } = accessConfig[access]; + return ( + + + + {label} + {description} + + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index e6fab42710fc..2c0b2285d77d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' +import { Route as ConnectAgentRouteImport } from './routes/connect-agent' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' @@ -55,6 +56,11 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) +const ConnectAgentRoute = ConnectAgentRouteImport.update({ + id: '/connect-agent', + path: '/connect-agent', + getParentRoute: () => rootRouteImport, +} as any) const ConnectRoute = ConnectRouteImport.update({ id: '/connect', path: '/connect', @@ -165,6 +171,7 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute + '/connect-agent': typeof ConnectAgentRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute @@ -190,6 +197,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/connect': typeof ConnectRoute + '/connect-agent': typeof ConnectAgentRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute @@ -218,6 +226,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute + '/connect-agent': typeof ConnectAgentRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute @@ -247,6 +256,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/connect' + | '/connect-agent' | '/pair' | '/settings' | '/usage' @@ -272,6 +282,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/connect' + | '/connect-agent' | '/pair' | '/settings' | '/usage' @@ -299,6 +310,7 @@ export interface FileRouteTypes { | '__root__' | '/_chat' | '/connect' + | '/connect-agent' | '/pair' | '/settings' | '/usage' @@ -327,6 +339,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute + ConnectAgentRoute: typeof ConnectAgentRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute @@ -364,6 +377,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } + '/connect-agent': { + id: '/connect-agent' + path: '/connect-agent' + fullPath: '/connect-agent' + preLoaderRoute: typeof ConnectAgentRouteImport + parentRoute: typeof rootRouteImport + } '/connect': { id: '/connect' path: '/connect' @@ -571,6 +591,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, + ConnectAgentRoute: ConnectAgentRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 438b8c5e7a1a..fff3e430091d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -161,7 +161,7 @@ function RootRouteView() { }; }, [pathname]); - if (pathname === "/pair" || pathname === "/connect") { + if (pathname === "/pair" || pathname === "/connect" || pathname === "/connect-agent") { return ( <> diff --git a/apps/web/src/routes/connect-agent.tsx b/apps/web/src/routes/connect-agent.tsx new file mode 100644 index 000000000000..dde798c32463 --- /dev/null +++ b/apps/web/src/routes/connect-agent.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConnectAgentSurface } from "../components/auth/ConnectAgentSurface"; + +export const Route = createFileRoute("/connect-agent")({ + component: ConnectAgentSurface, +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1d710f273cd0..fd60c4f2a397 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,7 +9,10 @@ import "vite-plus/test/config"; import { defineConfig, type Connect, type Plugin } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; -import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; +import { + DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES, + DEV_PROXIED_PATH_PREFIXES, +} from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; @@ -249,7 +252,7 @@ export default defineConfig(() => { prefix, { target: devProxyTarget, - changeOrigin: true, + changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix), ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), }, ]), diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 08802cbb202b..3acea00472e4 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -21,8 +21,35 @@ authority. Browser cookies, bearer tokens, and DPoP tokens adapt the same scoped session model. DPoP binds a token to a client's proof key; an invalid proof must fail rather than fall back to bearer authentication. The OAuth token-exchange -vocabulary gives these grants a familiar meaning, but the environment does not -implement a general-purpose OAuth authorization server. +vocabulary gives these grants a familiar meaning. + +### MCP clients are a separate audience + +Agents T3 Code did not launch sign in to `/mcp` through a narrow OAuth +authorization-code server ([McpOAuth](../../apps/server/src/auth/McpOAuth.ts)). +It accepts only loopback redirect URIs: an HTTPS redirect would let anyone send +the owner an approval link that delivers the code to their own server. Client +registration is stateless, so an unauthenticated caller cannot grow server +state. Approval spends a one-time pairing code, or uses a browser session with +`access:write`; proof-bound T3 Connect codes are refused without being spent. + +The user grants either read-only access or a runtime-mode ceiling, not a +scope list: MCP tools are all orchestration, and `orchestration:operate` +alone would let an agent start a thread in full access and act through it. +The result is an ordinary session with subject `mcp-client`. A read-only +grant holds `orchestration:read` alone. On `/mcp` it passes only tools +declared as reads in [McpToolAccess](../../apps/server/src/mcp/McpToolAccess.ts), +where every tool must declare who may call it to compile. Any other grant adds +`orchestration:operate` and a signed ceiling. Only `/mcp` accepts these sessions. Every other HTTP and WebSocket +path rejects that subject, because the RPC surface would let the agent act +above its ceiling. Inside MCP the credential sets the limits and tool +parameters only pick targets; see +[threadAccess](../../apps/server/src/mcp/threadAccess.ts). + +Issuer and resource URLs come from the request's Host and +`X-Forwarded-Proto`, so one server answers over loopback, Tailscale Serve and a +T3 Connect tunnel. A proxy that rewrites Host or drops the protocol header +breaks sign-in. Bearer and DPoP clients obtain short-lived WebSocket tickets through authenticated HTTP so long-lived tokens stay out of socket URLs. Browser sessions can diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 06a70184b3e8..886cd9add685 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -353,3 +353,183 @@ export const AuthSessionState = Schema.Struct({ expiresAt: Schema.optionalKey(Schema.DateTimeUtc), }); export type AuthSessionState = typeof AuthSessionState.Type; + +/** + * What an agent signed in through MCP OAuth may do, least to most: only read, + * or act on threads that never run above the given runtime mode. + */ +export const AuthMcpClientAccess = Schema.Literals([ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access", +]); +export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type; + +/** RFC 9728 metadata for an environment's `/mcp` resource. */ +export const AuthMcpProtectedResourceMetadata = Schema.Struct({ + resource: Schema.String, + authorization_servers: Schema.Array(Schema.String), + scopes_supported: Schema.Array(AuthEnvironmentScope), + bearer_methods_supported: Schema.Array(Schema.Literal("header")), + resource_name: Schema.String, +}); +export type AuthMcpProtectedResourceMetadata = typeof AuthMcpProtectedResourceMetadata.Type; + +/** RFC 8414 metadata for the authorization server MCP clients sign in through. */ +export const AuthMcpAuthorizationServerMetadata = Schema.Struct({ + issuer: Schema.String, + authorization_endpoint: Schema.String, + token_endpoint: Schema.String, + registration_endpoint: Schema.String, + response_types_supported: Schema.Array(Schema.Literal("code")), + grant_types_supported: Schema.Array(Schema.Literal("authorization_code")), + code_challenge_methods_supported: Schema.Array(Schema.Literal("S256")), + token_endpoint_auth_methods_supported: Schema.Array(Schema.Literal("none")), + scopes_supported: Schema.Array(AuthEnvironmentScope), + authorization_response_iss_parameter_supported: Schema.Boolean, +}); +export type AuthMcpAuthorizationServerMetadata = typeof AuthMcpAuthorizationServerMetadata.Type; + +/** RFC 7591 client metadata. Fields the server does not use are dropped. */ +export const AuthMcpClientRegistration = Schema.Struct({ + client_name: Schema.optionalKey(Schema.String), + redirect_uris: Schema.optionalKey(Schema.Array(Schema.String)), + token_endpoint_auth_method: Schema.optionalKey(Schema.String), +}); +export type AuthMcpClientRegistration = typeof AuthMcpClientRegistration.Type; + +export const AuthMcpRegisteredClient = Schema.Struct({ + client_id: Schema.String, + client_name: Schema.String, + redirect_uris: Schema.Array(Schema.String), + grant_types: Schema.Array(Schema.Literal("authorization_code")), + response_types: Schema.Array(Schema.Literal("code")), + token_endpoint_auth_method: Schema.Literal("none"), +}).pipe(HttpApiSchema.status(201)); +export type AuthMcpRegisteredClient = typeof AuthMcpRegisteredClient.Type; + +/** RFC 7591 §3.2.2 registration error. */ +export class AuthMcpRegistrationError extends Schema.Error( + "AuthMcpRegistrationError", +)( + { + error: Schema.Literals(["invalid_client_metadata", "invalid_redirect_uri"]), + error_description: Schema.String, + }, + { httpApiStatus: 400 }, +) { + override get message(): string { + return this.error_description; + } +} + +/** + * An agent's authorization request, as the approval page received it in its + * URL. Every field is checked by the server, so all are optional here. + */ +export const AuthMcpAuthorizationRequest = Schema.Struct({ + response_type: Schema.optionalKey(Schema.String), + client_id: Schema.optionalKey(Schema.String), + redirect_uri: Schema.optionalKey(Schema.String), + code_challenge: Schema.optionalKey(Schema.String), + code_challenge_method: Schema.optionalKey(Schema.String), + state: Schema.optionalKey(Schema.String), + resource: Schema.optionalKey(Schema.String), +}); +export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Type; + +/** + * What the approval page needs to show for an MCP OAuth sign-in. The server + * has already validated the request; nothing here is trusted by the client + * except for display. + */ +export const AuthMcpApprovalDetails = Schema.Struct({ + /** Self-declared by the client, so shown as such. */ + clientName: Schema.String, + /** Where the code goes: always a loopback address on the browser's machine. */ + redirectHost: Schema.String, + environmentHost: Schema.String, + /** Present when this browser's session may approve without a pairing code. */ + csrfToken: Schema.optionalKey(Schema.String), + /** + * What that session may approve in one click: only access whose scopes it + * holds. Anything else still needs a pairing code. Absent with `csrfToken`. + */ + oneClickAccess: Schema.optionalKey(Schema.Array(AuthMcpClientAccess)), +}); +export type AuthMcpApprovalDetails = typeof AuthMcpApprovalDetails.Type; + +/** + * Where the approval page sends the browser next: back to the agent with a + * code, a denial, or a protocol error the agent should receive. + */ +export const AuthMcpApprovalRedirect = Schema.Struct({ + redirectTo: Schema.String, +}); +export type AuthMcpApprovalRedirect = typeof AuthMcpApprovalRedirect.Type; + +export const AuthMcpApprovalDecision = Schema.Union([ + Schema.TaggedStruct("deny", {}), + Schema.TaggedStruct("pairing-code", { + access: AuthMcpClientAccess, + code: TrimmedNonEmptyString, + }), + /** One click, for a browser session that may approve (see `csrfToken`). */ + Schema.TaggedStruct("browser-session", { + access: AuthMcpClientAccess, + csrfToken: Schema.String, + }), +]); +export type AuthMcpApprovalDecision = typeof AuthMcpApprovalDecision.Type; + +export const AuthMcpApprovalDecisionRequest = Schema.Struct({ + authorization: AuthMcpAuthorizationRequest, + decision: AuthMcpApprovalDecision, +}); +export type AuthMcpApprovalDecisionRequest = typeof AuthMcpApprovalDecisionRequest.Type; + +/** A problem the approval page shows the user without redirecting anywhere. */ +export class AuthMcpApprovalError extends Schema.TaggedError()( + "AuthMcpApprovalError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +/** RFC 6749 §4.1.3 token request. Every field is checked by the server. */ +export const AuthMcpTokenRequest = Schema.Struct({ + grant_type: Schema.optionalKey(Schema.String), + code: Schema.optionalKey(Schema.String), + redirect_uri: Schema.optionalKey(Schema.String), + client_id: Schema.optionalKey(Schema.String), + code_verifier: Schema.optionalKey(Schema.String), + resource: Schema.optionalKey(Schema.String), +}).pipe(HttpApiSchema.asFormUrlEncoded()); +export type AuthMcpTokenRequest = typeof AuthMcpTokenRequest.Type; + +export const AuthMcpTokenResult = Schema.Struct({ + access_token: Schema.String, + token_type: Schema.Literal("Bearer"), + expires_in: Schema.Number, + scope: Schema.String, +}); +export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type; + +/** RFC 6749 §5.2 token error. */ +export class AuthMcpTokenError extends Schema.Error("AuthMcpTokenError")( + { + error: Schema.Literals([ + "invalid_request", + "invalid_client", + "invalid_grant", + "unsupported_grant_type", + ]), + error_description: Schema.String, + }, + { httpApiStatus: 400 }, +) { + override get message(): string { + return this.error_description; + } +} diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index b530c5c4c898..ac2d85d48cb3 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -15,6 +15,19 @@ import { AuthBrowserSessionResult, AuthClientSession, AuthCreatePairingCredentialInput, + AuthMcpApprovalDecisionRequest, + AuthMcpApprovalDetails, + AuthMcpApprovalError, + AuthMcpApprovalRedirect, + AuthMcpAuthorizationRequest, + AuthMcpAuthorizationServerMetadata, + AuthMcpClientRegistration, + AuthMcpProtectedResourceMetadata, + AuthMcpRegisteredClient, + AuthMcpRegistrationError, + AuthMcpTokenError, + AuthMcpTokenRequest, + AuthMcpTokenResult, AuthPairingCredentialResult, AuthPairingLink, AuthRevokeClientSessionInput, @@ -514,6 +527,58 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") }).middleware(EnvironmentAuthenticatedAuth), ) {} +/** + * The OAuth authorization server outside agents use to sign in to `/mcp`. + * `authorize` is where the agent sends the browser: it answers a redirect to + * the web app's approval page, or a plain error page for a request that names + * an unverified client or redirect, so the response is not a schema. + */ +class EnvironmentMcpOAuthHttpApi extends HttpApiGroup.make("mcpOAuth") + .add( + HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { + success: AuthMcpProtectedResourceMetadata, + }), + ) + .add( + HttpApiEndpoint.get("mcpProtectedResource", "/.well-known/oauth-protected-resource/mcp", { + success: AuthMcpProtectedResourceMetadata, + }), + ) + .add( + HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { + success: AuthMcpAuthorizationServerMetadata, + }), + ) + .add( + HttpApiEndpoint.post("register", "/oauth/mcp/register", { + payload: AuthMcpClientRegistration, + success: AuthMcpRegisteredClient, + error: AuthMcpRegistrationError, + }), + ) + .add(HttpApiEndpoint.get("authorize", "/oauth/mcp/authorize")) + .add( + HttpApiEndpoint.post("approval", "/oauth/mcp/approval", { + payload: AuthMcpAuthorizationRequest, + success: [AuthMcpApprovalDetails, AuthMcpApprovalRedirect], + error: AuthMcpApprovalError, + }), + ) + .add( + HttpApiEndpoint.post("decision", "/oauth/mcp/decision", { + payload: AuthMcpApprovalDecisionRequest, + success: AuthMcpApprovalRedirect, + error: AuthMcpApprovalError, + }), + ) + .add( + HttpApiEndpoint.post("token", "/oauth/mcp/token", { + payload: AuthMcpTokenRequest, + success: AuthMcpTokenResult, + error: AuthMcpTokenError, + }), + ) {} + const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); @@ -684,6 +749,7 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks") export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) + .add(EnvironmentMcpOAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) .add(EnvironmentPullRequestsHttpApi) .add(EnvironmentProjectsHttpApi) diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts index 13336cb48a1e..e7e487c9f1b6 100644 --- a/packages/shared/src/devProxy.ts +++ b/packages/shared/src/devProxy.ts @@ -8,7 +8,18 @@ * prefix only Vite knows gets answered with index.html; a prefix only the * server knows redirect-loops through the proxy. */ -export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws"] as const; +export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const; + +/** + * Prefixes the proxy must forward with the browser's own Host. MCP OAuth + * derives its issuer and resource URLs from the request, and a client + * rejects metadata naming a different origin than the one it fetched. + */ +export const DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES: ReadonlySet = new Set([ + "/oauth", + "/.well-known", + "/mcp", +]); export function isDevProxiedPath(pathname: string): boolean { return DEV_PROXIED_PATH_PREFIXES.some( From fbe5df2d4b630d13adc8fe2d38cab354e6d66d67 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 14:09:36 -0700 Subject: [PATCH 16/59] feat(web): copy an environment's MCP URL for outside agents (#16337) Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/ConnectionsSettings.tsx | 37 +++++++++++++++++++ docs/user/remote-access.md | 28 ++++++++++++++ .../src/connection/presentation.test.ts | 22 +++++++++++ .../src/connection/presentation.ts | 33 +++++++++++++++++ 4 files changed, 120 insertions(+) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 42084ab8d5a4..f915ca4457df 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -48,6 +48,7 @@ import { RelayConnectionTarget, connectionRoutes, connectionStatusText, + environmentMcpUrl, } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, @@ -1526,6 +1527,25 @@ function SavedBackendListRow({ }, [copyTraceIdToClipboard], ); + const { copyToClipboard: copyMcpUrl } = useCopyToClipboard<{ url: string }>({ + target: "MCP URL", + onCopy: ({ url }) => { + toastManager.add({ + type: "success", + title: "MCP URL copied", + description: `Add it to an agent, e.g. claude mcp add --transport http t3 ${url}`, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy MCP URL", + description: error.message, + }), + ); + }, + }); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); const resumingServerUpdate = @@ -1545,6 +1565,20 @@ function SavedBackendListRow({ if (discoveredDescriptor !== undefined && discoveredDescriptor !== lastDescriptor) { setLastDescriptor(discoveredDescriptor); } + // Held for the same reason as the descriptor, so Copy MCP URL survives a refresh. + const discoveredRelayHttpBaseUrl = + relayDiscovery.environments.get(environmentId)?.environment.endpoint.httpBaseUrl; + const [lastRelayHttpBaseUrl, setLastRelayHttpBaseUrl] = useState(discoveredRelayHttpBaseUrl); + if ( + discoveredRelayHttpBaseUrl !== undefined && + discoveredRelayHttpBaseUrl !== lastRelayHttpBaseUrl + ) { + setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); + } + const mcpUrl = environmentMcpUrl({ + entry: environment.entry, + relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, + }); const machineKind = resolveEnvironmentMachineKind( environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), @@ -1711,6 +1745,9 @@ function SavedBackendListRow({ {routesOpen ? "Hide routes" : "Routes"} + {mcpUrl ? ( + copyMcpUrl(mcpUrl, { url: mcpUrl })}>Copy MCP URL + ) : null} {errorTraceId ? ( copyTraceId(errorTraceId)}>Copy trace ID ) : null} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 7c88fd400ee8..4db4f57ef3ab 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -220,6 +220,34 @@ only reports what it would change. The browser always runs in Chrome's sandbox. Where you cannot change the host, set `T3CODE_SERVER_BROWSER_SANDBOX=0` for the environment to run without it. +## Connect an outside agent + +An agent T3 Code did not start, such as Claude Code in your own terminal, can +drive threads on an environment through its MCP server. In **Settings → +Connections**, open a saved environment's menu and choose **Copy MCP URL**, then +add it to the agent. For example: + +```sh +claude mcp add --transport http t3 https:///mcp +``` + +The first time the agent connects, it opens a sign-in page on the environment. +Enter a pairing code from **Settings → Connections** on a device that can manage +access, or from `t3 auth pairing create` on the host, and choose what the agent +may do. A browser already signed in to that environment as an administrator can +approve without a code. + +- **Read only** lets the agent read projects and threads in every project, and + see which providers and models are available. It cannot change anything. +- **Supervised** through **Full access** also let it start, message and stop + threads in every project, but it cannot start or steer a thread with more + permissions than the mode you chose. + +Use an HTTPS address: T3 Connect, Tailscale Serve, or `localhost` on the host +itself. Agents refuse to sign in through a plain `http://` LAN or tailnet +address. The agent appears under **Settings → Connections** like any other +client; revoke it there. Sign-ins last 30 days. + ## Manage or revoke access On the host, **Settings → Connections** lets authorized administrators create diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index fabc47034599..e9f4fe0d57eb 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -11,6 +11,7 @@ import { } from "./model.ts"; import { connectionCatalogDisplayUrl, + environmentMcpUrl, connectionStatusText, connectionStatusTitle, presentEnvironmentConnection, @@ -71,6 +72,27 @@ describe("connection presentation", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); + it("offers an MCP address only where an MCP client can sign in", () => { + expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); + const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ + ...ENTRY, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: TARGET.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + }); + expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( + "http://127.0.0.1:3773/mcp", + ); + // A plain-http LAN or tailnet address is refused by MCP clients' token checks. + expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); + }); + it("distinguishes initial connection, reconnect, and retry errors", () => { expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ phase: "connecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index f7586c5e3dbf..a409847f0b5e 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -92,6 +92,39 @@ export function presentEnvironmentConnection( return presentConnectionState(state); } +/** + * The address an agent outside T3 (Claude Code, Codex) uses to reach this + * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP + * clients refuse to sign in through a plain-http token endpoint elsewhere. + * SSH connections ride a local forward that disappears with the client, so + * they have no stable address to hand out. + */ +export function environmentMcpUrl(input: { + readonly entry: ConnectionCatalogEntry; + readonly relayHttpBaseUrl?: string | undefined; +}): string | null { + const httpBaseUrl = + input.entry.target._tag === "RelayConnectionTarget" + ? (input.relayHttpBaseUrl ?? null) + : input.entry.target._tag === "SshConnectionTarget" + ? null + : connectionCatalogDisplayUrl(input.entry); + if (httpBaseUrl === null) return null; + let url: URL; + try { + url = new URL(httpBaseUrl); + } catch { + return null; + } + const loopback = + url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null; + url.pathname = "/mcp"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { switch (entry.target._tag) { case "PrimaryConnectionTarget": From 7bee189dfb5cd6807457e98af0c2f95f5ed50edd Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Tue, 6 Oct 2026 14:26:16 -0700 Subject: [PATCH 17/59] chore(tsconfig): turn off the Schema-over-JSON diagnostic in test files (#16375) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/cloud/linkEnvironment.test.ts | 2 - apps/server/src/cli/config.test.ts | 5 -- apps/server/src/cli/pair.test.ts | 1 - apps/server/src/cloud/bootService.test.ts | 4 -- apps/server/src/cloud/selfUpdate.test.ts | 1 - apps/server/src/device/SshDeviceHost.test.ts | 1 - .../server/src/device/sshDeviceScript.test.ts | 2 +- apps/server/src/git/GitManager.test.ts | 52 ------------------- apps/server/src/keybindings.test.ts | 1 - .../src/mcp/AcpMcpOverAcpBridge.test.ts | 1 - apps/server/src/mcp/AcpMcpStdioBridge.test.ts | 2 +- .../src/orchestration-v2/ProjectStore.test.ts | 1 - .../016_CanonicalizeModelSelections.test.ts | 7 --- .../provider/ClaudeCapabilitiesProbe.test.ts | 2 - .../src/provider/CodexChatGptAuth.test.ts | 2 +- .../src/provider/CodexChatGptModels.test.ts | 1 - apps/server/src/provider/GrokProvider.test.ts | 2 - .../provider/ProviderInstanceRegistry.test.ts | 2 - .../AzureDevOpsPullRequestCli.test.ts | 9 ---- .../BitbucketPullRequestApi.test.ts | 16 ------ .../pullRequest/GitHubPullRequestCli.test.ts | 1 - .../pullRequest/GitLabPullRequestCli.test.ts | 40 +------------- apps/server/src/serverSettings.test.ts | 12 +---- apps/server/src/serviceLauncher.test.ts | 3 -- .../src/sourceControl/AzureDevOpsCli.test.ts | 5 -- .../src/sourceControl/BitbucketApi.test.ts | 2 - .../src/sourceControl/GitLabCli.test.ts | 13 +---- .../ClaudeTextGeneration.test.ts | 1 - apps/server/src/vcs/VcsProjectConfig.test.ts | 2 - apps/web/src/cloud/linkEnvironment.test.ts | 4 -- .../src/state/pullRequestDiffHttp.test.ts | 1 - tsconfig.base.json | 8 ++- 32 files changed, 14 insertions(+), 192 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index a236663c9ea8..1e78f250ef4e 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -293,7 +293,6 @@ describe("mobile cloud link environment client", () => { const bodies: Array = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { - // @effect-diagnostics-next-line preferSchemaOverJson:off bodies.push(JSON.parse(requestBodyText(init.body))); } if (String(url).endsWith("/v1/client/environment-link-challenges")) { @@ -348,7 +347,6 @@ describe("mobile cloud link environment client", () => { const bodies: Array> = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { - // @effect-diagnostics-next-line preferSchemaOverJson:off bodies.push(JSON.parse(requestBodyText(init.body)) as Record); } if (String(url).endsWith("/v1/client/environment-link-challenges")) { diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 17072a5e1c35..04f6c3ca7a8c 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -650,7 +650,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off `${JSON.stringify({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -722,7 +721,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off `${JSON.stringify({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -774,7 +772,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off `${JSON.stringify({ observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -1073,7 +1070,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, ); @@ -1145,7 +1141,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); yield* fs.writeFileString( derivedPaths.settingsPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, ); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 39fe8652ce61..77cd8f1f0bc9 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -173,7 +173,6 @@ describe("t3 pair", () => { const listed = yield* captureStdout( runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), ); - // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>; assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4d8bae4377c4..85de4a7bd437 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -359,7 +359,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { current: true, installedVersion: "1.2.3", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", @@ -546,7 +545,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs, statePath } = yield* makeHarness(); yield* service.install(); - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", @@ -647,7 +645,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness(); yield* service.install(); - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL - 1, activeVersion: "1.2.3", @@ -808,7 +805,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const { service, fs, statePath, commands } = yield* makeHarness("darwin"); yield* service.install(); const plistPath = (yield* service.status).unitPath; - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL - 1, activeVersion: "1.2.3", diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 2ee84535470c..111b62c8a481 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -84,7 +84,6 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, }; return { - // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. stdout: JSON.stringify(result), stderr: "", code: ChildProcessSpawner.ExitCode(0), diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index f0d0a7ece0ae..871f66bae0d6 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout. import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Net from "@t3tools/shared/Net"; diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 8d9bc77d891c..f1f56c715e60 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes. +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - verifies generated remote scripts using real shell and Node processes. import * as Effect from "effect/Effect"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { describe, expect, it } from "@effect/vitest"; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 8f743c65fa31..95b99fb2c433 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -790,7 +790,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 13, @@ -835,7 +834,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 14, @@ -875,7 +873,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 0, @@ -928,7 +925,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 16, @@ -1042,7 +1038,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const { manager, ghCalls } = yield* makeManager({ ghScenario: { - // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], }, }); @@ -1095,7 +1090,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 217, @@ -1138,7 +1132,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { prListSequence: [ "[]", // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 114, @@ -1244,7 +1237,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 216, @@ -1296,7 +1288,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 221, @@ -1347,7 +1338,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 217, @@ -1410,7 +1400,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/deleted-fork-branch": JSON.stringify([ { number: 218, @@ -1492,7 +1481,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 219, @@ -1545,7 +1533,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 220, @@ -1719,7 +1706,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 215, @@ -1768,7 +1754,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 214, @@ -1991,7 +1976,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 1661, @@ -2045,7 +2029,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 488, @@ -2111,7 +2094,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off main: JSON.stringify([ { number: 777, @@ -2187,7 +2169,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off "effect-atom": JSON.stringify([ { number: 1618, @@ -2199,7 +2180,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { updatedAt: "2026-03-01T10:00:00Z", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -2250,7 +2230,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 22, @@ -2289,7 +2268,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 23, @@ -2325,7 +2303,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 54, @@ -2365,7 +2342,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/pushed-plain": JSON.stringify([ { number: 88, @@ -2420,7 +2396,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/fork-plain": JSON.stringify([ { number: 89, @@ -2476,7 +2451,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/fork-settle": JSON.stringify([ { number: 91, @@ -2524,7 +2498,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. - // @effect-diagnostics-next-line preferSchemaOverJson:off "feature/sticky-plain": JSON.stringify([ { number: 90, @@ -2564,7 +2537,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 45, @@ -2698,7 +2670,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const { manager } = yield* makeManager({ ghScenario: { - // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -2742,7 +2713,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const { manager } = yield* makeManager({ ghScenario: { - // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -2791,7 +2761,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const { manager } = yield* makeManager({ ghScenario: { - // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -2831,7 +2800,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const { manager } = yield* makeManager({ ghScenario: { - // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -2874,7 +2842,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }; const { manager } = yield* makeManager({ ghScenario: { - // @effect-diagnostics-next-line preferSchemaOverJson:off prListSequence: [JSON.stringify([existingPr])], failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -3469,7 +3436,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 77, @@ -3605,7 +3571,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 303, @@ -3652,7 +3617,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 404, @@ -3704,7 +3668,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { defaultBranch: "", prListSequence: [ "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 505, @@ -3744,7 +3707,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 42, @@ -3798,7 +3760,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 142, @@ -3875,7 +3836,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off "effect-atom": JSON.stringify([ { number: 1618, @@ -3885,7 +3845,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { headRefName: "effect-atom", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "upstream/effect-atom": JSON.stringify([ { number: 1518, @@ -3935,9 +3894,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: JSON.stringify([ { number: 41, @@ -4003,7 +3960,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - // @effect-diagnostics-next-line preferSchemaOverJson:off statemachine: JSON.stringify([ { number: 142, @@ -4021,7 +3977,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), }, }, @@ -4264,7 +4219,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequence: [ "[]", - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 88, @@ -4385,7 +4339,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 1661, @@ -4403,7 +4356,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 188, @@ -4466,9 +4418,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ghScenario: { prListSequenceByHeadSelector: { statemachine: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 188, @@ -6084,9 +6034,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { number: 201, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index df5175ef65a3..38b6b3a9c205 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -219,7 +219,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; yield* fs.writeFileString( keybindingsConfigPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+shift+d+o", command: "terminal.new" }, diff --git a/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts b/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts index e7e9ee78753c..a564a711a2f6 100644 --- a/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpOverAcpBridge.test.ts @@ -1,5 +1,4 @@ // The bridge intentionally treats MCP JSON-RPC messages as opaque JSON. -// @effect-diagnostics preferSchemaOverJson:off import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts index 654659151a8c..9cde2b880a7a 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts @@ -1,6 +1,6 @@ // The harness asserts raw JSON-RPC wire strings, mirroring the bridge's // schema-free passthrough. -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off preferSchemaOverJson:off +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off import * as NodeStream from "node:stream"; import { describe, expect, it } from "@effect/vitest"; diff --git a/apps/server/src/orchestration-v2/ProjectStore.test.ts b/apps/server/src/orchestration-v2/ProjectStore.test.ts index 23884a0ec54f..77cc0fda60e2 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.test.ts @@ -44,7 +44,6 @@ it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemor FROM projection_projects WHERE project_id = ${projectId} `; - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.strictEqual(rows[0]?.defaultModelSelection, JSON.stringify(modelSelection)); assert.deepStrictEqual( Option.getOrNull(yield* projects.get(projectId))?.defaultModelSelection, diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 78624fa13709..9c20724c55d3 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -265,7 +265,6 @@ layer("016_CanonicalizeModelSelections", (it) => { FROM orchestration_events ORDER BY rowid ASC `; - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), { projectId: "project-1", title: "Project", @@ -281,7 +280,6 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), { projectId: "project-2", title: "Fallback Project", @@ -297,7 +295,6 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { projectId: "project-3", title: "Null Model Project", @@ -307,7 +304,6 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), { threadId: "thread-1", projectId: "project-1", @@ -327,7 +323,6 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), { threadId: "thread-2", projectId: "project-1", @@ -346,7 +341,6 @@ layer("016_CanonicalizeModelSelections", (it) => { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), { threadId: "thread-1", turnId: "turn-1", @@ -360,7 +354,6 @@ layer("016_CanonicalizeModelSelections", (it) => { }, deliveryMode: "buffered", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), { threadId: "thread-3", projectId: "project-1", diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 8147a43b05a5..831df2d172ec 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -293,7 +293,6 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { }, }); - // @effect-diagnostics-next-line preferSchemaOverJson:off const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { readonly args: ReadonlyArray; readonly cwd: string; @@ -310,7 +309,6 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const settingsFlagIndex = invocation.args.indexOf("--settings"); assert.notEqual(settingsFlagIndex, -1); - // @effect-diagnostics-next-line preferSchemaOverJson:off const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { readonly disableAllHooks?: boolean; }; diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index fc840d029b99..96465947b651 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - Local mock OAuth server validates the browser callback boundary. +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Local mock OAuth server validates the browser callback boundary. import * as NodeHttp from "node:http"; import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/provider/CodexChatGptModels.test.ts b/apps/server/src/provider/CodexChatGptModels.test.ts index 736f1319d7f0..9533a3c64d00 100644 --- a/apps/server/src/provider/CodexChatGptModels.test.ts +++ b/apps/server/src/provider/CodexChatGptModels.test.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics preferSchemaOverJson:off - Mock HTTP responses use JSON fixtures. import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { HttpClient, HttpClientResponse } from "effect/http"; diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 7a01812c6833..f77ee10465e2 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -380,7 +380,6 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { directory: dir, name: "grok", source: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off `process.stderr.write(${JSON.stringify(`${secretStderr}\n`)});`, "process.exit(2);", "", @@ -417,7 +416,6 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { " process.exit(0);", "}", 'if (process.argv[2] === "models") {', - // @effect-diagnostics-next-line preferSchemaOverJson:off ` process.stdout.write(${JSON.stringify(input.modelsOutput)});`, " process.exit(0);", "}", diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index d78de9ecf44e..7a132b40fc88 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -172,7 +172,6 @@ const makeTildeProviderFixtures = Effect.fn( ); yield* fileSystem.writeFileString( codexScriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), ); yield* fileSystem.chmod(codexPath, 0o755); @@ -346,7 +345,6 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const fixtures = yield* makeTildeProviderFixtures(); yield* fileSystem.writeFileString( fixtures.codexScriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. JSON.stringify({ rootThreadId: "probe-thread", notifications: [], diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index a6824d8a0a13..be8354c6444c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -228,7 +228,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ...row, description: "x".repeat(10_000), })); - // @effect-diagnostics-next-line preferSchemaOverJson:off const response = JSON.stringify(rows); expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); @@ -341,7 +340,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { pullRequestId: "malformed" }, pullRequestRows(1, 1)[0], @@ -434,7 +432,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { Effect.gen(function* () { // `--query user` unwraps the object, so the wrapper has to put it back. mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), ); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; @@ -636,7 +633,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ pullRequestId: 42, title: "Add the page", @@ -653,7 +649,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ value: [ { @@ -674,7 +669,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ changeEntries: [ { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } }, @@ -713,7 +707,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { // pull request read every time they checked whether a file had been pushed to. const pullRequest = Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ pullRequestId: 42, title: "Add the page", @@ -1219,7 +1212,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ value: [ { @@ -1374,7 +1366,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { Effect.succeed( output( // Well-formed, but with nothing to build a link from: not a decode failure. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ pullRequestId: 42, title: "Add the page", diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 49cdaa90f7d2..d614dd90a7e7 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -676,7 +676,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { expect(call.method).toBe("PUT"); expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); }), ); @@ -688,7 +687,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); }), ); @@ -705,7 +703,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { body: "New body.", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).body ?? "")).toEqual({ title: "A new title", description: "New body.", @@ -736,7 +733,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("fails the read when Bitbucket answers with something unreadable", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(response(JSON.stringify({ error: "nope" }))), ); const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; @@ -769,7 +765,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("fails when the credentials belong to no named account", () => Effect.gen(function* () { - // @effect-diagnostics-next-line preferSchemaOverJson:off mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; @@ -784,7 +779,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { mockedRequest.mockReturnValueOnce( Effect.succeed( response( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ next: "https://api.bitbucket.org/2.0/comments?page=2", values: [ @@ -805,7 +799,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { response( // The reply arrives a page after the remark it answers, which is why the threads // are only assembled once every page is in hand. - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ values: [ { @@ -840,7 +833,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { mockedRequest.mockReturnValue( Effect.succeed( response( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ next: "https://api.bitbucket.org/2.0/comments?page=2", values: [ @@ -868,7 +860,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { mockedRequest.mockReturnValueOnce( Effect.succeed( response( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ values: [ { @@ -941,7 +932,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { }); expect(callAt(0).url).toContain("/pullrequests/7/comments"); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).body ?? "")).toEqual({ content: { raw: "why remove?" }, inline: { path: "src/a.ts", from: 12 }, @@ -988,7 +978,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { body: "Fixed.", }); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).body ?? "")).toEqual({ content: { raw: "Fixed." }, parent: { id: 10 }, @@ -1001,7 +990,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { mockedRequest.mockReturnValue( Effect.succeed( response( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), ), ), @@ -1017,7 +1005,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("escapes a repository name before it goes inside a filter literal", () => Effect.gen(function* () { - // @effect-diagnostics-next-line preferSchemaOverJson:off mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; @@ -1075,7 +1062,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { .mockReturnValueOnce( Effect.succeed( response( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), ), ), @@ -1113,7 +1099,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { const call = callAt(1); expect(call.method).toBe("PUT"); expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(call.body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], }); @@ -1136,7 +1121,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { requested: false, }); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 7e6146834db8..28aca8b6a9b3 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -789,7 +789,6 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, }), diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 76fb1059b91d..8fb1334de364 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -226,10 +226,8 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("advances the cursor through malformed raw rows", () => Effect.gen(function* () { - // @effect-diagnostics-next-line preferSchemaOverJson:off const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray; mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -314,7 +312,6 @@ layer("GitLabPullRequestCli.layer", (it) => { Effect.gen(function* () { // Full pages of unusable rows: nothing is collected, so the collected-count bound never // trips and only the page bound can end the walk. - // @effect-diagnostics-next-line preferSchemaOverJson:off const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -627,7 +624,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("reports a commit with no parent as a structured error", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -654,7 +650,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("expands a new file from a root commit without requiring a parent", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); @@ -680,7 +675,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ diff_refs: { base_sha: "a1b2c3d", @@ -718,7 +712,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ diff_refs: { base_sha: "a1b2c3d", @@ -758,7 +751,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ diff_refs: { base_sha: "a1b2c3d", @@ -823,7 +815,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("offers no squash when the project does not say it allows one", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -840,7 +831,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("reads the project's merge settings as its merge capabilities", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -884,7 +874,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("fails when the authenticated account has no username", () => Effect.gen(function* () { - // @effect-diagnostics-next-line preferSchemaOverJson:off mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -935,7 +924,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { id: "abc123", @@ -994,7 +982,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ iid: 7, title: "t", @@ -1030,7 +1017,6 @@ layer("GitLabPullRequestCli.layer", (it) => { // The diff revisions first, because a positioned comment cannot be placed without them. expect(argsOfCall(0)[1]).toContain("merge_requests/7"); expect(argsOfCall(1)[1]).toContain("/discussions"); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ body: "why remove?", position: { @@ -1086,7 +1072,6 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(argsOfCall(0)).toContain("--method"); expect(argsOfCall(0)).toContain("PUT"); expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); }), ); @@ -1117,13 +1102,11 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("removes an award by listing them and deleting the reader's own id", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ username: "bilal" }))), ); mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { id: 5, name: "thumbsup", user: { username: "bilal" } }, { id: 6, name: "thumbsup", user: { username: "julius" } }, @@ -1155,7 +1138,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("does nothing when the reader has no award of that name to take back", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // @effect-diagnostics-next-line preferSchemaOverJson:off Effect.succeed(output(JSON.stringify({ username: "bilal" }))), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); @@ -1179,7 +1161,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ iid: 7, title: "t", @@ -1217,10 +1198,7 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) .mockReturnValueOnce( - Effect.succeed( - // @effect-diagnostics-next-line preferSchemaOverJson:off - output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }])), - ), + Effect.succeed(output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }]))), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -1258,7 +1236,6 @@ layer("GitLabPullRequestCli.layer", (it) => { // GitLab replaces the whole set, so the reviewer already on the merge request has to be // sent back with the new one or the request would take them off it. expect(argsOfCall(1)).toContain("PUT"); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); }), ); @@ -1282,7 +1259,6 @@ layer("GitLabPullRequestCli.layer", (it) => { requested: false, }); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); }), ); @@ -1303,7 +1279,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }); // Sending it as a number would rewrite the reviewer set around something nobody chose. - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); }), ); @@ -1330,7 +1305,6 @@ layer("GitLabPullRequestCli.layer", (it) => { "--header", "Content-Type: application/json", ]); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); }), ); @@ -1348,7 +1322,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }); // A title sent as an empty string would wipe the one the merge request already has. - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); }), ); @@ -1367,7 +1340,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 1); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title", description: "What this changes.", @@ -1407,7 +1379,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ iid: 7, title: "t", @@ -1424,7 +1395,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ data: { project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, @@ -1449,7 +1419,6 @@ layer("GitLabPullRequestCli.layer", (it) => { ["src/gone.ts", ""], ]); // The head the reader is looking at, not whatever the source branch has moved on to. - // @effect-diagnostics-next-line preferSchemaOverJson:off const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); expect(body).toMatchObject({ variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, @@ -1462,7 +1431,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ iid: 7, title: "t", @@ -1480,10 +1448,7 @@ layer("GitLabPullRequestCli.layer", (it) => { // these files, and reading it that way would report every file the reader has cleared as // changed on nothing worse than a permission. mockedExecute.mockReturnValueOnce( - Effect.succeed( - // @effect-diagnostics-next-line preferSchemaOverJson:off - output(JSON.stringify({ data: { project: null } })), - ), + Effect.succeed(output(JSON.stringify({ data: { project: null } }))), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -1520,7 +1485,6 @@ layer("GitLabPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ iid: 7, title: "t", diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 59f188d7d1b5..44ce0767e4b4 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -578,7 +578,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { const change = Option.getOrUndefined(yield* Stream.runHead(changes)); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); // Inspect raw persisted JSON before schema decoding can apply defaults. - // @effect-diagnostics-next-line preferSchemaOverJson:off const persisted = JSON.parse(raw) as Record; assert.strictEqual(next.sidebarAutoSettleAfterDays, null); @@ -759,7 +758,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.parse(raw).sourceControlWriterModelSelection, sourceControlWriterModelSelection, ); @@ -1046,7 +1044,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isFalse(settings.providers.grok.enabled); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.isFalse(JSON.parse(raw).providers.grok.enabled); }).pipe(Effect.provide(layerServerSettings())), ); @@ -1067,7 +1064,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - // @effect-diagnostics-next-line preferSchemaOverJson:off const persisted = JSON.parse(raw); assert.isTrue(persisted.providers.cursor.enabled); assert.isTrue(persisted.providers.grok.enabled); @@ -1104,7 +1100,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isFalse(resolveProviderInstanceEnabled(grok)); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - // @effect-diagnostics-next-line preferSchemaOverJson:off const persisted = JSON.parse(raw); assert.isFalse(persisted.providers.cursor.enabled); assert.isFalse(persisted.providers.grok.enabled); @@ -1288,7 +1283,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(raw), { addProjectBaseDirectory: "~/Development", observability: { @@ -1506,7 +1500,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.notInclude(raw, "sk-or-secret"); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [ { name: "OPENROUTER_API_KEY", @@ -1992,10 +1985,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { projectSettingsOverrides: { [legacyProject]: null }, }); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - const persisted = yield* decodeServerSettings( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.parse(raw), - ); + const persisted = yield* decodeServerSettings(JSON.parse(raw)); assert.isTrue(persisted.projectSettingsFolded); assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]); }).pipe(Effect.provide(layerServerSettings())), diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 30b239f3e47f..df2ab9e9cd06 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -198,7 +198,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const databasePath = path.join(root, "userdata", "state.sqlite"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, "before trial"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); @@ -252,7 +251,6 @@ if (context.update?.status === "pending") { const databasePath = path.join(root, "userdata", "state.sqlite"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, "before trial"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); @@ -308,7 +306,6 @@ if (context.update?.status === "pending") { const original = "database before migration"; yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, original); - // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` import { writeFileSync } from "node:fs"; diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 6ef0b35559fd..249397824609 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -39,7 +39,6 @@ describe("AzureDevOpsCli.layer", () => { mockRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ pullRequestId: 42, title: "Add Azure provider", @@ -97,7 +96,6 @@ describe("AzureDevOpsCli.layer", () => { mockRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ pullRequestId: 863, title: "Fix Azure link", @@ -134,7 +132,6 @@ describe("AzureDevOpsCli.layer", () => { mockRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { pullRequestId: 7, @@ -195,7 +192,6 @@ describe("AzureDevOpsCli.layer", () => { mockRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ name: "repo", webUrl: "https://dev.azure.com/acme/project/_git/repo", @@ -228,7 +224,6 @@ describe("AzureDevOpsCli.layer", () => { mockRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ name: "repo", webUrl: "https://dev.azure.com/acme/project/_git/repo", diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 2ea48c3fdc8c..352bfc60d970 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -541,7 +541,6 @@ it.effect("creates repositories through the Bitbucket REST API", () => { assert.ok(request); const rawBody = (request.body as { readonly body?: Uint8Array }).body; assert.ok(rawBody); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { scm: "git", is_private: true, @@ -577,7 +576,6 @@ it.effect("creates pull requests using the official REST payload shape", () => { assert.ok(request); const rawBody = (request.body as { readonly body?: Uint8Array }).body; assert.ok(rawBody); - // @effect-diagnostics-next-line preferSchemaOverJson:off assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { title: "Provider PR", description: "PR body", diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index a87a32831098..5a10f13b8f8f 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -39,7 +39,6 @@ layer("GitLabCli.layer", (it) => { mockedRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ iid: 42, title: "Add MR thread creation", @@ -94,7 +93,6 @@ layer("GitLabCli.layer", (it) => { mockedRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { iid: 0, @@ -163,7 +161,6 @@ layer("GitLabCli.layer", (it) => { mockedRun.mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ path_with_namespace: "octocat/t3code", web_url: "https://gitlab.com/octocat/t3code", @@ -230,18 +227,10 @@ layer("GitLabCli.layer", (it) => { Effect.gen(function* () { mockedRun + .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) .mockReturnValueOnce( Effect.succeed( processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ id: 1234 }), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ path_with_namespace: "octocat/t3code", web_url: "https://gitlab.com/octocat/t3code", diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index b373973b4944..0ee4b8763cfc 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -367,7 +367,6 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { const claudeConfigDir = path.join(process.cwd(), ".claude-work-test"); return yield* withFakeClaudeEnv( { - // @effect-diagnostics-next-line preferSchemaOverJson:off output: JSON.stringify({ structured_output: { title: "Use Claude home", diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index f28d3eb51452..1c91cbadea8c 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -42,7 +42,6 @@ describe("VcsProjectConfig", () => { yield* fileSystem.makeDirectory(nested, { recursive: true }); yield* fileSystem.writeFileString( path.join(configDir, "vcs.json"), - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ vcs: { kind: "jj" } }), ); @@ -72,7 +71,6 @@ describe("VcsProjectConfig", () => { yield* fileSystem.makeDirectory(configDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(configDir, "vcs.json"), - // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ vcs: { kind: "jj" } }), ); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index cb775c26ff6a..dbcaacfa25a4 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -240,7 +240,6 @@ describe("web cloud link environment client", () => { "http://127.0.0.1:3000/api/connect/preferences", ); expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ publishAgentActivity: true, }); @@ -290,7 +289,6 @@ describe("web cloud link environment client", () => { expect(String(fetchMock.mock.calls[1]?.[0])).toBe( "http://127.0.0.1:3000/api/connect/link-proof", ); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ challenge: "challenge", endpoint: { @@ -341,11 +339,9 @@ describe("web cloud link environment client", () => { }), ); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ managedTunnelsEnabled: false, }); - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ endpoint: { providerKind: "manual" }, }); diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts index b69aeaab3855..849553e89359 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts @@ -71,7 +71,6 @@ describe("fetchEnvironmentPullRequestDiff", () => { : ""; // The assertion deliberately inspects the serialized wire body rather than decoding a // domain value for use in application code. - // @effect-diagnostics-next-line preferSchemaOverJson:off expect(JSON.parse(body)).toEqual({ projectId: "project-1", repository: "owner/repository", diff --git a/tsconfig.base.json b/tsconfig.base.json index 61f331a128f9..f6914a8e1e6c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -51,7 +51,13 @@ "globalFetch": "error", "globalFetchInEffect": "error", "schemaNumber": "off" - } + }, + "overrides": [ + { + "include": ["**/*.test.ts", "**/*.test.tsx"], + "options": { "diagnosticSeverity": { "preferSchemaOverJson": "off" } } + } + ] } ] } From c9b21b28e2cebc4c9cbd80726e757df36309dea8 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Tue, 6 Oct 2026 14:32:03 -0700 Subject: [PATCH 18/59] chore(review): CodeRabbit gates outside contributors' pull requests (#16332) --- .coderabbit.config.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 9f7291f479cf..5a9505c70276 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,14 +1,43 @@ -import { defineConfig } from "@coderabbitai/config"; +import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -export default defineConfig({ +const approvability = `Fail when a maintainer should read this pull request before CodeRabbit approves it, and name the rule and file. Fail if it: + +- Changes a product default: a setting's default value, or what users get without opting in. Making a feature do what it already promises is a bug fix, not a default change. +- Adds or broadens a directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic, including file-level, line-level, and configuration-level overrides. +- Adds a subsystem or user workflow, or is a large refactor across apps or packages. +- Changes packages/contracts or persisted data in a way that existing clients or stored data might not accept. +- Changes authentication, pairing, credentials, secrets, or remote connection trust. +- Adds or changes an external side effect, such as acting on GitHub, publishing a release, or calling a webhook. +- Adds, upgrades, or patches a dependency. +- Changes CI or release configuration, agent or contributor instructions, or any review tool's configuration, including .github/, AGENTS.md, CONTRIBUTING.md, .agents/, and .coderabbit.config.ts. + +Otherwise pass. A focused bug fix, copy or layout fix, revert, or docs-only or test-only change passes unless a rule above applies. If you cannot decide, fail rather than report inconclusive. When failing, say that the pull request needs a maintainer's review. +`; + +// Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit +// requests changes until its comments are resolved and its checks pass, then approves. +const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); + +export default defineConfig((ctx) => ({ reviews: { high_level_summary: false, review_status: false, + request_changes_workflow: isGated(ctx), + allow_author_approval: !isGated(ctx), auto_review: { enabled: true, }, pre_merge_checks: { docstrings: { mode: "off" }, + override_requested_reviewers_only: isGated(ctx), + custom_checks: [ + { + name: "Approvability", + mode: isGated(ctx) ? "error" : "off", + instructions: approvability, + }, + ], }, path_filters: [ // Vendored read-only reference checkouts of upstream Effect and Alchemy @@ -34,4 +63,4 @@ export default defineConfig({ ], }, }, -}); +})); From f4f148eb670622a049ae6561d7795011e383fc43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 14:51:55 -0700 Subject: [PATCH 19/59] fix(desktop): include Linux package license and app metadata (#16597) --- .../linux/com.t3tools.t3code.metainfo.xml | 32 +++++++++++++++++++ scripts/build-desktop-artifact.ts | 13 +++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml diff --git a/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml b/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml new file mode 100644 index 000000000000..0ae54a9849fc --- /dev/null +++ b/apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml @@ -0,0 +1,32 @@ + + + com.t3tools.t3code + CC0-1.0 + MIT + T3 Code + Desktop GUI for coding agents + + T3 Tools Inc. + + +

    + T3 Code is an open-source desktop app for coding agents. Work with your + existing agent subscriptions, review code changes, and run commands in + your projects. +

    +

    + Connect from desktop, web, or mobile to continue working remotely. +

    +
    + t3code.desktop + t3code + + t3code + + + Development + + https://t3.codes + https://github.com/pingdotgg/t3code/issues + https://github.com/pingdotgg/t3code +
    diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6bf280469c69..301b42613d25 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -929,6 +929,7 @@ interface StagePackageJson { readonly private: true; readonly packageManager: string; readonly description: string; + readonly license: string; readonly homepage: string; readonly author: string; readonly main: string; @@ -2766,6 +2767,8 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( // dynamically loads the system libfuse2 library. Pin the static runtime so // the AppImage also launches on distributions that only provide FUSE 3. buildConfig.toolsets = { appimage: "1.0.3" }; + const path = yield* Path.Path; + const repoRoot = yield* RepoRoot; buildConfig.linux = { // The .deb is built from the same unpacked app after the AppImage. // electron-builder lists both in latest-linux.yml and writes @@ -2794,6 +2797,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( }, }; buildConfig.deb = { + // FPM runs outside the staged app directory, so source paths must be absolute. + // AppStream consumers associate this metadata with our t3code.desktop entry. + fpm: [ + `${path.join(repoRoot, "apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml")}=/usr/share/metainfo/com.t3tools.t3code.metainfo.xml`, + `${path.join(repoRoot, "LICENSE")}=/usr/share/doc/t3code/copyright`, + ], // Electron's runtime libraries. Debian 13 and Ubuntu 24.04 renamed some // for 64-bit time; the old name is the fallback for older releases. depends: [ @@ -3702,7 +3711,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( t3codeCommitHash: commitHash, private: true, packageManager: rootPackageJson.packageManager, - description: "T3 Code desktop build", + description: + "T3 Code is an open-source desktop app for coding agents. Work with your existing agent subscriptions, review code changes, and run commands in your projects. Connect from desktop, web, or mobile to continue working remotely.", + license: "MIT", // Required by the .deb control file. homepage: "https://t3.codes", author: "T3 Tools", From 8ddf200e8f637eaf9a4f0d3036ece8f1d3869d10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 16:01:44 -0700 Subject: [PATCH 20/59] fix(server): one failing RPC handler no longer ends the client's other requests (#15515) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/observability/DefectReporter.test.ts | 102 +++++++++++++++ .../src/observability/DefectReporter.ts | 24 ++++ apps/server/src/ws.ts | 14 +- packages/effect-acp/src/_internal/shared.ts | 17 +++ packages/effect-acp/src/agent.test.ts | 38 ++++++ packages/effect-acp/src/agent.ts | 13 +- packages/effect-acp/src/client.test.ts | 123 ++++++++++++++++++ packages/effect-acp/src/client.ts | 16 ++- packages/effect-acp/src/protocol.test.ts | 69 ++++++++++ packages/effect-acp/src/protocol.ts | 54 ++++++-- 10 files changed, 454 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/observability/DefectReporter.test.ts create mode 100644 apps/server/src/observability/DefectReporter.ts diff --git a/apps/server/src/observability/DefectReporter.test.ts b/apps/server/src/observability/DefectReporter.test.ts new file mode 100644 index 000000000000..28c81180ba67 --- /dev/null +++ b/apps/server/src/observability/DefectReporter.test.ts @@ -0,0 +1,102 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Rpc, RpcClient, RpcGroup, RpcServer } from "effect/rpc"; + +import { WS_RPC_SERVER_OPTIONS } from "../ws.ts"; +import * as DefectReporter from "./DefectReporter.ts"; + +class TestRpcs extends RpcGroup.make( + Rpc.make("subscribe", { success: Schema.Number, stream: true }), + Rpc.make("boom", { success: Schema.Void }), +) {} +type TestRpc = RpcGroup.Rpcs; + +/** Runs `body` with every error log captured. */ +const withErrorLogs = ( + body: (logs: Queue.Queue>) => Effect.Effect, +) => + Effect.gen(function* () { + const logs = yield* Queue.unbounded>(); + const logger = Logger.make(({ cause, logLevel }) => { + if (logLevel === "Error") Queue.offerUnsafe(logs, cause); + }); + return yield* body(logs).pipe( + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); + +const errorMessage = (cause: Cause.Cause) => (Cause.squash(cause) as Error).message; + +describe("DefectReporter", () => { + it.effect("a dying RPC handler fails alone, and its defect is logged", () => + withErrorLogs((logs) => + Effect.gen(function* () { + const subscription = yield* Queue.unbounded(); + // The same client/server pairing as RpcTest.makeClient, with ws.ts's options. + let client!: Effect.Success< + ReturnType> + >; + const server = yield* RpcServer.makeNoSerialization(TestRpcs, { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => client.write(response), + }).pipe( + // Provided to the handlers, as ws.ts does. + Effect.provide( + TestRpcs.toLayer({ + subscribe: () => Stream.fromQueue(subscription), + boom: () => Effect.die(new Error("handler bug")), + }).pipe(Layer.provide(DefectReporter.layer)), + ), + ); + client = yield* RpcClient.makeNoSerialization(TestRpcs, { + supportsAck: true, + onFromClient: ({ message }) => server.write(0, message), + }); + + const received = yield* Queue.unbounded(); + const sibling = yield* client.client.subscribe().pipe( + Stream.runForEach((value) => Queue.offer(received, value)), + Effect.forkScoped, + ); + yield* Queue.offer(subscription, 1); + assert.equal(yield* Queue.take(received), 1); + + const boom = yield* Effect.exit(client.client.boom()); + assert.isTrue(Exit.hasDies(boom)); + // The server reports before it answers, so the log is already written. + const logged = yield* Queue.clear(logs); + assert.deepEqual(logged.map(errorMessage), ["handler bug"]); + + // The sibling subscription on the same client keeps delivering. + yield* Queue.offer(subscription, 2); + const next = yield* Queue.take(received).pipe( + Effect.raceFirst(Fiber.await(sibling).pipe(Effect.as("subscription ended"))), + ); + assert.equal(next, 2); + assert.equal(yield* Queue.size(logs), 0); + }), + ).pipe(Effect.scoped), + ); + + it.effect("logs defects, not typed failures or interrupts", () => + withErrorLogs((logs) => + Effect.gen(function* () { + yield* ErrorReporter.report(Cause.fail(new Error("expected"))); + yield* ErrorReporter.report(Cause.interrupt()); + yield* ErrorReporter.report(Cause.die(new Error("bug"))); + + // Reporters log synchronously, so the logs are already written. + assert.deepEqual((yield* Queue.clear(logs)).map(errorMessage), ["bug"]); + }).pipe(Effect.provide(DefectReporter.layer)), + ), + ); +}); diff --git a/apps/server/src/observability/DefectReporter.ts b/apps/server/src/observability/DefectReporter.ts new file mode 100644 index 000000000000..220b2696d831 --- /dev/null +++ b/apps/server/src/observability/DefectReporter.ts @@ -0,0 +1,24 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; + +/** + * Logs the defects of WebSocket RPC handlers. RpcServer reports every failed + * handler exit; typed failures are expected responses, so only dies are logged. + */ +const reporter: ErrorReporter.ErrorReporter = { + [ErrorReporter.TypeId]: ErrorReporter.TypeId, + report: ({ cause, fiber }) => { + for (const reason of cause.reasons) { + if (reason._tag !== "Die" || ErrorReporter.isIgnored(reason.defect)) continue; + // Reporters are called synchronously from the failing fiber. Logging with + // its context keeps its loggers and annotations, and a fork cannot throw + // back into the server that reported. + Effect.runForkWith(fiber.context)( + Effect.logError("Unhandled defect", Cause.fromReasons([reason])), + ); + } + }, +}; + +export const layer = ErrorReporter.layer([reporter]); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 99a05b8e205b..dcf9fad56835 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -210,6 +210,7 @@ import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as DefectReporter from "./observability/DefectReporter.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; @@ -3787,6 +3788,14 @@ const layerWsRpc = ( }), ); +// A defect in a handler's effect fails only its own request. RpcServer's default +// sends a socket-level Defect frame instead, and the client ends every pending +// request on the socket with it. DefectReporter logs these defects. +export const WS_RPC_SERVER_OPTIONS = { + disableTracing: true, + disableFatalDefects: true, +} as const; + export const layer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -3830,7 +3839,7 @@ export const layer = Layer.unwrap( yield* analytics.record("client.connected", clientAnalyticsProps); const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; - yield* RpcServer.make(ServerWsRpcGroup, { disableTracing: true }).pipe( + yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), Effect.provide(RpcAuthorization.layer(session.scopes)), Effect.forkScoped, @@ -3847,6 +3856,9 @@ export const layer = Layer.unwrap( serverBrowser, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + // Request fibers run in the handlers' context, so this reporter sees + // their defects, not the rest of the server's. + Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index e4a98d51d8bf..527fb99304c6 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { RpcClientError } from "effect/rpc"; @@ -29,6 +30,19 @@ export const callRpc =
    ( }), ); +/** + * Runs a notification handler so it cannot stop the caller: a typed failure is + * dropped, as notifications have no reply, and a defect is logged. + */ +export const isolateNotificationHandler = (effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasDies(cause) + ? Effect.logError("ACP notification handler failed", cause) + : Effect.void, + ), + ); + export const runHandler = Effect.fnUntraced(function* >( handler: ((payload: A, ...args: Args) => Effect.Effect) | undefined, payload: A, @@ -39,6 +53,9 @@ export const runHandler = Effect.fnUntraced(function* + Effect.logError(`ACP request handler failed for '${method}'`, defect), + ), Effect.mapError((error) => AcpError.AcpRequestError.fromCoreHandlerError(error, method).toProtocolError(), ), diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 9b69ecb5918d..416c4808a3ed 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -35,6 +35,14 @@ const SessionCancelNotification = jsonRpcNotification( const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number })); const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); +/** A response whose cause is a handler's defect, as RpcServer encodes it. */ +const DieResponse = Schema.Struct({ + id: Schema.Number, + error: Schema.Struct({ + _tag: Schema.Literal("Cause"), + data: Schema.Tuple([Schema.Struct({ _tag: Schema.Literal("Die") })]), + }), +}); const decodeRequestPermissionRequest = Schema.decodeEffect( Schema.fromJsonString(RequestPermissionRequest), ); @@ -296,3 +304,33 @@ it.effect("effect-acp agent uses distinct ids for RPC calls and extension reques }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); }), ); + +it.effect("effect-acp agent answers a request whose handler dies with an error for it", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const context = yield* Layer.buildWithScope(AcpAgent.layer(stdio), scope); + const agent = yield* Effect.service(AcpAgent.AcpAgent).pipe(Effect.provide(context)); + yield* agent.handleInitialize(() => Effect.die(new Error("handler bug"))); + + yield* Queue.offer( + input, + yield* encodeJsonl(InitializeRequest, { + jsonrpc: "2.0", + id: 7, + method: "initialize", + params: { + protocolVersion: 2, + capabilities: {}, + info: { name: "effect-acp-test", version: "0.0.0" }, + }, + headers: [], + }), + ); + const response = yield* Queue.take(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(DieResponse))), + ); + assert.equal(response.id, 7); + yield* Scope.close(scope, Exit.void); + }), +); diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 153fea8428ee..2ddf5cc8cc0d 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -18,6 +19,7 @@ import { callRpc, decodeExtNotificationRegistration, decodeExtRequestRegistration, + isolateNotificationHandler, runHandler, } from "./_internal/shared.ts"; @@ -280,7 +282,11 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( ), ), Effect.flatMap((decoded) => - Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }), + Effect.forEach( + cancelHandlers, + (handler) => isolateNotificationHandler(handler(decoded)), + { discard: true }, + ), ), ); } @@ -421,7 +427,10 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( }), ); - yield* RpcServer.make(AcpRpcs.AgentRpcs).pipe( + yield* RpcServer.make(AcpRpcs.AgentRpcs, { disableFatalDefects: true }).pipe( + // runHandler logs handler defects with their method. A reporter inherited + // from the caller (a WebSocket request, say) would log them again. + Effect.provideService(ErrorReporter.CurrentErrorReporters, new Set()), Effect.provideService(RpcServer.Protocol, transport.serverProtocol), Effect.provide(layerAgentHandler), Effect.forkScoped, diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index d25df6ccf5e7..6209dcffa41f 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -5,11 +5,13 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -50,6 +52,16 @@ const PermissionRequest = jsonRpcRequest( const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ElicitationRequest = jsonRpcRequest("elicitation/create", AcpSchema.CreateElicitationRequest); const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse); +/** A JSON-RPC error response; only its id and the presence of an error matter here. */ +const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown }); +/** A response whose cause is a handler's defect, as RpcServer encodes it. */ +const DieResponse = Schema.Struct({ + id: Schema.String, + error: Schema.Struct({ + _tag: Schema.Literal("Cause"), + data: Schema.Tuple([Schema.Struct({ _tag: Schema.Literal("Die") })]), + }), +}); const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest)); const XAiPromptCompleteNotification = jsonRpcNotification( "_x.ai/session/prompt_complete", @@ -1130,6 +1142,117 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect("answers each request whose handler dies, and keeps reading", () => + Effect.gen(function* () { + const errorLogs = yield* Queue.unbounded(); + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Error") Queue.offerUnsafe(errorLogs, String([message].flat()[0])); + }); + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.provide(Logger.layer([logger])), + ); + const bug = () => Effect.die(new Error("handler bug")); + yield* acp.handleRequestPermission(bug); + yield* acp.handleExtRequest("x/dies", Schema.Unknown, bug); + yield* acp.handleExtNotification("x/notification-dies", Schema.Unknown, bug); + yield* acp.handleSessionUpdate(bug); + const updates = yield* Queue.unbounded(); + yield* acp.handleSessionUpdate((notification) => + Queue.offer(updates, notification.sessionId).pipe(Effect.asVoid), + ); + yield* acp.handleExtRequest("x/test", Schema.Struct({ hello: Schema.String }), () => + Effect.succeed({ ok: true }), + ); + const send = (line: Effect.Effect) => + Effect.flatMap(line, (bytes) => Queue.offer(input, bytes)); + const decodeDie = Schema.decodeEffect(Schema.fromJsonString(DieResponse)); + // A stopped reader leaves these waiting; fail instead of hanging. + const next = (queue: Queue.Dequeue) => + TestClock.withLive(Queue.take(queue).pipe(Effect.timeout("2 seconds"))); + + yield* send( + encodeJsonl(PermissionRequest, { + jsonrpc: "2.0", + id: "permission-a", + method: "session/request_permission", + params: { + sessionId: "session-1", + title: "Tool", + subject: { + type: "tool_call" as const, + toolCall: { toolCallId: "tool-1", title: "Tool" }, + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }], + }, + headers: [], + }), + ); + assert.equal((yield* next(output).pipe(Effect.flatMap(decodeDie))).id, "permission-a"); + + yield* send( + encodeJsonl(jsonRpcRequest("x/dies", Schema.Unknown), { + jsonrpc: "2.0", + id: "ext-a", + method: "x/dies", + params: {}, + headers: [], + }), + ); + const extDied = yield* next(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ErrorResponse))), + ); + assert.equal(extDied.id, "ext-a"); + + yield* send( + encodeJsonl(jsonRpcNotification("x/notification-dies", Schema.Unknown), { + jsonrpc: "2.0", + method: "x/notification-dies", + params: {}, + }), + ); + yield* send( + encodeJsonl(SessionUpdateNotification, { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } }, + }, + }), + ); + + // The next session handler still ran. + assert.equal(yield* next(updates), "session-1"); + + // The reader survived all three: a later request is still answered. + yield* send( + encodeJsonl(ExtRequest, { + jsonrpc: "2.0", + id: "ext-b", + method: "x/test", + params: { hello: "world" }, + headers: [], + }), + ); + const answered = yield* next(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ExtResponse))), + ); + assert.deepEqual([answered.id, answered.result], ["ext-b", { ok: true }]); + + // Every defect was logged at Error, once. + assert.deepEqual((yield* Queue.clear(errorLogs)).toSorted(), [ + "ACP extension request handler failed for 'x/dies'", + "ACP notification handler failed", + "ACP notification handler failed", + "ACP request handler failed for 'session/request_permission'", + ]); + yield* Scope.close(scope, Exit.void); + }), + ); + it.effect("answers elicitation/create with the flat action shape", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 2df7f9409f4e..732e2d26650f 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as ErrorReporter from "effect/ErrorReporter"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; @@ -22,6 +23,7 @@ import { callRpc, decodeExtNotificationRegistration, decodeExtRequestRegistration, + isolateNotificationHandler, runHandler, } from "./_internal/shared.ts"; import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; @@ -838,9 +840,12 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( registration: BufferedNotificationHandler, notification: A, ) => - Effect.forEach(registration.handlers, (handler) => handler(notification).pipe(Effect.ignore), { - discard: true, - }); + // One handler failing or dying does not stop the others, or the reader. + Effect.forEach( + registration.handlers, + (handler) => isolateNotificationHandler(handler(notification)), + { discard: true }, + ); const flushBufferedNotifications = (registration: BufferedNotificationHandler) => Effect.suspend(() => { @@ -1099,7 +1104,10 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }), ); - yield* RpcServer.make(AcpRpcs.CompatClientRpcs).pipe( + yield* RpcServer.make(AcpRpcs.CompatClientRpcs, { disableFatalDefects: true }).pipe( + // runHandler logs handler defects with their method. A reporter inherited + // from the caller (a WebSocket request, say) would log them again. + Effect.provideService(ErrorReporter.CurrentErrorReporters, new Set()), Effect.provideService(RpcServer.Protocol, transport.serverProtocol), Effect.provide(layerClientHandler), Effect.forkScoped, diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 91677618e070..9c3f41e5f4c9 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -11,6 +11,7 @@ import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { it, assert } from "@effect/vitest"; @@ -257,6 +258,42 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("terminates when a callback on the reader dies", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + transformSessionUpdate: () => { + throw new Error("normalizer bug"); + }, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { sessionUpdate: "plan", entries: [] }, + }, + })}\n`, + ), + ); + + // Pending requests are failed through termination instead of hanging. + const error = yield* TestClock.withLive( + Deferred.await(termination).pipe(Effect.timeout("2 seconds")), + ); + assert.instanceOf(error, AcpError.AcpTransportError); + assert.equal((error as AcpError.AcpTransportError).operation, "read-input-stream"); + }), + ); + it.effect("logs outgoing notifications when logOutgoing is enabled", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); @@ -500,6 +537,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("answers an extension request whose handler also dies as an internal error", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + // The typed failure must not hide the defect from its cleanup. + onExtRequest: () => + Effect.fail(AcpError.AcpRequestError.invalidParams("bad params")).pipe( + Effect.ensuring(Effect.die(new Error("cleanup bug"))), + ), + }); + + yield* Queue.offer( + input, + yield* encodeJsonl(ExtRequest, { + jsonrpc: "2.0", + id: 9, + method: "x/test", + params: { hello: "world" }, + headers: [], + }), + ); + + const response = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(JsonRpcErrorResponse), + )(yield* Queue.take(output)); + assert.equal(response.id, 9); + assert.equal(response.error.code, -32603); + }), + ); + it.effect("preserves numeric ids for inbound extension requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index fb926cfca053..12e85a9e7542 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -3,6 +3,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as PlatformError from "effect/PlatformError"; @@ -20,6 +21,7 @@ import * as AcpSchema from "./schema.ts"; import * as AcpSchemaV1 from "./_generated/schema-v1.gen.ts"; import { CLIENT_METHODS } from "./_generated/meta.gen.ts"; import * as AcpError from "./errors.ts"; +import { isolateNotificationHandler } from "./_internal/shared.ts"; const isAcpError = Schema.is(AcpError.AcpError); export interface AcpProtocolLogEvent { @@ -385,8 +387,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const dispatchNotification = (notification: AcpIncomingNotification) => Queue.offer(notificationQueue, notification).pipe( Effect.andThen( + // A failing or dying handler must not stop the reader, or every later + // message on the connection goes unanswered. options.onNotification - ? options.onNotification(notification).pipe(Effect.ignore) + ? isolateNotificationHandler(options.onNotification(notification)) : Effect.void, ), Effect.asVoid, @@ -458,12 +462,38 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi method: message.tag, }) .pipe( - Effect.matchEffect({ - onFailure: (error) => - respondWithError( - message.id, - AcpError.AcpRequestError.fromExtensionHandlerError(error, message.tag), - ), + Effect.matchCauseEffect({ + // A dying handler answers its own request, like a core handler, and + // leaves the reader running. A defect wins over a typed failure in + // the same cause, so it is never hidden behind an expected error. + onFailure: (cause) => { + const failure = Cause.hasDies(cause) ? Option.none() : Cause.findErrorOption(cause); + if (Option.isSome(failure)) { + return respondWithError( + message.id, + AcpError.AcpRequestError.fromExtensionHandlerError(failure.value, message.tag), + ); + } + return Effect.logError( + `ACP extension request handler failed for '${message.tag}'`, + cause, + ).pipe( + Effect.andThen( + respondWithError( + message.id, + AcpError.AcpRequestError.internalError( + `ACP extension request handler failed for method '${message.tag}'`, + undefined, + { + method: message.tag, + operation: "handle-extension-request", + cause: Cause.squash(cause), + }, + ), + ), + ), + ); + }, onSuccess: (value) => respondWithSuccess(message.id, value), }), ); @@ -685,8 +715,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi ), ), ), - Effect.matchEffect({ - onFailure: (error) => { + // Anything that ends the reader, including a defect in a callback it runs, + // terminates the connection so pending requests fail instead of hanging. + Effect.matchCauseEffect({ + onFailure: (cause) => { + // The reader's own interruption never gets here: an interrupted fiber + // skips failure handlers. An interrupt raised inside it ends it too. + const failure = Cause.findErrorOption(cause); + const error = Option.isSome(failure) ? failure.value : Cause.squash(cause); const normalized: AcpError.AcpError = isAcpError(error) ? error : new AcpError.AcpTransportError({ From 13aabf3d7d0ec13e902234e86d3df27149c0e4a9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 16:20:55 -0700 Subject: [PATCH 21/59] fix(contracts): a context record that cannot be encoded no longer fails the send (#16398) Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/threads/git/GitOverviewSheet.tsx | 12 ++++++ .../src/lib/composerContextRecords.test.ts | 4 +- packages/contracts/src/baseSchemas.test.ts | 32 +++++++++++++-- packages/contracts/src/baseSchemas.ts | 20 ++++++++- .../contracts/src/composerContext.test.ts | 41 +++++++++++++++++++ packages/contracts/src/composerContext.ts | 29 ++++++++----- 6 files changed, 122 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 828c8838e31a..31ad85873d1c 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -55,6 +55,18 @@ type GitOverviewSheetProps = StaticScreenProps<{ }; export function GitOverviewSheet(props: GitOverviewSheetProps) { + const navigation = useNavigation(); + const { environmentId, threadId } = props.route.params; + // A hand-typed deep link can carry a blank ID, which the branded IDs reject. + const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0; + useEffect(() => { + if (isBlankLink) navigation.goBack(); + }, [isBlankLink, navigation]); + if (isBlankLink) return null; + return ; +} + +function GitOverviewSheetContent(props: GitOverviewSheetProps) { const { layout } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts index e5a83030a5fa..3634cea6d448 100644 --- a/apps/web/src/lib/composerContextRecords.test.ts +++ b/apps/web/src/lib/composerContextRecords.test.ts @@ -514,7 +514,7 @@ describe("composerContextRecords", () => { expect( isSameComposerContextPayload(base, { ...base, - elements: base.elements?.map((element) => ({ + elements: base.elements!.map((element) => ({ ...element, htmlPreview: '', })), @@ -523,7 +523,7 @@ describe("composerContextRecords", () => { expect( isSameComposerContextPayload(base, { ...base, - elements: base.elements?.map((element) => ({ + elements: base.elements!.map((element) => ({ ...element, source: { functionName: "Checkout", diff --git a/packages/contracts/src/baseSchemas.test.ts b/packages/contracts/src/baseSchemas.test.ts index 3e2c05c35058..d1bcd4c153a0 100644 --- a/packages/contracts/src/baseSchemas.test.ts +++ b/packages/contracts/src/baseSchemas.test.ts @@ -48,8 +48,9 @@ describe("ForwardCompatibleArray", () => { ]); }); - it("sends an element it cannot encode as a hole instead of failing the array", () => { - const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString })); + const Named = ForwardCompatibleArray(Schema.Struct({ name: TrimmedNonEmptyString })); + + it("drops an element it cannot encode instead of failing the array", () => { const wire = JSON.parse( JSON.stringify( Schema.encodeUnknownSync(Schema.toCodecJson(Named))([ @@ -59,9 +60,34 @@ describe("ForwardCompatibleArray", () => { ]), ), ); - expect(wire).toEqual([{ name: "a" }, null, { name: "b" }]); + expect(wire).toEqual([{ name: "a" }, { name: "b" }]); expect(fromWire(Named)(wire)).toEqual([{ name: "a" }, { name: "b" }]); }); + + it("drops it too when a wrapper reads the encoded array as JSON values", () => { + // How context records are bounded before forward-compatible decoding. + const Wrapped = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Named)); + expect( + Schema.encodeUnknownSync(Schema.toCodecJson(Wrapped))([{ name: "a" }, { name: " " }]), + ).toEqual([{ name: "a" }]); + }); + + it("still drops the null holes a server on an earlier build sends", () => { + expect(fromWire(Named)([{ name: "a" }, null, { name: "b" }])).toEqual([ + { name: "a" }, + { name: "b" }, + ]); + }); + + it("does not accept holes as a decoded value", () => { + expect(Schema.is(Named)([undefined])).toBe(false); + // A sparse array's missing index is a hole too. + const sparse: Array<{ name: string }> = [{ name: "a" }]; + sparse.length = 2; + expect(Schema.is(Named)(sparse)).toBe(false); + expect(() => Named.make(sparse)).toThrow(); + expect(Schema.is(Named)([{ name: "a" }])).toBe(true); + }); }); describe("ForwardCompatibleUnion", () => { diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index adb64a1a4a6f..3ca88df66b19 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -140,13 +140,31 @@ export const ForwardCompatibleArray = (element: Elem Schema.UndefinedOr(Schema.toType(element)).pipe( Schema.catchEncoding(() => Effect.succeedSome(undefined)), ), + ).check( + // The holes above are an encoding detail: a decoded value has none, so + // `Schema.is` and `make` still reject an array that does. Aborts, so a + // later check on the array never sees a hole. + Schema.makeFilter( + (values) => { + // Every index, not `every`, which skips the holes of a sparse array. + for (let index = 0; index < values.length; index++) { + if (values[index] === undefined) return false; + } + return true; + }, + { expected: "an array without holes" }, + true, + ), ), SchemaTransformation.transform< ReadonlyArray, ReadonlyArray >({ decode: (values) => values.filter((value) => value !== undefined), - encode: (values) => values, + // An element that fails its own checks is dropped before the wire, so + // a wrapper that reads the encoded array as JSON values never meets + // the hole it left. + encode: (values) => values.filter((value) => value !== undefined), }), ), ) as unknown as ForwardCompatibleArray; diff --git a/packages/contracts/src/composerContext.test.ts b/packages/contracts/src/composerContext.test.ts index 87aa403cd6f7..b43e59c2840b 100644 --- a/packages/contracts/src/composerContext.test.ts +++ b/packages/contracts/src/composerContext.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -197,6 +199,45 @@ describe("OrchestrationMessageContext", () => { ).toThrow(); }); + it("sends a message without a record it cannot encode", () => { + const wire = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationMessageContext))({ + version: 1, + records: [ + decodeContext({ version: 1, records: [knownRecords.terminal] }).records[0], + { ...knownRecords.terminal, contextId: "ctx_2", terminalLabel: " " }, + ], + }); + expect(decodeContext(wire).records.map((record) => record.contextId)).toEqual(["ctx_1"]); + }); + + it("sends a message without the records the wire cannot carry", () => { + const wire = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationMessageContext))({ + version: 1, + records: [ + decodeContext({ version: 1, records: [knownRecords.terminal] }).records[0], + { + ...base, + contextId: "ctx_2", + kind: "future-kind", + label: "x", + payload: { count: Number.NaN }, + }, + { ...knownRecords["review-comment"], contextId: "ctx_3", fenceLanguage: undefined }, + // JSON.stringify throws on a bigint on every engine. + { ...base, contextId: "ctx_4", kind: "future-kind", label: "y", payload: { n: 1n } }, + ], + }); + expect(decodeContext(wire).records.map((record) => record.contextId)).toEqual(["ctx_1"]); + }); + + it("reports a hole as a schema issue, even when collecting every issue", () => { + const result = Schema.decodeUnknownExit(Schema.toType(OrchestrationMessageContext))( + { version: 1, records: [undefined] }, + { errors: "all" }, + ); + expect(Exit.isFailure(result) && Cause.hasFails(result.cause)).toBe(true); + }); + it("normalizes decoded record identifiers", () => { const context = decodeContext({ version: 1, diff --git a/packages/contracts/src/composerContext.ts b/packages/contracts/src/composerContext.ts index 6d8975a0100d..116c141c9dfb 100644 --- a/packages/contracts/src/composerContext.ts +++ b/packages/contracts/src/composerContext.ts @@ -155,6 +155,9 @@ export const ElementContextRecord = Schema.Struct({ }); export type ElementContextRecord = typeof ElementContextRecord.Type; +// Optional record fields are `optionalKey`: a record holding an explicit +// `undefined` cannot be sent as JSON, so it fails its own check and is dropped +// alone instead of failing the whole message. export const PreviewAnnotationContextRecord = Schema.Struct({ ...recordBase, kind: Schema.Literal("preview-annotation"), @@ -165,14 +168,14 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ targetSummary: ShortString, styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)), /** Picked elements inside the annotation, with the detail the agent needs to find them. */ - elements: Schema.optional(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), + elements: Schema.optionalKey(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), /** Original target ids and edits allow pasted annotations to retain exact style changes. */ - elementIds: Schema.optional(Schema.Array(ShortString).check(Schema.isMaxLength(50))), + elementIds: Schema.optionalKey(Schema.Array(ShortString).check(Schema.isMaxLength(50))), /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary, so a pasted annotation still says what it marked. */ - regionCount: Schema.optional(NonNegativeInt), - strokeCount: Schema.optional(NonNegativeInt), - styleChangeDetails: Schema.optional( + regionCount: Schema.optionalKey(NonNegativeInt), + strokeCount: Schema.optionalKey(NonNegativeInt), + styleChangeDetails: Schema.optionalKey( Schema.Array( Schema.Struct({ targetId: ShortString, @@ -184,7 +187,7 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ ).check(Schema.isMaxLength(200)), ), /** The screenshot travels as its own image record; this links the two. */ - screenshotContextId: Schema.optional(ComposerContextId), + screenshotContextId: Schema.optionalKey(ComposerContextId), }); export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type; @@ -199,8 +202,8 @@ export const ReviewCommentContextRecord = Schema.Struct({ rangeLabel: ShortString, text: BoundedString(COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS), diff: BoundedString(COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS), - fenceLanguage: Schema.optional(BoundedString(64)), - pullRequest: Schema.optional(PullRequestContextMetadata), + fenceLanguage: Schema.optionalKey(BoundedString(64)), + pullRequest: Schema.optionalKey(PullRequestContextMetadata), }).check(Schema.makeFilter((record) => record.endIndex >= record.startIndex)); export type ReviewCommentContextRecord = typeof ReviewCommentContextRecord.Type; @@ -231,6 +234,8 @@ export const ThreadContextRecord = Schema.Struct({ }); export type ThreadContextRecord = typeof ThreadContextRecord.Type; +const isJson = Schema.is(Schema.Json); + /** * Catch-all for kinds this build does not know. Known discriminators are excluded so a * malformed known record fails its own schema instead of sliding through unchecked. @@ -241,12 +246,16 @@ export const UnknownContextRecord = Schema.Struct({ kind: ComposerContextKind.check(Schema.isPattern(KNOWN_KIND_PATTERN)), payload: Schema.Unknown.check( Schema.makeFilter((payload) => { + let encoded: string | undefined; try { - const encoded = JSON.stringify(payload); - return encoded !== undefined && encoded.length <= 64_000; + encoded = JSON.stringify(payload); } catch { + // Cycles, bigints, and payloads nested deeper than this engine's stack. return false; } + // Only JSON values, so a payload the wire cannot carry (a Date, NaN, an + // undefined field) fails this record alone instead of the whole message. + return encoded !== undefined && encoded.length <= 64_000 && isJson(payload); }), ), }); From d8d037eae1b77a77f372fd1afd2662a31ebe37c8 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 6 Oct 2026 19:21:17 -0400 Subject: [PATCH 22/59] fix(web): open pull request row actions on right-click (#16612) --- .../pullRequest/ThreadPullRequestsPanel.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 643c17d4b775..42b09f4387e7 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -11,7 +11,7 @@ import { MoreHorizontalIcon, PlusIcon, } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; @@ -84,6 +84,15 @@ function LinkRow({ onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null; }) { const openPrLink = useOpenPrLink(threadRef); + const [menuOpen, setMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); + const menuAnchor = useMemo( + () => + menuPosition + ? { getBoundingClientRect: () => new DOMRect(menuPosition.x, menuPosition.y, 0, 0) } + : undefined, + [menuPosition], + ); const { link, depth, stack } = line; const snapshot = link.snapshot; const open = snapshot === null || snapshot.state === "open"; @@ -91,6 +100,12 @@ function LinkRow({ return (
    { + event.preventDefault(); + event.stopPropagation(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + setMenuOpen(true); + }} // Each layer steps in under the one it targets. The step is capped: beyond a few layers // the indent only says "still in the stack", which the connector line already does, and // a sixteen-layer stack would otherwise stair-step off the right edge. @@ -221,7 +236,13 @@ function LinkRow({ )} > - + { + setMenuOpen(open); + if (!open) setMenuPosition(null); + }} + > } /> - + void writeTextToClipboard(link.url, "link")}> Copy link From 876f8656e310f365eab98678e68bcc45615fec7f Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 20:50:48 -0300 Subject: [PATCH 23/59] fix(web): show attempted paths in file preview errors (#15628) --- .../components/files/FilePreviewPanel.test.ts | 47 +++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 27 ++++++++++- .../src/components/files/filePreviewMode.ts | 22 +++++++++ .../files/projectFilesQueryState.test.tsx | 39 +++++++++++++++ .../files/projectFilesQueryState.ts | 5 +- 5 files changed, 137 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index ebebf510994b..84fb69e79cf7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -1,3 +1,5 @@ +import { ProjectReadFileError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { @@ -6,12 +8,57 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { + filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; +const decodeReadError = Schema.decodeSync(ProjectReadFileError); + +describe("file preview read errors", () => { + it.each([ + ["path_not_file", "The path is a directory or special file, not a regular file."], + ["binary_file", "The file is binary and cannot be displayed as text."], + ["workspace_path_outside_root", "The requested path is outside the workspace."], + ["resolved_path_outside_root", "The path resolves to a location outside the workspace."], + [ + "operation_failed", + "The file could not be accessed or read. It may be missing or inaccessible.", + ], + ] as const)("describes %s without revealing the platform cause", (failure, message) => { + const error = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "workspace/outline.md", + failure, + operation: "realpath-target", + resolvedPath: "/workspace/workspace/outline.md", + cause: new Error("EACCES: sensitive platform detail"), + }); + expect(filePreviewReadErrorMessage(error)).toBe(message); + }); + + it("distinguishes an inaccessible workspace from an inaccessible file", () => { + const error = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "outline.md", + failure: "operation_failed", + operation: "realpath-workspace-root", + operationPath: "/workspace", + }); + expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed."); + }); + + it("preserves the public message from older servers", () => { + const error = decodeReadError({ + _tag: "ProjectReadFileError", + message: "Legacy file read failure.", + }); + expect(filePreviewReadErrorMessage(error)).toBe("Legacy file read failure."); + }); +}); + describe("file comment annotations", () => { it("normalizes and formats selected line ranges", () => { expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 560c9a1336a5..5ae0753429fd 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { + filePreviewReadErrorMessage, isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, @@ -957,6 +958,7 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && relativePath !== null, ); + const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath; // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview // pane, and let the tree fill the surface with the folder revealed. Mutation @@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} /> ) : relativePath && file.error && file.data === null ? ( -
    - {file.error} +
    +
    +

    + {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error} +

    + {attemptedPath ? ( +

    + Attempted path + + {attemptedPath} + +

    + ) : null} + {!isHostFile ? ( +

    + Workspace folder:{" "} + + {file.readError?.cwd ?? cwd} + + . Check the link's path or locate the file in Files. +

    + ) : null} +
    ) : relativePath && file.data === null ? (
    diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 12a3eb95a3e8..764d44bfd83d 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,4 +1,5 @@ import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; +import type { ProjectReadFileError } from "@t3tools/contracts"; import { isAbsolutePath } from "~/terminal-links"; /** Resolve workspace links before choosing between the explorer and a file preview. */ @@ -9,6 +10,27 @@ export function resolveFilePreviewPath(path: string | null, cwd: string): string export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); +/** Describe existing failure codes without exposing the underlying platform cause. */ +export function filePreviewReadErrorMessage(error: ProjectReadFileError): string { + switch (error.failure) { + case "path_not_file": + return "The path is a directory or special file, not a regular file."; + case "binary_file": + return "The file is binary and cannot be displayed as text."; + case "workspace_path_outside_root": + return "The requested path is outside the workspace."; + case "resolved_path_outside_root": + return "The path resolves to a location outside the workspace."; + case "operation_failed": + // A realpath failure can mean a missing path, permissions, or another I/O error. + return error.operation === "realpath-workspace-root" + ? "The workspace folder could not be accessed." + : "The file could not be accessed or read. It may be missing or inaccessible."; + default: + return error.message; + } +} + export function shouldShowFileExplorer(input: { readonly relativePath: string | null; readonly explorerOpen: boolean; diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index a33465f6cb19..49bade395980 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -5,6 +5,7 @@ import { type ProjectReadFileResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom, AtomRegistry } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-1"); +const decodeReadError = Schema.decodeSync(ProjectReadFileError); function deferred() { let resolve!: (value: A) => void; @@ -137,6 +139,7 @@ describe("project query refresh", () => { const render = (mutationId: string | null) => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + expect(query.readError).toBeNull(); renderedContents = query.data?.contents ?? null; useWorkspaceMutationRefresh({ mutationId, @@ -245,6 +248,7 @@ describe("project query refresh", () => { await flushEffects(); expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(query.readError).toBeNull(); expect(requests).toHaveLength(0); } finally { registry.dispose(); @@ -273,6 +277,7 @@ describe("project query refresh", () => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); expect(query.isNotFile).toBe(true); + expect(query.readError?.failure).toBe("path_not_file"); expect(query.data).toBeNull(); } finally { unmount(); @@ -302,6 +307,40 @@ describe("project query refresh", () => { reactHooks.beginRender(); const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); expect(query.isNotFile).toBe(true); + expect(query.readError?.failure).toBe("path_not_file"); + expect(query.data).toBeNull(); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("retains decoded read failure context for the connected environment", async () => { + const error = decodeReadError({ + _tag: "ProjectReadFileError", + cwd: "C:\\workspace", + relativePath: "workspace/outline.md", + failure: "operation_failed", + operation: "realpath-target", + operationPath: "C:\\workspace\\workspace\\outline.md", + resolvedPath: "C:\\workspace\\workspace\\outline.md", + message: "Failed to read workspace file.", + }); + const readAtom = Atom.make(Effect.fail(error)); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + await flushEffects(); + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "C:\\workspace", "workspace/outline.md"); + expect(query.readError).toBe(error); + expect(query.error).toBe(error.message); + expect(query.isNotFile).toBe(false); expect(query.data).toBeNull(); } finally { unmount(); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index fa4657ca815f..0b3ca2040e88 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -37,6 +37,7 @@ interface ProjectQueryState { } interface ProjectFileQueryState extends ProjectQueryState { + readonly readError: ProjectReadFileError | null; /** The path exists but is not a regular file, typically a directory. */ readonly isNotFile: boolean; } @@ -217,11 +218,13 @@ export function useProjectFileQuery( ); const optimisticFile = relativePath === null ? null : optimisticResult; const cause = failureCause(result); + const readError = isProjectReadFileError(cause) ? cause : null; return { data: optimisticFile?.data ?? data, error: errorMessage(cause), - isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file", + readError, + isNotFile: readError?.failure === "path_not_file", isPending: result.waiting, refresh, }; From 5055de3ae483d5ece45695ad682b9766ff0cf423 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 20:53:27 -0300 Subject: [PATCH 24/59] fix(vcs): passive sidebar rows stop retaining remote pollers (#15666) --- .../connection/background-activity-scopes.ts | 3 +- .../connection/background-activity.test.ts | 11 ++ apps/server/src/git/GitManager.test.ts | 124 ++++++++++++++++++ .../src/vcs/VcsStatusBroadcaster.test.ts | 51 +++++-- apps/server/src/vcs/VcsStatusBroadcaster.ts | 25 ++-- apps/web/src/components/Sidebar.tsx | 4 +- .../src/components/ThreadStatusIndicators.tsx | 2 +- .../lib/backgroundActivityReporter.test.ts | 9 ++ .../web/src/lib/backgroundActivityReporter.ts | 3 +- packages/contracts/src/git.ts | 7 + packages/contracts/src/rpc.ts | 3 +- 11 files changed, 213 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/connection/background-activity-scopes.ts b/apps/mobile/src/connection/background-activity-scopes.ts index da013a572191..2e2a343ff0d8 100644 --- a/apps/mobile/src/connection/background-activity-scopes.ts +++ b/apps/mobile/src/connection/background-activity-scopes.ts @@ -45,7 +45,8 @@ function scopeForSubscription( if (observation.method !== WS_METHODS.subscribeVcsStatus) { return null; } - const input = observation.input as { readonly cwd?: unknown }; + const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown }; + if (input.includeRemote === false) return null; return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; } diff --git a/apps/mobile/src/connection/background-activity.test.ts b/apps/mobile/src/connection/background-activity.test.ts index 7a2d902557ed..170ba4efa9c9 100644 --- a/apps/mobile/src/connection/background-activity.test.ts +++ b/apps/mobile/src/connection/background-activity.test.ts @@ -12,6 +12,12 @@ describe("mobile background activity", () => { it.effect("retains VCS demand only while the mobile subscription is active", () => Effect.gen(function* () { const environmentId = EnvironmentId.make("mobile-environment"); + const releasePassive = yield* observeMobileBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: "/workspace", includeRemote: false }, + }); + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); const release = yield* observeMobileBackgroundActivitySubscription({ environmentId, method: WS_METHODS.subscribeVcsStatus, @@ -22,6 +28,11 @@ describe("mobile background activity", () => { { type: "vcs-status", cwd: "/workspace" }, ]); + yield* releasePassive; + expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ + { type: "vcs-status", cwd: "/workspace" }, + ]); + yield* release; expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); }), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 95b99fb2c433..adef6ab42afe 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -6,7 +6,10 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -16,11 +19,13 @@ import * as References from "effect/References"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { GitActionProgressEvent, + GitManagerServiceError, GitPreparePullRequestThreadInput, ModelSelection, } from "@t3tools/contracts"; @@ -43,6 +48,11 @@ import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; import { @@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( ); it.layer(layerGitManagerTest)("GitManager", (it) => { + it.effect("passive worktree status streams do not start remote refreshes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-passive-vcs-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + const worktreeDir = NodePath.join(repoDir, "worktree"); + yield* runGit(repoDir, ["worktree", "add", "-b", "feature/passive", worktreeDir]); + yield* runGit(worktreeDir, ["push", "-u", "origin", "feature/passive"]); + + const { manager } = yield* makeManager(); + let remoteReads = 0; + const workflowContext = yield* Layer.build( + GitWorkflowService.layer.pipe( + Layer.provide( + Layer.succeed(GitManager.GitManager, { + ...manager, + remoteStatus: (input, options) => + manager.remoteStatus(input, options).pipe( + Effect.tap(() => + Effect.sync(() => { + remoteReads += 1; + }), + ), + ), + }), + ), + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), + Layer.provide(VcsProcess.layer), + ), + ), + ), + ); + const broadcasterContext = yield* Layer.build( + VcsStatusBroadcaster.layer.pipe( + Layer.provide( + Layer.succeed( + GitWorkflowService.GitWorkflowService, + Context.get(workflowContext, GitWorkflowService.GitWorkflowService), + ), + ), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + hasDemand: () => Effect.succeed(true), + shouldRunScopeWork: () => Effect.succeed(true), + }), + ), + ), + ); + const broadcaster = Context.get( + broadcasterContext, + VcsStatusBroadcaster.VcsStatusBroadcaster, + ); + const passiveScope = yield* Scope.make(); + const snapshots = yield* Deferred.make(); + const localUpdated = yield* Deferred.make(); + const remoteUpdated = yield* Deferred.make(); + let snapshotCount = 0; + for (const cwd of [repoDir, worktreeDir]) { + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd, includeRemote: false }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, + ), + (event) => { + if ( + cwd === worktreeDir && + event._tag === "localUpdated" && + event.local.hasWorkingTreeChanges + ) { + return Deferred.succeed(localUpdated, undefined); + } + if (cwd === worktreeDir && event._tag === "remoteUpdated") { + expect(event.remote?.hasUpstream).toBe(true); + return Deferred.succeed(remoteUpdated, undefined); + } + if (event._tag !== "snapshot") return Effect.void; + expect(event.local.isRepo).toBe(true); + expect(event.local.refName).toBe(cwd === repoDir ? "main" : "feature/passive"); + snapshotCount += 1; + return snapshotCount === 2 ? Deferred.succeed(snapshots, undefined) : Effect.void; + }, + ).pipe( + Effect.catchCause((cause) => Deferred.failCause(snapshots, cause)), + Effect.forkIn(passiveScope), + ); + } + yield* Deferred.await(snapshots); + expect(remoteReads).toBe(0); + yield* TestClock.adjust("1 minute"); + expect(remoteReads).toBe(0); + + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(NodePath.join(worktreeDir, "README.md"), "changed\n"); + yield* broadcaster.refreshLocalStatus(worktreeDir); + yield* Deferred.await(localUpdated); + expect(remoteReads).toBe(0); + + const activeScope = yield* Scope.make(); + yield* Stream.runDrain(broadcaster.streamStatus({ cwd: worktreeDir })).pipe( + Effect.forkIn(activeScope), + ); + yield* Deferred.await(remoteUpdated); + expect(remoteReads).toBe(1); + yield* Scope.close(activeScope, Exit.void); + yield* TestClock.adjust("1 minute"); + expect(remoteReads).toBe(1); + yield* Scope.close(passiveScope, Exit.void); + }), + ); + it.effect("status includes draft PR metadata when branch already has a draft PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 480ed8458fe3..35d013433c49 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -594,7 +594,7 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("streams a local snapshot first and remote updates later", () => { + it.effect("passive streams retain cached remote status", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -608,18 +608,25 @@ describe("VcsStatusBroadcaster", () => { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const snapshotDeferred = yield* Deferred.make(); const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }).pipe(Effect.forkScoped); + yield* Stream.runForEach( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkScoped); const snapshot = yield* Deferred.await(snapshotDeferred); - yield* broadcaster.refreshStatus("/repo"); + yield* TestClock.adjust("1 minute"); + assert.equal(state.remoteStatusCalls, 0); + yield* broadcaster.getStatus({ cwd: "/repo" }); + state.currentRemoteStatus = remoteStatusWithPr; + yield* broadcaster.refreshPullRequestStatus("/repo"); const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); assert.deepStrictEqual(snapshot, { @@ -629,8 +636,16 @@ describe("VcsStatusBroadcaster", () => { } satisfies VcsStatusStreamEvent); assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", - remote: baseRemoteStatus, + remote: remoteStatusWithPr, } satisfies VcsStatusStreamEvent); + const cachedSnapshot = yield* Stream.runHead( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ); + assert.deepStrictEqual(Option.getOrThrow(cachedSnapshot), { + _tag: "snapshot", + local: baseLocalStatus, + remote: remoteStatusWithPr, + }); }).pipe(Effect.provide(layerTestFor(state))); }); @@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTest)); }); - it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + it.effect("releases remote demand while passive observers remain", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -1092,6 +1107,16 @@ describe("VcsStatusBroadcaster", () => { const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); const secondScope = yield* Scope.make(); + const passiveSnapshot = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(passiveSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkScoped); + yield* Deferred.await(passiveSnapshot); + assert.equal(state.remoteStatusCalls, 0); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => event._tag === "snapshot" ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 1f43ade5a28c..468bc3301bfc 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -19,6 +19,7 @@ import type { VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, + VcsStatusSubscriptionInput, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; @@ -201,7 +202,7 @@ export class VcsStatusBroadcaster extends Context.Service< cwd: string, ) => Effect.Effect; readonly streamStatus: ( - input: VcsStatusInput, + input: VcsStatusSubscriptionInput, options?: StreamStatusOptions, ) => Stream.Stream; } @@ -708,15 +709,19 @@ export const make = Effect.gen(function* () { const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); const initialRemote = cachedStatus?.remote?.value ?? null; - yield* retainRemotePoller( - cwd, - input.cwd, - options?.automaticRemoteRefreshInterval ?? - Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - cachedStatus?.remote === null || cachedStatus?.remote === undefined, - ); - - const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); + if (input.includeRemote !== false) { + yield* retainRemotePoller( + cwd, + input.cwd, + options?.automaticRemoteRefreshInterval ?? + Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), + cachedStatus?.remote === null || cachedStatus?.remote === undefined, + ); + } + const release = + input.includeRemote === false + ? Effect.void + : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 667f87b8df96..370d4f335e8a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1209,7 +1209,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, includeRemote: false }, }) : null, ); @@ -2204,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, includeRemote: false }, }) : null, ); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index b17d8f324c67..ad0c9c99a292 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -910,7 +910,7 @@ export function ThreadRowLeadingStatus({ gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, includeRemote: false }, }) : null, ); diff --git a/apps/web/src/lib/backgroundActivityReporter.test.ts b/apps/web/src/lib/backgroundActivityReporter.test.ts index e32d991f9f53..23e904483327 100644 --- a/apps/web/src/lib/backgroundActivityReporter.test.ts +++ b/apps/web/src/lib/backgroundActivityReporter.test.ts @@ -22,6 +22,12 @@ describe("wasRecentlyInteracted", () => { Effect.gen(function* () { const environmentId = EnvironmentId.make("environment-observation-test"); const scope = { type: "vcs-status" as const, cwd: "/repo" }; + const releasePassive = yield* observeBackgroundActivitySubscription({ + environmentId, + method: WS_METHODS.subscribeVcsStatus, + input: { cwd: scope.cwd, includeRemote: false }, + }); + expect(retainedBackgroundScopes(environmentId)).toEqual([]); const release = yield* observeBackgroundActivitySubscription({ environmentId, method: WS_METHODS.subscribeVcsStatus, @@ -30,6 +36,9 @@ describe("wasRecentlyInteracted", () => { expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); + yield* releasePassive; + expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); + yield* release; expect(retainedBackgroundScopes(environmentId)).toEqual([]); }), diff --git a/apps/web/src/lib/backgroundActivityReporter.ts b/apps/web/src/lib/backgroundActivityReporter.ts index bf0e7def102d..5a65dab38095 100644 --- a/apps/web/src/lib/backgroundActivityReporter.ts +++ b/apps/web/src/lib/backgroundActivityReporter.ts @@ -118,7 +118,8 @@ function scopeForSubscription( if (observation.method !== WS_METHODS.subscribeVcsStatus) { return null; } - const input = observation.input as { readonly cwd?: unknown }; + const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown }; + if (input.includeRemote === false) return null; return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 6e2efd0404b9..b2cb2e902d8e 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -111,6 +111,13 @@ export const VcsStatusInput = Schema.Struct({ }); export type VcsStatusInput = typeof VcsStatusInput.Type; +export const VcsStatusSubscriptionInput = Schema.Struct({ + ...VcsStatusInput.fields, + /** Passive observers receive cached remote status without retaining its refresh loop. */ + includeRemote: Schema.optional(Schema.Boolean), +}); +export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type; + export const VcsPullInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4d6ec6e47223..f05935309235 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -121,6 +121,7 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, VcsStatusInput, + VcsStatusSubscriptionInput, VcsStatusResult, VcsStatusStreamEvent, } from "./git.ts"; @@ -1236,7 +1237,7 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, }); const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { - payload: VcsStatusInput, + payload: VcsStatusSubscriptionInput, success: VcsStatusStreamEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, From 72d5c32ba67953805feb6fe9ad3b70b632a64c47 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 20:53:52 -0300 Subject: [PATCH 25/59] feat(web): group keybindings settings by area with a page toolbar (#12822) --- .../KeybindingsSettings.logic.test.ts | 20 ++ .../settings/KeybindingsSettings.logic.ts | 47 +++ .../settings/KeybindingsSettings.tsx | 272 ++++++++---------- 3 files changed, 182 insertions(+), 157 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index f790f3681316..2e5f88079084 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -7,6 +7,7 @@ import { buildKeybindingCommandOptions, buildWhenVariableOptions, commandLabel, + groupKeybindingRows, keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, @@ -58,6 +59,25 @@ describe("KeybindingsSettings.logic", () => { ); }, ); + it("groups rows by command area in page order and drops empty groups", () => { + const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "")); + expect(groups.map((group) => group.title)).toEqual([ + "Navigation", + "Threads", + "Composer", + "Terminal", + "Preview & diff", + "Appearance", + "Other", + ]); + const composer = groups.find((group) => group.id === "composer"); + expect(composer?.rows.map((row) => row.command)).toEqual( + expect.arrayContaining(["composer.host", "modelPicker.toggle"]), + ); + expect(groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "split"))).toEqual( + [expect.objectContaining({ id: "terminal" })], + ); + }); it("orders Usage bindings and command choices like the page", () => { const expected = [ "usage.open", diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index cbdd456a0172..fde8f3419c09 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -201,6 +201,53 @@ export function keybindingConflictLabels( return [...new Set(conflicts)].toSorted(); } +export interface KeybindingGroup { + readonly id: string; + readonly title: string; + readonly rows: ReadonlyArray; +} + +/** Page sections in display order; a command joins the first group listing its prefix. */ +const KEYBINDING_GROUPS = [ + { + id: "navigation", + title: "Navigation", + prefixes: ["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"], + }, + { id: "threads", title: "Threads", prefixes: ["thread", "chat", "pullRequest"] }, + { id: "composer", title: "Composer", prefixes: ["composer", "modelPicker"] }, + { id: "terminal", title: "Terminal", prefixes: ["terminal"] }, + { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] }, + { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] }, + { id: "scripts", title: "Project scripts", prefixes: ["script"] }, +] as const; +const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const; + +function keybindingGroupFor(command: KeybindingCommand): { id: string; title: string } { + const prefix = String(command).split(".")[0] ?? ""; + return ( + KEYBINDING_GROUPS.find((group) => (group.prefixes as ReadonlyArray).includes(prefix)) ?? + OTHER_KEYBINDING_GROUP + ); +} + +/** Splits sorted rows into the page's sections, dropping sections with no rows. */ +export function groupKeybindingRows( + rows: ReadonlyArray, +): ReadonlyArray { + const rowsByGroup = new Map>(); + for (const row of rows) { + const group = keybindingGroupFor(row.command); + const bucket = rowsByGroup.get(group.id); + if (bucket) bucket.push(row); + else rowsByGroup.set(group.id, [row]); + } + return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => { + const groupRows = rowsByGroup.get(group.id); + return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : []; + }); +} + export function buildKeybindingRows( keybindings: ResolvedKeybindingsConfig, query: string, diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index bf14e709dda1..b2660f8943e2 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -59,13 +59,16 @@ import { keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, + groupKeybindingRows, type KeybindingCommandOption, + type KeybindingGroup, type KeybindingRow, type WhenVariableOption, unknownWhenVariables, whenAstToExpression, whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; +import { SettingsGroup } from "./SettingsGroup"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) { ); } -function ExpandableHeaderSearch({ +/** Filter box in the page toolbar; Mod+F focuses it from anywhere on the page. */ +function KeybindingsSearchInput({ query, onChange, - isOpen, - onOpenChange, inputRef, - collapsedAccessory, }: { query: string; onChange: (next: string) => void; - isOpen: boolean; - onOpenChange: (next: boolean) => void; - inputRef?: RefObject; - collapsedAccessory?: ReactNode; + inputRef: RefObject; }) { - if (!isOpen) { - return ( - <> - {collapsedAccessory} - - onOpenChange(true)} - aria-label="Search keybindings" - > - - - } - /> - Search keybindings - - - ); - } - return ( - + - + onChange(event.currentTarget.value)} - onBlur={() => { - if (query.length === 0) onOpenChange(false); - }} onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - onChange(""); - onOpenChange(false); - } + // The settings route treats an unhandled Escape as "go back"; + // inside the search box it clears, then leaves the field. + if (event.key !== "Escape") return; + event.preventDefault(); + if (query.length > 0) onChange(""); + else event.currentTarget.blur(); }} placeholder="Search keybindings" aria-label="Search keybindings" @@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { return ( @@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) { ); } -interface KeybindingsListProps extends KeybindingRowActions { - rows: ReadonlyArray; - commandOptions: ReadonlyArray; +interface KeybindingsGroupsProps extends KeybindingRowActions { + groups: ReadonlyArray; + anchorIds: ReadonlyMap; savingCommand: KeybindingCommand | null; - isAddingBinding: boolean; - onCancelAdd: () => void; } -/** The add-binding row, one settings row per binding, and the empty state. */ -function KeybindingsList(props: KeybindingsListProps) { - const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = - props; - const newProps: NewKeybindingProps = { - commandOptions, - allRows: rows, - variables: rowActions.variables, - isSaving: savingCommand !== null, - onSave: rowActions.onSave, - onCancel: onCancelAdd, - }; - // Settings search jumps to a command, so only its first row anchors. - const anchorIds = useMemo(() => { - const ids = new Map(); - const seen = new Set(); - for (const row of rows) { - if (seen.has(row.command)) continue; - seen.add(row.command); - ids.set(row.id, keybindingSearchAnchorId(row.command)); - } - return ids; - }, [rows]); - return ( -
    - {isAddingBinding ? : null} - {rows.map((row) => ( +/** One titled section per command area, each holding its binding rows. */ +function KeybindingsGroups(props: KeybindingsGroupsProps) { + const { groups, anchorIds, savingCommand, ...rowActions } = props; + return groups.map((group) => ( + + {group.rows.map((row) => ( ))} - {rows.length === 0 && !isAddingBinding ? ( -
    - No keybindings match your search. -
    - ) : null} -
    - ); + + )); } /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { + // The label carries the whole sentence so assistive tech reads it without + // opening the tooltip. + const message = "The browser may claim some shortcuts first. The desktop app receives them all."; return ( -
    - - - Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app - for better keybinding support. - -
    + + + + + } + /> + + {message} + + ); } @@ -1347,11 +1299,22 @@ export function KeybindingsSettingsPanel() { availableEditors, ); const [query, setQuery] = useState(""); - const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); const [savingCommand, setSavingCommand] = useState(null); const [isAddingBinding, setIsAddingBinding] = useState(false); const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); + const groups = useMemo(() => groupKeybindingRows(rows), [rows]); + // Settings search jumps to a command, so only its first row anchors. + const anchorIds = useMemo(() => { + const ids = new Map(); + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.command)) continue; + seen.add(row.command); + ids.set(row.id, keybindingSearchAnchorId(row.command)); + } + return ids; + }, [rows]); // The search-target context is provided by this panel's own page container, // so the jump target is read from the route hash here. const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); @@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() { } event.preventDefault(); - setIsSearchOpen(true); - requestAnimationFrame(() => { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); @@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() { const cancelAdd = useCallback(() => setIsAddingBinding(false), []); - const bindingsCount = ( - - {rows.length + (isAddingBinding ? 1 : 0)}{" "} - {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"} - - ); - - const listProps: KeybindingsListProps = { - rows, + const rowActions: KeybindingRowActions = { allRows: rows, - commandOptions, variables: whenVariables, - savingCommand, - isAddingBinding, - onCancelAdd: cancelAdd, onSave: saveKeybinding, onReset: resetKeybinding, onRemove: removeKeybinding, @@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() { - - - setIsAddingBinding(true)} - aria-label="Add keybinding" - > - - - } - /> - Add keybinding - - - - - - } - /> - Open keybindings.json - -
    - } + headerAction={!isElectron ? : null} > - {!isElectron ? : null} - - +
    + + + + + + + } + /> + Open keybindings.json + +
    + + {isAddingBinding ? ( + + + + ) : null} + + {groups.length > 0 ? ( + + ) : ( + +
    + No keybindings match your search. +
    +
    + )} ); } From c5c6f9cb485b2db0f205797394bc95f9f575d139 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:00:21 +0300 Subject: [PATCH 26/59] feat(web): stop T3-owned subagents from Lineage (#15211) --- ...ThreadRelationshipsControl.agents.test.tsx | 111 +++++++++++++++++- .../chat/ThreadRelationshipsControl.tsx | 53 ++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index af1d42f3876b..6c7534d04884 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ projects: [] as unknown[], configs: new Map(), showTooltips: false, + command: vi.fn().mockResolvedValue({ _tag: "Success" }), })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); @@ -29,7 +30,7 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../lib/archivedThreadsState", () => ({ useArchivedThreadSnapshots: () => ({ snapshots: [] }), })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); vi.mock("../ui/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => children, TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => @@ -48,8 +49,116 @@ afterEach(async () => { state.projects = []; state.configs.clear(); state.showTooltips = false; + state.command.mockClear(); + state.projection = null; }); +it.each(["codex", "claudeAgent"])( + "stops only active app-owned %s subagents without opening their thread", + async (driver) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const parent = { + id: "parent", + lineage: { relationshipToParent: null }, + activeProviderThreadId: null, + }; + const child = { + id: "child", + title: "Worker", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + }; + const agent = { + id: "agent", + childThreadId: "child", + origin: "app_owned", + driver, + providerInstanceId: "codex", + title: "Worker", + prompt: "Check the change", + model: "gpt-5.4", + status: "running", + progress: null, + result: null, + startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"), + }; + state.shells = [{ environmentId: "test", source: child }]; + const projection = { + thread: parent, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [agent], + }; + state.projection = projection; + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); + await act(async () => stopButton().props.onClick()); + expect(state.command).toHaveBeenCalledWith({ + environmentId: "test", + input: { threadId: "child" }, + }); + expect(state.navigate).not.toHaveBeenCalled(); + + for (const status of ["starting", "running", "waiting"] as const) { + state.command.mockClear(); + state.shells = [ + { + environmentId: "test", + source: { + ...child, + activityRunStatus: status, + activityRunStartedAt: DateTime.makeUnsafe("2026-09-16T12:05:00Z"), + }, + }, + ]; + state.projection = { + ...projection, + subagents: [{ ...agent, origin: "provider_native", status: "completed" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] }; + await act(async () => renderer.update(cloneElement(panel))); + await act(async () => stopButton().props.onClick()); + expect(state.command).toHaveBeenCalledTimes(1); + expect(state.command).toHaveBeenLastCalledWith({ + environmentId: "test", + input: { threadId: "child" }, + }); + } + state.shells = [{ environmentId: "test", source: child }]; + for (const status of ["completed", "failed", "interrupted"]) { + state.projection = { ...projection, subagents: [{ ...agent, status }] }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + } + state.projection = { ...projection, subagents: [{ ...agent, startedAt: null }] }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + state.projection = { + ...projection, + subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); + }, +); + it("shows the matching child agent details and refreshes them when the agent settles", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const agent = { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 6d5236818699..cca32d57ab04 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -36,6 +36,7 @@ import { LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, + SquareIcon, UnplugIcon, } from "lucide-react"; import { useMemo, useState, type ReactNode } from "react"; @@ -55,6 +56,7 @@ import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadR import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import { THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, @@ -234,7 +236,9 @@ export function ThreadRelationshipsPanel(props: { const navigate = useNavigate(); const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); + const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); + const [stoppingThreadId, setStoppingThreadId] = useState(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = useMemo( @@ -310,6 +314,19 @@ export function ThreadRelationshipsPanel(props: { setBusyAction(null); }; + const stopSubagent = async (childThreadId: ThreadId) => { + if (stoppingThreadId !== null) return; + setStoppingThreadId(childThreadId); + const result = await interruptTurn({ + environmentId: props.environmentId, + input: { threadId: childThreadId }, + }); + setStoppingThreadId(null); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not stop subagent" }); + } + }; + const parentTitle = mergeTargetThreadId === null ? null @@ -365,6 +382,10 @@ export function ThreadRelationshipsPanel(props: { isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, node?.thread, ); + const canStop = + agent?.origin === "app_owned" && + agent.startedAt && + ["pending", "running", "waiting"].includes(agent.status); const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? agent?.title ?? threadId, isSubagent, @@ -416,7 +437,9 @@ export function ThreadRelationshipsPanel(props: { {agent ? ( agent.startedAt ? ( - + ) : null @@ -431,7 +454,7 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? (
    @@ -510,6 +533,32 @@ export function ThreadRelationshipsPanel(props: { {relationshipTooltip} )} + {canStop && agent ? ( +
    + + void stopSubagent(threadId)} + /> + } + > + {stoppingThreadId === threadId ? ( + + ) : ( + + )} + + Stop subagent + +
    + ) : null}
  • ); }) From ab28039ef77ca8847c6bfe845c3502ff7e348c30 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 21:00:50 -0300 Subject: [PATCH 27/59] feat(web): add fast actions to linked pull requests (#16627) --- .../pullRequest/PullRequestSpeedActions.tsx | 21 ++++--- .../pullRequest/ThreadPullRequestsPanel.tsx | 60 +++++++++++++++++-- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx index ee1bc5dcc02c..319e73d20a31 100644 --- a/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx @@ -11,13 +11,18 @@ import { usePullRequestDefaultMergeMethodResolver, } from "./usePullRequestActions"; -export interface PullRequestSpeedActionResult { - readonly entry: EnvironmentPullRequestEntry; +type PullRequestSpeedActionEntry = Pick< + EnvironmentPullRequestEntry, + "environmentId" | "projectId" | "host" | "repository" | "number" | "state" | "isDraft" +> & { readonly stack?: object | undefined }; + +export interface PullRequestSpeedActionResult { + readonly entry: Entry; readonly action: PullRequestAction; } /** No detail or stack reads until a merge is clicked, even on a long list. */ -export function PullRequestSpeedActions({ +export function PullRequestSpeedActions({ entry, visible, onActed, @@ -25,12 +30,12 @@ export function PullRequestSpeedActions({ sweeping = false, onCloseSweepStart, }: { - entry: EnvironmentPullRequestEntry; + entry: Entry; visible: boolean; - onActed: (result: PullRequestSpeedActionResult) => void; + onActed?: (result: PullRequestSpeedActionResult) => void; closing?: boolean; sweeping?: boolean; - onCloseSweepStart?: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; + onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void; }) { const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( entry.environmentId, @@ -45,7 +50,7 @@ export function PullRequestSpeedActions({ const { actionPending, perform } = usePullRequestActionRunner({ environmentId: entry.environmentId, reference, - onSuccess: (action) => onActed({ entry, action }), + onSuccess: (action) => onActed?.({ entry, action }), resolveMergeMethod: (detail) => { const allowed = detail.capabilities.mergeMethods.filter( (method) => detail.mergeCapabilities[method], @@ -102,7 +107,7 @@ export function PullRequestSpeedActions({ {action === "merge" && entry.stack ? "Open this pull request to merge its stack" - : action === "close" + : action === "close" && onCloseSweepStart ? "Close immediately, or drag across rows to close several" : `${label} immediately`} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 42b09f4387e7..237528702b4d 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -1,4 +1,5 @@ -import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveThreadPullRequestChains, visibleThreadPullRequests, @@ -14,9 +15,10 @@ import { import { useCallback, useMemo, useState } from "react"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink"; import { cn } from "~/lib/utils"; -import { useServerConfigs, useThreadShell } from "~/state/entities"; +import { useShortcutModifierState } from "~/shortcutModifierState"; +import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -42,6 +44,7 @@ import { pullRequestChecksStatePresentation, } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; +import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; const SOURCE_LABELS: Record = { manual: "Linked by you", @@ -74,11 +77,15 @@ function ChecksGlyph({ function LinkRow({ line, threadRef, + projectId, + speedMode, onUnlink, onSetWatching, }: { line: PullRequestListLine; threadRef: ScopedThreadRef; + projectId: ProjectId | null; + speedMode: boolean; onUnlink: (link: ThreadPullRequestLink) => void; /** Null when the environment cannot watch pull requests. */ onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null; @@ -97,6 +104,22 @@ function LinkRow({ const snapshot = link.snapshot; const open = snapshot === null || snapshot.state === "open"; const watching = link.watch !== undefined; + const actionEntry = + projectId !== null && + snapshot !== null && + snapshot.state !== "merged" && + detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github" + ? { + environmentId: threadRef.environmentId, + projectId, + host: link.host, + repository: link.repository, + number: link.number, + state: snapshot.state, + isDraft: snapshot.isDraft, + ...(link.stack === null ? {} : { stack: link.stack }), + } + : null; return (
    + {actionEntry !== null ? ( + + ) : null} {/* Out of the row's flow, so no row reserves a column for a button only the hovered one shows. It sits over the right end of the second line on the row's own hover color, fading in from the left, so it covers the time and leaves the diff counts alone. */} @@ -233,6 +259,8 @@ function LinkRow({ "pointer-events-none opacity-0 group-hover/pr-row:pointer-events-auto group-hover/pr-row:opacity-100", "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100", "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100", + "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden", + speedMode && actionEntry !== null && "hidden", )} > @@ -301,12 +329,24 @@ export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { const thread = useThreadShell(threadRef); + const projects = useProjects(); + const environmentProjects = useMemo( + () => + projects + .filter((project) => project.environmentId === threadRef.environmentId) + .toSorted((left, right) => + left.id === thread?.projectId ? -1 : right.id === thread?.projectId ? 1 : 0, + ), + [projects, threadRef.environmentId, thread?.projectId], + ); + const modifiers = useShortcutModifierState(true); + const speedMode = + modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true }); - const supportsWatch = - useServerConfigs().get(threadRef.environmentId)?.environment.capabilities - .threadPullRequestWatch === true; + const capabilities = useServerConfigs().get(threadRef.environmentId)?.environment.capabilities; + const supportsWatch = capabilities?.threadPullRequestWatch === true; const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); const handleUnlink = useCallback( @@ -377,6 +417,14 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread key={`${line.link.host}/${line.link.repository}#${line.link.number}`} line={line} threadRef={threadRef} + projectId={ + capabilities?.pullRequests === true + ? (findProjectForChangeRequest(environmentProjects, line.link)?.id ?? + thread?.projectId ?? + null) + : null + } + speedMode={speedMode} onUnlink={handleUnlink} onSetWatching={supportsWatch ? handleSetWatching : null} /> From 7202ba666080cfca1351c080d7aaa2d81bfa9a66 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:04:00 +0300 Subject: [PATCH 28/59] feat(web): open right panel tab menu with Mod+T (#15686) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 5 + .../RightPanelTabs.keyboard.test.tsx | 221 ++++++++++++++++++ .../src/components/RightPanelTabs.test.tsx | 10 + apps/web/src/components/RightPanelTabs.tsx | 33 ++- apps/web/src/routes/_chat.pull-requests.tsx | 2 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 7 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/RightPanelTabs.keyboard.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 89b74c063da4..e705a42d5976 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11603,6 +11603,8 @@ export default function ChatView(props: ChatViewProps) { ({ useTheme: () => ({ resolvedTheme: "light" }) })); +vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) })); + +let root: Root; +let container: HTMLDivElement; +const addFiles = vi.fn(); +const noop = () => undefined; + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + addFiles.mockClear(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function renderPanel(overrides: Partial> = {}) { + await act(() => + root.render( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={noop} + onCloseSurface={noop} + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={noop} + onAddBrowser={noop} + onAddBrowserInProfile={noop} + onAddTerminal={noop} + onAddDiff={noop} + onAddFiles={addFiles} + onAddPullRequest={noop} + onAddPullRequests={noop} + onAddDevice={noop} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + {...overrides} + > + content + , + ), + ); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); +} + +async function press(key: string, options: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options }); + await act(() => (document.activeElement ?? document.body).dispatchEvent(event)); + await act(() => vi.advanceTimersByTimeAsync(0)); + await act(() => vi.advanceTimersToNextFrame()); + return event; +} + +describe("right panel new-tab shortcut", () => { + it.each(["MacIntel", "Win32", "Linux x86_64"])( + "opens the menu on %s and chooses a tab", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await renderPanel(); + const event = await press( + "t", + platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true }, + ); + expect(event.defaultPrevented).toBe(true); + expect(document.querySelector('[role="menu"]')?.textContent).toContain( + "Linked pull requests", + ); + expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull(); + expect( + ( + await press("t", { + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + repeat: true, + }) + ).defaultPrevented, + ).toBe(true); + await press("f"); + expect(addFiles).toHaveBeenCalledOnce(); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }, + ); + + it("leaves the shortcut alone while the mounted panel is closed", async () => { + await renderPanel({ open: false }); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }); + + it.each(["Win32", "Linux x86_64"])( + "leaves Ctrl+T for the focused terminal on %s", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + await renderPanel({ + getShortcutContext: () => ({ + terminalFocus: true, + terminalOpen: true, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }); + const terminal = document.createElement("textarea"); + const onKeyDown = vi.fn(); + terminal.addEventListener("keydown", onKeyDown); + container.append(terminal); + await act(() => terminal.focus()); + const event = await press("t", { ctrlKey: true }); + expect(event.defaultPrevented).toBe(false); + expect(onKeyDown).toHaveBeenCalledWith(event); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }, + ); + + it("uses a custom binding from a text field and closes on Escape", async () => { + const input = document.createElement("input"); + await renderPanel({ + keybindings: compileResolvedKeybindingsConfig([{ key: "mod+y", command: "rightPanel.new" }]), + }); + document.body.append(input); + input.focus(); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + await press("y", { metaKey: true }); + expect(document.querySelector('[role="menu"]')).not.toBeNull(); + await press("Escape"); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + expect(document.activeElement).toBe( + container.querySelector('[aria-label="Add panel surface"]'), + ); + input.remove(); + }); + + it("does not open over another popup or during text composition", async () => { + await renderPanel(); + expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false); + const dialog = document.createElement("div"); + dialog.dataset.slot = "dialog-popup"; + container.append(dialog); + expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false); + expect(document.querySelector('[role="menu"]')).toBeNull(); + }); + + it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => { + const panel = { + mode: "sheet" as const, + surfaces: [{ id: "files" as const, kind: "files" as const }], + }; + await renderPanel(panel); + await press("t", { metaKey: true }); + await press("ArrowDown"); + await press("ArrowDown"); + await press("ArrowDown"); + expect(document.activeElement?.textContent).toContain("Files"); + await press("Enter"); + expect(addFiles).toHaveBeenCalledOnce(); + await press("t", { metaKey: true }); + await renderPanel({ ...panel, open: false }); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + await renderPanel(panel); + expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 25b0997813e1..823028eddff1 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { describe, expect, it } from "vite-plus/test"; import { @@ -98,6 +99,15 @@ function renderTabs( return renderToStaticMarkup( ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} surfaces={second ? [previewSurface, secondSurface] : [previewSurface]} environmentId={null} activeSurfaceId={previewSurface.id} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 914028f7c8cd..4f6f3607f470 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -11,6 +11,7 @@ import type { PreviewSessionSnapshot, ProjectId, PullRequestState, + ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -32,6 +33,7 @@ import { type ReactNode, useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -41,6 +43,7 @@ import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; +import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -83,6 +86,8 @@ interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; + keybindings: ResolvedKeybindingsConfig; + getShortcutContext: () => ShortcutMatchContext; /** Forwarded to PreviewPanelShell so this surface persists its own width. */ widthStorageKey?: string; /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ @@ -793,6 +798,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const addSurfaceTriggerRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ @@ -801,6 +807,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) { canScrollRight: false, }); + if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); + + const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if ( + resolveShortcutCommand(event, props.keybindings, { + context: { ...props.getShortcutContext(), rightPanelOpen: true }, + }) !== "rightPanel.new" + ) + return; + if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) { + addSurfaceTriggerRef.current?.focus(); + setAddSurfaceMenuOpen(true); + } + }); + useEffect(() => { + if (props.open === false) return; + document.addEventListener("keydown", onNewSurfaceKeyDown, true); + return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); + }, [props.open]); + const updateTabScrollState = useCallback(() => { const viewport = tabScrollViewport(tabListRef.current); if (!viewport) return; @@ -1214,9 +1244,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
    ); })} - {props.surfaces.length > 0 ? ( + {props.open !== false ? ( = [ { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, + { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, From 4b5c60485366421794787aee7513913f92eced29 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 17:12:49 -0700 Subject: [PATCH 29/59] fix(server): provider sessions clean up when their start is interrupted (#15571) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/device/LocalDeviceHost.ts | 156 ++-- .../Adapters/ClaudeAdapterV2.ts | 3 +- .../Adapters/CodexAdapterV2.ts | 4 +- .../Adapters/OpenCode2AdapterV2.test.ts | 19 + .../Adapters/OpenCode2AdapterV2.ts | 16 +- .../Adapters/OpenCodeAdapterV2.test.ts | 46 +- .../Adapters/OpenCodeAdapterV2.ts | 8 +- .../orchestration-v2/Adapters/PiAdapterV2.ts | 3 +- .../ProviderSessionManager.test.ts | 847 +++++++++++++++++- .../ProviderSessionManager.ts | 321 ++++--- 10 files changed, 1213 insertions(+), 210 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 2e3cb3d0f71a..65ddf6895742 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -383,62 +383,63 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); const origin = `http://127.0.0.1:${port}`; const scope = yield* Scope.make("sequential"); - const child = yield* spawner - .spawn( - ChildProcess.make( - nodePath, - [ - hubTool.entryPath, - "--port", - String(port), - "--host", - "127.0.0.1", - "--hide-sidebar", - "--hide-boot-device", - ], - { - detached: false, - shell: false, - stdout: "pipe", - stderr: "pipe", - env: yield* hubEnvironment(hostEnvironment).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(HostProcessPlatform, hostPlatform), - ), - }, - ), - ) - .pipe( - Effect.provideService(Scope.Scope, scope), - Effect.mapError( - (cause) => - new DeviceHost.DeviceHostError({ - hostId, - step: "starting the device hub", - cause, - }), - ), - ); - const startedAtMillis = yield* Clock.currentTimeMillis; - const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; - yield* Effect.forkIn(observeHubOutput(hub), scope); - yield* waitForHttpReady({ - baseUrl: origin, - path: "/readyz", - timeoutMs: HUB_READY_TIMEOUT_MS, - makeError: (info) => - new DeviceHost.DeviceHostError({ - hostId, - step: "waiting for the device hub to answer", - cause: info.cause, - }), - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.tapError(() => stopHub(hub)), - ); - yield* recordHub(hub, hubTool); - yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); - return hub; + // On failure or interrupt, from the spawn on: the hub is not recorded yet, + // so nothing else stops it. + return yield* Effect.gen(function* () { + const child = yield* spawner + .spawn( + ChildProcess.make( + nodePath, + [ + hubTool.entryPath, + "--port", + String(port), + "--host", + "127.0.0.1", + "--hide-sidebar", + "--hide-boot-device", + ], + { + detached: false, + shell: false, + stdout: "pipe", + stderr: "pipe", + env: yield* hubEnvironment(hostEnvironment).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(HostProcessPlatform, hostPlatform), + ), + }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "starting the device hub", + cause, + }), + ), + ); + const startedAtMillis = yield* Clock.currentTimeMillis; + const hub: HubProcess = { child, scope, origin, startedAtMillis, nodePath }; + yield* Effect.forkIn(observeHubOutput(hub), scope); + yield* waitForHttpReady({ + baseUrl: origin, + path: "/readyz", + timeoutMs: HUB_READY_TIMEOUT_MS, + makeError: (info) => + new DeviceHost.DeviceHostError({ + hostId, + step: "waiting for the device hub to answer", + cause: info.cause, + }), + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + yield* recordHub(hub, hubTool); + yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); + return hub; + }).pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore))); }); const observeHubOutput = (hub: HubProcess) => @@ -622,25 +623,30 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { ); yield* onPhase("starting"); const hub = yield* spawnHub(hubTool, nodePath); - yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.ignore, - ); - const candidate = helperPaths(hubTool); - const [axExists, cliExists] = yield* Effect.all([ - fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), - fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)), - ]); - const next: RunningHost = { - hub, - agentDevice: null, - helpers: { - serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, - serveSimCli: cliExists ? candidate.serveSimCli : null, - }, - }; - yield* Ref.set(runningRef, next); + // Until the hub is in runningRef, nothing else stops it, so publishing it + // is part of the guarded step. + const next = yield* Effect.gen(function* () { + yield* pruneLocalDeviceTools(config.baseDir, nodePath, "hub").pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); + const candidate = helperPaths(hubTool); + const [axExists, cliExists] = yield* Effect.all([ + fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), + fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)), + ]); + const next: RunningHost = { + hub, + agentDevice: null, + helpers: { + serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, + serveSimCli: cliExists ? candidate.serveSimCli : null, + }, + }; + yield* Ref.set(runningRef, next); + return next; + }).pipe(Effect.onError(() => stopHub(hub))); yield* Ref.set(restartDelayRef, 0); yield* Effect.forkDetach(superviseHub(hub, hubTool)); return next; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9af1c0efde4b..81f9abc001a3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7148,7 +7148,8 @@ export function makeClaudeAdapterV2( options: queryOptions, }) .pipe( - Effect.tapError(() => + // An interrupted open leaves the old process just as dead. + Effect.onError(() => // Same-native-thread replacement: the old process is already // dead, so its process-scoped roster is not authoritative. // First-ever failed open (no prior live query) must not invent diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 19517c349116..d562f876df49 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -6174,8 +6174,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi yield* Ref.update(pendingRootTurns, (current) => new Map(current).set(threadId, turnInput), ); + // Cleared on interrupt too, as startTurn does: an interrupted start + // must not adopt the native turn that a late turn/started reports. yield* client.request("thread/compact/start", { threadId }).pipe( - Effect.tapError(() => + Effect.onError(() => Ref.update(pendingRootTurns, (current) => { const next = new Map(current); next.delete(threadId); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 46792897e789..7274de8c386f 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -428,6 +428,25 @@ describe("OpenCode2 adapter", () => { }).pipe(Effect.scoped), ); + it.effect("ends a turn on the provider thread it started on", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + // A forked run starts on its own row for the same native session, while + // the adapter tracks the session under the id it minted for the fork. + const forkedRow = { + ...thread, + id: ProviderThreadId.make("provider-thread:opencode2-adapter:forked-run-row"), + }; + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(forkedRow)); + assert.equal((yield* Fiber.join(terminal))?.providerThreadId, forkedRow.id); + }).pipe(Effect.scoped), + ); + it.effect("ends the turn when its terminal event is one this build cannot decode", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 7716b75baaf9..102eb721bb1a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -1786,7 +1786,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const base = { type: "turn.terminal" as const, driver, - providerThreadId: state.providerThread.id, + // The provider thread the turn started on. After a native fork the + // session's own thread has a fresh id, and the terminal must name the + // thread the start was recorded against. + providerThreadId: turn.providerTurn.providerThreadId, providerTurnId: turn.providerTurn.id, runOrdinal: turn.input.runOrdinal, threadDisposition, @@ -2825,12 +2828,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr * reconciles. Retried a few times; the caller fails everything after that. */ const reconnect = Effect.gen(function* () { + // Returned on any failure, including the interrupt a closing session + // sends a reconnect still in flight, so a spawned server can idle-stop. const scope = yield* Scope.make(); - const next = yield* borrow.pipe( - Effect.provideService(Scope.Scope, scope), - Effect.tapError(() => Scope.close(scope, Exit.void)), - ); - const stream = yield* next.events.pipe(Effect.tapError(() => Scope.close(scope, Exit.void))); + const { next, stream } = yield* Effect.gen(function* () { + const next = yield* borrow.pipe(Effect.provideService(Scope.Scope, scope)); + return { next, stream: yield* next.events }; + }).pipe(Effect.onError(() => Scope.close(scope, Exit.void))); const previous = currentScope; connection = next; client = next.client; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 44632e02c66d..a8e28a377d1a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -182,7 +182,10 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct runtimePolicy: policy, }); const now = yield* DateTime.now; - const startTurn = (text = "hello") => + const startTurn = ( + text = "hello", + startProviderThread: OrchestrationV2ProviderThread = providerThread, + ) => runtime.startTurn({ appThread: { id: threadId, @@ -213,7 +216,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct providerTurnOrdinal: 1, attemptId: RunAttemptId.make(`attempt-opencode-${suffix}`), rootNodeId: NodeId.make(`node-opencode-${suffix}`), - providerThread, + providerThread: startProviderThread, message: { createdBy: "user", creationSource: "web", @@ -1291,6 +1294,45 @@ describe("OpenCodeAdapterV2", () => { }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); + it.effect("ends a turn on the provider thread it started on", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness( + "forked-row", + "native-opencode-forked-row", + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + session: { + create: async () => ({ + data: { id: "native-opencode-forked-row", time: { created: 1, updated: 1 } }, + }), + summarize: async () => ({ data: true }), + }, + }, + ); + // A forked run starts on its own row for the same native session, while + // the adapter tracks the session under the id it minted. + const forkedRow = { + ...harness.providerThread, + id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), + }; + yield* harness.startTurn("/compact", forkedRow); + const terminal = yield* harness.runtime.events.pipe( + Stream.filter( + (event): event is Extract => + event.type === "turn.terminal", + ), + Stream.runHead, + ); + assert.equal(Option.getOrUndefined(terminal)?.providerThreadId, forkedRow.id); + }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), + ); + it.effect("does not restore messages beyond OpenCode's persisted revert boundary", () => Effect.gen(function* () { const nativeEvents = asyncEventStream(); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index aad31d636325..510a4c859d36 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -279,6 +279,8 @@ interface ActiveOpenCodeTurn { readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + /** The provider thread the turn started on, which its terminal names. */ + readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; readonly providerTurnOrdinal: number; readonly runOrdinal: number; readonly runAttemptId: OrchestrationV2ProviderTurn["runAttemptId"]; @@ -2118,7 +2120,7 @@ export function makeOpenCodeAdapterV2( ? { type: "turn.terminal", driver: OPENCODE_PROVIDER, - providerThreadId: state.providerThread.id, + providerThreadId: turn.providerThreadId, providerTurnId: turn.providerTurnId, runOrdinal: turn.runOrdinal, failureItemOrdinal: itemOrdinal(turn, `terminal-failure:${turn.providerTurnId}`), @@ -2134,7 +2136,7 @@ export function makeOpenCodeAdapterV2( : { type: "turn.terminal", driver: OPENCODE_PROVIDER, - providerThreadId: state.providerThread.id, + providerThreadId: turn.providerThreadId, providerTurnId: turn.providerTurnId, runOrdinal: turn.runOrdinal, status, @@ -2256,6 +2258,7 @@ export function makeOpenCodeAdapterV2( modelSelection: state.appThread.modelSelection, runtimePolicy: state.parentSubagent.parentTurn.runtimePolicy, providerTurnId, + providerThreadId: providerTurn.providerThreadId, providerTurnOrdinal: providerTurn.ordinal, runOrdinal: state.parentSubagent.parentTurn.runOrdinal, runAttemptId: null, @@ -3192,6 +3195,7 @@ export function makeOpenCodeAdapterV2( modelSelection: turnInput.modelSelection, runtimePolicy: turnInput.runtimePolicy, providerTurnId, + providerThreadId: turnInput.providerThread.id, providerTurnOrdinal: turnInput.providerTurnOrdinal, runOrdinal: turnInput.runOrdinal, runAttemptId: turnInput.attemptId, diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 87e24f92b0e8..9270253d5c74 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -2679,8 +2679,9 @@ export function makePiAdapterV2( } // Pi fork replaces the session file, including for rollback. Persist // its new identity before any later request can fail or restart. + // An interrupted read leaves the identity just as unknown as a failed one. const forkState = yield* request({ type: "get_state" }).pipe( - Effect.tapError(() => + Effect.onError(() => Effect.sync(() => { threadState = null; }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 61a3dedafd53..bfb076ee3bf3 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -128,6 +128,30 @@ const layerFlakyReleaseEventSink = (flaky: FlakyReleaseWrites) => }), ).pipe(Layer.provide(layerTestEventSink)); +// Once armed, holds the next attach write until the writer is interrupted. +const layerPausingAttachEventSink = (pause: { + readonly armed: Ref.Ref; + readonly paused: Deferred.Deferred; +}) => + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const delegate = yield* EventSink.EventSinkV2; + return EventSink.EventSinkV2.of({ + ...delegate, + write: (input) => + Effect.gen(function* () { + const attach = input.events.some((event) => event.type === "provider-session.attached"); + if (attach && (yield* Ref.getAndSet(pause.armed, false))) { + yield* Deferred.succeed(pause.paused, undefined); + return yield* Effect.never; + } + return yield* delegate.write(input); + }), + }); + }), + ).pipe(Layer.provide(layerTestEventSink)); + const CodexCapabilities: OrchestrationV2ProviderCapabilities = CodexProviderCapabilitiesV2; const ExclusiveCapabilities: OrchestrationV2ProviderCapabilities = { ...CodexCapabilities, @@ -244,11 +268,13 @@ function makeProviderThread(input: { readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly now: DateTime.Utc; + readonly nativeThreadId?: string; }): OrchestrationV2ProviderThread { + const nativeThreadId = input.nativeThreadId ?? "native-thread"; return { id: input.idAllocator.derive.providerThread({ driver: CODEX_DRIVER, - nativeThreadId: "native-thread", + nativeThreadId, }), driver: CODEX_DRIVER, providerInstanceId: modelSelection.instanceId, @@ -257,7 +283,7 @@ function makeProviderThread(input: { ownerNodeId: null, nativeThreadRef: { driver: CODEX_DRIVER, - nativeId: "native-thread", + nativeId: nativeThreadId, strength: "strong", }, nativeConversationHeadRef: null, @@ -294,9 +320,29 @@ function makeProviderAdapter( }) => Effect.Effect; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; + readonly startTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + /** Registers the process's closeCount finalizer before `beforeOpen` runs. */ + readonly spawnBeforeOpen?: boolean; + /** Completed when a hanging scope close reaches its wedged finalizer. */ + readonly scopeCloseReached?: Deferred.Deferred; } = {}, ): ProviderAdapterV2Shape { + const countClose = Effect.addFinalizer(() => + Ref.update(state, (current) => ({ + ...current, + closeCount: current.closeCount + 1, + })), + ); + // Registered after countClose so it runs first on scope close, wedging the + // close before the closeCount finalizer, like a provider process that never + // yields its message stream. + const hangClose = Effect.addFinalizer(() => + (options.scopeCloseReached === undefined + ? Effect.void + : Deferred.succeed(options.scopeCloseReached, undefined) + ).pipe(Effect.andThen(Effect.never)), + ); return { instanceId: ProviderInstanceId.make("codex"), driver: CODEX_DRIVER, @@ -304,10 +350,20 @@ function makeProviderAdapter( planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), openSession: (input) => Effect.gen(function* () { + if (options.spawnBeforeOpen === true) { + yield* countClose; + if (options.hangSessionScopeClose === true) yield* hangClose; + } + if (options.mcpConfigs !== undefined && options.spawnBeforeOpen === true) { + yield* Ref.update(options.mcpConfigs, (configs) => [ + ...configs, + McpProviderSession.readMcpProviderSession(input.threadId), + ]); + } if (options.beforeOpen !== undefined) { yield* options.beforeOpen(input); } - if (options.mcpConfigs !== undefined) { + if (options.mcpConfigs !== undefined && options.spawnBeforeOpen !== true) { yield* Ref.update(options.mcpConfigs, (configs) => [ ...configs, McpProviderSession.readMcpProviderSession(input.threadId), @@ -329,17 +385,9 @@ function makeProviderAdapter( eventQueues, }; }); - yield* Effect.addFinalizer(() => - Ref.update(state, (current) => ({ - ...current, - closeCount: current.closeCount + 1, - })), - ); - if (options.hangSessionScopeClose === true) { - // Registered last so it runs first on scope close, wedging the - // close before the closeCount finalizer, like a provider process - // that never yields its message stream. - yield* Effect.addFinalizer(() => Effect.never); + if (options.spawnBeforeOpen !== true) { + yield* countClose; + if (options.hangSessionScopeClose === true) yield* hangClose; } return { @@ -365,7 +413,7 @@ function makeProviderAdapter( ...current, resumeCount: current.resumeCount + 1, })).pipe(Effect.as(threadInput.providerThread)), - startTurn: () => Effect.void, + startTurn: () => options.startTurn ?? Effect.void, steerTurn: () => Effect.void, interruptTurn: () => Ref.update(state, (current) => ({ @@ -408,18 +456,29 @@ function layerTest(input: { }) => Effect.Effect; readonly failReleaseEventWrites?: boolean; readonly flakyReleaseWrites?: FlakyReleaseWrites; + readonly pauseAttachWrite?: Parameters[0]; + /** Once armed, holds the next credential lookup until it is interrupted. */ + readonly pauseResolve?: { + readonly armed: Ref.Ref; + readonly paused: Deferred.Deferred; + }; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; + readonly startTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; + readonly spawnBeforeOpen?: boolean; + readonly scopeCloseReached?: Deferred.Deferred; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; }) { const layerConfiguredEventSink = input.flakyReleaseWrites !== undefined ? layerFlakyReleaseEventSink(input.flakyReleaseWrites) - : input.failReleaseEventWrites - ? layerFailingReleaseEventSink - : layerTestEventSink; + : input.pauseAttachWrite !== undefined + ? layerPausingAttachEventSink(input.pauseAttachWrite) + : input.failReleaseEventWrites + ? layerFailingReleaseEventSink + : layerTestEventSink; const layerRegistry = ProviderAdapterRegistry.layerSingle( makeProviderAdapter(input.state, { failEventStream: input.failEventStream ?? false, @@ -432,9 +491,18 @@ function layerTest(input: { ...(input.hangSessionScopeClose === undefined ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), + ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), + ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), + ...(input.scopeCloseReached === undefined + ? {} + : { scopeCloseReached: input.scopeCloseReached }), }), ); + const layerConfiguredMcpRegistry = + input.pauseResolve === undefined + ? layerTestMcpRegistry + : layerPausingMcpRegistry(input.pauseResolve); const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( @@ -449,7 +517,7 @@ function layerTest(input: { layerTestStores, layerConfiguredEventSink, IdAllocator.layer, - layerTestMcpRegistry, + layerConfiguredMcpRegistry, ProviderSessionManager.layerWithOptions({ idleTimeoutMs: input.idleTimeoutMs, ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }), @@ -460,7 +528,7 @@ function layerTest(input: { layerConfiguredEventSink, IdAllocator.layer, layerProviderEventIngestorTest, - layerTestMcpRegistry, + layerConfiguredMcpRegistry, layerTestStores, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), ...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]), @@ -489,6 +557,28 @@ const layerTestMcpRegistry = Layer.effect( Layer.provide(NodeServices.layer), ); +const layerPausingMcpRegistry = (pause: { + readonly armed: Ref.Ref; + readonly paused: Deferred.Deferred; +}) => + Layer.effect( + McpSessionRegistry.McpSessionRegistry, + Effect.gen(function* () { + const delegate = yield* McpSessionRegistry.McpSessionRegistry; + return McpSessionRegistry.McpSessionRegistry.of({ + ...delegate, + resolve: (rawToken) => + Ref.getAndSet(pause.armed, false).pipe( + Effect.flatMap((armed) => + armed + ? Deferred.succeed(pause.paused, undefined).pipe(Effect.andThen(Effect.never)) + : delegate.resolve(rawToken), + ), + ), + }); + }), + ).pipe(Layer.provide(layerTestMcpRegistry)); + function makeBrowserAccessProject(projectId: ProjectId): Project { return { id: projectId, @@ -997,6 +1087,498 @@ it.effect("ProviderSessionManagerV2 opens a duplicate session only once", () => }), ); +it.effect("ProviderSessionManagerV2 cleans up an open interrupted mid-handshake", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const handshakeStarted = yield* Deferred.make(); + const holdHandshake = yield* Ref.make(true); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-interrupted-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + const opening = yield* manager + .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) + .pipe(Effect.forkScoped); + yield* Deferred.await(handshakeStarted); + const issued = (yield* Ref.get(mcpConfigs)).at(-1); + const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(token); + assert.isDefined(yield* registry.resolve(token!)); + + // A Stop while the provider is still starting. + yield* Fiber.interrupt(opening); + + // The process started for this open is stopped, and the credential minted + // for it revoked. + assert.equal((yield* Ref.get(state)).closeCount, 1); + assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + + // Nothing of the interrupted open is left behind: the next open starts a + // fresh process with a fresh credential that a later release revokes, + // which a leaked reservation would prevent. + yield* Ref.set(holdHandshake, false); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const replacement = (yield* Ref.get(mcpConfigs)).at(-1); + const replacementToken = replacement?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(replacementToken); + assert.notEqual(replacementToken, token); + const projection = yield* projectionStore.getThreadProjection(threadId); + assert.equal(projection.providerSessions.at(-1)?.status, "ready"); + + yield* manager.close(providerSessionId); + assert.isUndefined(yield* registry.resolve(replacementToken!)); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + mcpConfigs, + beforeOpen: () => + Ref.get(holdHandshake).pipe( + Effect.flatMap((hold) => + hold + ? Deferred.succeed(handshakeStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ), + ), + // The process is spawned before the handshake that is interrupted. + spawnBeforeOpen: true, + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 cleans up an interrupted open whose scope close hangs", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const handshakeStarted = yield* Deferred.make(); + const scopeCloseReached = yield* Deferred.make(); + const releaseRetry = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-interrupted-hung-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + const opening = yield* manager + .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) + .pipe(Effect.forkChild); + yield* Deferred.await(handshakeStarted); + const issued = (yield* Ref.get(mcpConfigs)).at(-1); + const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(token); + + // The interrupt starts cleanup; the scope close then never finishes. + const interrupter = yield* Fiber.interrupt(opening).pipe(Effect.forkChild); + yield* Deferred.await(scopeCloseReached); + + // The session cleanup already ran, ahead of the stuck close. + assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + // The close is time-boxed, so the interrupter returns and the session's + // open lock is free for the next open. + yield* TestClock.adjust("30 seconds"); + yield* Fiber.join(interrupter); + yield* Deferred.succeed(releaseRetry, undefined); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + assert.equal((yield* Ref.get(state)).openCount, 1); + + // Its close hangs as well; release it while the test clock can still move. + const stopping = yield* manager.shutdown.pipe(Effect.forkChild); + yield* TestClock.adjust("30 seconds"); + yield* Fiber.join(stopping); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + mcpConfigs, + // The first open hangs in its handshake; the retry completes. + beforeOpen: () => + Deferred.isDone(handshakeStarted).pipe( + Effect.flatMap((retry) => + retry + ? Deferred.await(releaseRetry) + : Deferred.succeed(handshakeStarted, undefined).pipe( + Effect.andThen(Effect.never), + ), + ), + ), + spawnBeforeOpen: true, + hangSessionScopeClose: true, + scopeCloseReached, + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 releases an idle session whose turn start was stopped", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const startTurnReached = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-stopped-turn-start"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const starting = yield* runtime + .startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "stopped before the provider accepted it", + attachments: [], + }, + modelSelection, + runtimePolicy, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(startTurnReached); + + // Stop lands while the provider is still accepting the turn. No terminal + // event follows, so the session must count itself idle again. + yield* Fiber.interrupt(starting); + yield* TestClock.adjust("2 seconds"); + yield* Effect.yieldNow; + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + startTurn: Deferred.succeed(startTurnReached, undefined).pipe( + Effect.andThen(Effect.never), + ), + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a shared session busy when another thread's start is stopped early", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const projectId = ProjectId.make("project-provider-session-manager-shared-stopped-start"); + // Blocks the next project read, so a re-attach can be stopped before its + // start marks the session busy. + const holdProjectRead = yield* Ref.make(false); + const projectReadHeld = yield* Deferred.make(); + const projectServiceLayer = Layer.mock(ProjectService.ProjectService)({ + getById: (requestedProjectId) => + Ref.get(holdProjectRead).pipe( + Effect.flatMap((hold) => + hold + ? Deferred.succeed(projectReadHeld, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed(Option.some(makeBrowserAccessProject(requestedProjectId))), + ), + ), + }); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadA = ThreadId.make("thread-provider-session-manager-shared-stopped-a"); + const threadB = ThreadId.make("thread-provider-session-manager-shared-stopped-b"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now, projectId }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now, projectId }), + ], + }); + const runtime = yield* manager.open({ + threadId: threadA, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: threadB, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const startTurn = (threadId: ThreadId) => + Effect.gen(function* () { + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + return yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + }); + + // B's turn is accepted and still running. + yield* startTurn(threadB); + + // A detaches, and its next start is stopped while re-attaching, before + // it marks the session busy. + yield* manager.detach({ providerSessionId, threadId: threadA }); + yield* Ref.set(holdProjectRead, true); + const startingA = yield* startTurn(threadA).pipe(Effect.forkChild); + yield* Deferred.await(projectReadHeld); + yield* Fiber.interrupt(startingA); + + // B's running turn keeps the session busy past the idle timeout. + yield* TestClock.adjust("2 seconds"); + yield* Effect.yieldNow; + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + projectServiceLayer, + serverSettingsLayer: ServerSettings.layerTest({ + projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: true } }, + }), + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a shared session busy when a stopped start still ends its turn", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + // A's adapter start installs its turn, then is stopped mid-request; like + // OpenCode2, the adapter still ends that turn with turn.terminal later. + const holdStart = yield* Ref.make(false); + const startHeld = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadA = ThreadId.make("thread-provider-session-manager-stopped-ended-a"); + const threadB = ThreadId.make("thread-provider-session-manager-stopped-ended-b"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: threadA, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: threadB, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThreadOf = (threadId: ThreadId) => + makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${threadId}`, + }); + const startTurn = (threadId: ThreadId) => + Effect.gen(function* () { + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + return yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: providerThreadOf(threadId), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + }); + + // B's turn is accepted and still running. + yield* startTurn(threadB); + + // A's start is stopped after the adapter began the turn. + yield* Ref.set(holdStart, true); + const startingA = yield* startTurn(threadA).pipe(Effect.forkChild); + yield* Deferred.await(startHeld); + yield* Fiber.interrupt(startingA); + + // The adapter ends A's turn anyway. + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(queue); + yield* Queue.offer(queue!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThreadOf(threadA).id, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "native-turn-stopped-a", + }), + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + + // B's running turn keeps the session busy past the idle timeout. + yield* TestClock.adjust("2 seconds"); + yield* Effect.yieldNow; + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 1000, + startTurn: Ref.get(holdStart).pipe( + Effect.flatMap((hold) => + hold + ? Deferred.succeed(startHeld, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ), + ), + }), + ), + ); + }), +); + +it.effect("ProviderSessionManagerV2 stops a session still opening when its layer shuts down", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const handshakeStarted = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-shutdown-during-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + // Detached, like an open the layer does not own: only the session + // scope's parent can stop its process when the layer closes. + yield* manager + .open({ threadId, providerSessionId, modelSelection, runtimePolicy }) + .pipe(Effect.forkDetach); + yield* Deferred.await(handshakeStarted); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }).pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + beforeOpen: () => + Deferred.succeed(handshakeStarted, undefined).pipe(Effect.andThen(Effect.never)), + spawnBeforeOpen: true, + }), + ), + ); + + assert.equal((yield* Ref.get(state)).closeCount, 1); + }), +); + it.effect("ProviderSessionManagerV2 releases live sessions when its layer shuts down", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -1368,6 +1950,228 @@ it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persist }), ); +it.effect( + "ProviderSessionManagerV2 keeps a thread attached when an earlier attach of it is interrupted", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const armed = yield* Ref.make(false); + const paused = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const owner = ThreadId.make("thread-provider-session-manager-attach-race-owner"); + const threadId = ThreadId.make("thread-provider-session-manager-attach-race"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: owner, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const resume = runtime.resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-attach-race", + }), + }); + + // The first attach of the thread is stopped after attaching it, while + // a second attach of the same thread is already on its way. + yield* Ref.set(armed, true); + const first = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(paused); + const second = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(first); + yield* Fiber.join(second); + + // The second attach owns the thread's attachment and credential. + const config = McpProviderSession.readMcpProviderSession(threadId); + assert.isDefined(config); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.equal((yield* registry.resolve(token))?.thread.threadId, threadId); + }).pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, pauseAttachWrite: { armed, paused } }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a replacement session's attachment when a stale attach is interrupted", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const armed = yield* Ref.make(false); + const paused = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const eventStore = yield* EventStore.EventStoreV2; + const now = yield* DateTime.now; + const owner = ThreadId.make("thread-provider-session-manager-stale-attach-owner"); + const threadId = ThreadId.make("thread-provider-session-manager-stale-attach"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: owner, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + // An attach of the thread to this session stalls mid-write... + yield* Ref.set(armed, true); + const stale = yield* runtime + .resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-stale-attach", + }), + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(paused); + + // ...while the session is replaced and the thread opens the new one. + yield* manager.release({ providerSessionId, reason: "runtime_error" }); + const replacement = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const config = McpProviderSession.readMcpProviderSession(threadId); + assert.isDefined(config); + + yield* Fiber.interrupt(stale); + + // The replacement keeps the thread and its credential. + assert.equal( + McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, + config!.providerSessionId, + ); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.equal((yield* registry.resolve(token))?.thread.threadId, threadId); + // Still attached: resuming on the replacement does not attach the thread again. + const attachedEvents = eventStore + .read({ threadId, eventType: "provider-session.attached" }) + .pipe( + Stream.runCollect, + Effect.map((events) => events.length), + ); + const attachedBefore = yield* attachedEvents; + yield* replacement.resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-stale-attach", + }), + }); + assert.equal(yield* attachedEvents, attachedBefore); + }).pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, pauseAttachWrite: { armed, paused } }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 revokes a reused credential after a resume stopped while checking it", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const armed = yield* Ref.make(false); + const paused = yield* Deferred.make(); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const owner = ThreadId.make("thread-provider-session-manager-resolve-stop-owner"); + const threadId = ThreadId.make("thread-provider-session-manager-resolve-stop"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: owner, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: owner, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const resume = runtime.resumeThread({ + threadId, + providerThread: makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: "native-resolve-stop", + }), + }); + // The thread gets a credential, then detaches and keeps it for a re-attach. + yield* resume; + yield* manager.detach({ providerSessionId, threadId }); + const config = McpProviderSession.readMcpProviderSession(threadId); + assert.isDefined(config); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + + // A re-attach is stopped while it checks whether that credential is reusable. + yield* Ref.set(armed, true); + const stopped = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(paused); + yield* Fiber.interrupt(stopped); + + // Nothing holds the credential now, so a terminal release revokes it. + yield* manager.release({ providerSessionId, reason: "manual_shutdown" }); + assert.isUndefined(yield* registry.resolve(token)); + }).pipe( + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, pauseResolve: { armed, paused } }), + ), + ); + }), +); + it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP credentials", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -3011,17 +3815,20 @@ it.effect( providerInstanceId: modelSelection.instanceId, threadId: firstThreadId, }); + // Each thread has its own native thread on the shared session. const firstProviderThread = makeProviderThread({ idAllocator, threadId: firstThreadId, providerSessionId, now, + nativeThreadId: "native-thread-a", }); const secondProviderThread = makeProviderThread({ idAllocator, threadId: secondThreadId, providerSessionId, now, + nativeThreadId: "native-thread-b", }); const firstRunId = idAllocator.derive.run({ threadId: firstThreadId, ordinal: 1 }); const secondRunId = idAllocator.derive.run({ threadId: secondThreadId, ordinal: 1 }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 5730432aad87..f84441129cc3 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -9,6 +9,7 @@ import { ProviderInstanceId, ProviderSessionId, ThreadId, + type ProviderThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -59,6 +60,10 @@ import * as ProjectionStore from "./ProjectionStore.ts"; const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; + +/** The identity a turn's start and its `turn.terminal` share. */ +const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => + `${providerThreadId}#${runOrdinal}`; const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; export const ProviderSessionReleaseReason = Schema.Literals([ @@ -213,7 +218,12 @@ interface LiveSessionEntry { readonly requestEventPermit: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly idleGeneration: number; - readonly busyCount: number; + /** + * Turns this session is running, keyed by `busyTurnKey`. A turn's start adds + * it and its `turn.terminal` (or a failed start) removes it, so a turn can + * only clear itself and the session is idle when the set is empty. + */ + readonly busyTurns: ReadonlySet; readonly lastActivityAtMs: number; readonly idleFiber: Fiber.Fiber | null; /** Set when idle release is deferred for pending background work; bounds total deferral. */ @@ -472,7 +482,17 @@ export const layerWithOptions = ( // revoke the credential between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - const resolved = yield* mcpSessionRegistry.resolve(rawToken); + // The caller only learns of the reservation once this returns, + // so a stop while resolving must drop it here. + const resolved = yield* mcpSessionRegistry + .resolve(rawToken) + .pipe( + Effect.onInterrupt(() => + Effect.sync(() => + dropMcpCredentialReservation(threadId, existing.providerSessionId), + ), + ), + ); if ( resolved !== undefined && resolved.thread.threadId === threadId && @@ -842,7 +862,8 @@ export const layerWithOptions = ( } if ( input.onlyIfIdleGeneration !== undefined && - (existing.busyCount > 0 || existing.idleGeneration !== input.onlyIfIdleGeneration) + (existing.busyTurns.size > 0 || + existing.idleGeneration !== input.onlyIfIdleGeneration) ) { return ["kept", current] as const; } @@ -861,6 +882,45 @@ export const layerWithOptions = ( ] as const; }); + // Scope close can wedge on a misbehaving adapter finalizer (e.g. a + // provider process that never yields its message stream). Time-box it so + // the caller, and any lock or worker it holds, moves on and leaves a + // diagnosable trail. A close that finishes late is still logged. + const closeScopeWithin = ( + scope: Scope.Closeable, + annotations: { readonly providerSessionId?: ProviderSessionId; readonly reason: string }, + ) => + Effect.gen(function* () { + const closeFiber = yield* Scope.close(scope, Exit.void).pipe( + Effect.exit, + Effect.forkDetach({ startImmediately: true }), + ); + const closeExit = yield* Fiber.join(closeFiber).pipe( + Effect.timeoutOption(RELEASE_SCOPE_CLOSE_TIMEOUT_MS), + ); + if (Option.isNone(closeExit)) { + yield* Effect.logWarning("orchestration-v2.provider-session-scope-close-timeout", { + ...annotations, + timeoutMs: RELEASE_SCOPE_CLOSE_TIMEOUT_MS, + }); + yield* Fiber.join(closeFiber).pipe( + Effect.flatMap((exit) => + Exit.isFailure(exit) + ? Effect.logWarning("orchestration-v2.provider-session-scope-close-failed", { + ...annotations, + cause: exit.cause, + }) + : Effect.logInfo( + "orchestration-v2.provider-session-scope-close-completed-late", + annotations, + ), + ), + Effect.forkDetach, + ); + } + return closeExit; + }); + const releaseEntry = (input: { readonly providerSessionId: ProviderSessionId; readonly reason: ProviderSessionReleaseReason; @@ -889,49 +949,10 @@ export const layerWithOptions = ( input.detail ?? `Provider session released: ${input.reason}.`, ); } - // Scope close can wedge on a misbehaving adapter finalizer - // (e.g. a provider process that never yields its message - // stream). Time-box it so release still persists released - // events and leaves a diagnosable trail instead of silently - // parking the session as "ready" forever. - const closeFiber = yield* Scope.close(entry.scope, Exit.void).pipe( - Effect.exit, - Effect.forkDetach({ startImmediately: true }), - ); - const closeExit = yield* Fiber.join(closeFiber).pipe( - Effect.timeoutOption(RELEASE_SCOPE_CLOSE_TIMEOUT_MS), - ); - if (Option.isNone(closeExit)) { - yield* Effect.logWarning( - "orchestration-v2.provider-session-scope-close-timeout", - { - providerSessionId: input.providerSessionId, - reason: input.reason, - timeoutMs: RELEASE_SCOPE_CLOSE_TIMEOUT_MS, - }, - ); - yield* Fiber.join(closeFiber).pipe( - Effect.flatMap((exit) => - Exit.isFailure(exit) - ? Effect.logWarning( - "orchestration-v2.provider-session-scope-close-failed", - { - providerSessionId: input.providerSessionId, - reason: input.reason, - cause: exit.cause, - }, - ) - : Effect.logInfo( - "orchestration-v2.provider-session-scope-close-completed-late", - { - providerSessionId: input.providerSessionId, - reason: input.reason, - }, - ), - ), - Effect.forkDetach, - ); - } + const closeExit = yield* closeScopeWithin(entry.scope, { + providerSessionId: input.providerSessionId, + reason: input.reason, + }); const records = { entry, reason: input.reason, @@ -1022,7 +1043,7 @@ export const layerWithOptions = ( const entry = current.get(key); if ( entry === undefined || - entry.busyCount > 0 || + entry.busyTurns.size > 0 || entry.idleGeneration !== input.generation ) { return; @@ -1044,7 +1065,7 @@ export const layerWithOptions = ( const latestEntry = latest.get(key); if ( latestEntry === undefined || - latestEntry.busyCount > 0 || + latestEntry.busyTurns.size > 0 || latestEntry.idleGeneration !== input.generation || latestEntry.runtime !== probedRuntime ) { @@ -1076,7 +1097,7 @@ export const layerWithOptions = ( } // hasPendingBackgroundWork yields to the adapter, so the idle // decision above can go stale; the generation guard revalidates - // busyCount and idleGeneration inside releaseEntry's atomic + // busyTurns and idleGeneration inside releaseEntry's atomic // entry removal. yield* releaseEntry({ providerSessionId: input.providerSessionId, @@ -1113,7 +1134,7 @@ export const layerWithOptions = ( const key = sessionKey(providerSessionId); const current = yield* Ref.get(sessions); const entry = current.get(key); - if (entry === undefined || entry.busyCount > 0) { + if (entry === undefined || entry.busyTurns.size > 0) { return; } @@ -1126,7 +1147,7 @@ export const layerWithOptions = ( const lastActivityAtMs = yield* Clock.currentTimeMillis; yield* Ref.update(sessions, (latest) => { const latestEntry = latest.get(key); - if (latestEntry === undefined || latestEntry.busyCount > 0) { + if (latestEntry === undefined || latestEntry.busyTurns.size > 0) { return latest; } const updated = new Map(latest); @@ -1164,6 +1185,7 @@ export const layerWithOptions = ( }), ); + /** Returns the runtime the thread was attached to, or undefined if it already was. */ const attachThread = (input: { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; @@ -1173,25 +1195,34 @@ export const layerWithOptions = ( Ref.modify(sessions, (current) => { const entry = current.get(sessionKey(input.providerSessionId)); if (entry === undefined || entry.attachedThreadIds.has(input.threadId)) { - return [false, current] as const; + return [undefined, current] as const; } const updated = new Map(current); updated.set(sessionKey(input.providerSessionId), { ...entry, attachedThreadIds: new Set([...entry.attachedThreadIds, input.threadId]), }); - return [true, updated] as const; + return [entry.runtime, updated] as const; }), ); + /** + * Undoes an attach to `runtime`. A replacement session that reopened under + * the same id since is left alone. + */ const removeThreadAttachment = (input: { readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; + readonly runtime: ProviderAdapterV2SessionRuntime; }) => Ref.update(sessions, (current) => { const key = sessionKey(input.providerSessionId); const entry = current.get(key); - if (entry === undefined || !entry.attachedThreadIds.has(input.threadId)) { + if ( + entry === undefined || + entry.runtime !== input.runtime || + !entry.attachedThreadIds.has(input.threadId) + ) { return current; } const attachedThreadIds = new Set(entry.attachedThreadIds); @@ -1245,6 +1276,7 @@ export const layerWithOptions = ( readonly providerInstanceId: ProviderInstanceId; }) => Effect.suspend(() => { + let attachedTo: ProviderAdapterV2SessionRuntime | undefined; let preparedForCleanup: PreparedMcpCredential | undefined; let reservationDropped = false; const dropReservation = () => { @@ -1253,12 +1285,17 @@ export const layerWithOptions = ( dropMcpCredentialReservation(input.threadId, preparedForCleanup.mcpCredentialId); } }; - return Effect.gen(function* () { - const attached = yield* threadAttachment.withLock( - threadAttachmentKey(input), - attachThread(input), + // The whole attach, including undoing a failed one, holds the + // thread's lock: a concurrent attach of the same thread waits, so it + // never sees an attachment that this call is about to roll back. + const attach = Effect.gen(function* () { + const attached = yield* attachThread(input).pipe( + // Recorded with no gap for an interrupt: cleanup undoes only an + // attach this call made, never one an earlier open made. + Effect.tap((runtime) => Effect.sync(() => (attachedTo = runtime))), + Effect.uninterruptible, ); - if (attached) { + if (attached !== undefined) { const prepared = yield* prepareMcpSession(input.threadId, input.providerInstanceId); preparedForCleanup = prepared; if (prepared.mcpCredentialId !== undefined) { @@ -1288,22 +1325,46 @@ export const layerWithOptions = ( } } }).pipe( - Effect.tapError(() => - removeThreadAttachment(input).pipe( - // Revoke only a credential this attach freshly minted: a REUSED - // credential is by definition held by another live provider - // process, and revoking it thread-wide would break that - // process's MCP client mid-conversation. - Effect.andThen( - Effect.suspend(() => { - dropReservation(); - return preparedForCleanup?.issued === true - ? clearMcpSession(input.threadId, preparedForCleanup.mcpCredentialId) - : Effect.void; - }), - ), - ), + // An interrupted attach is undone too, so the next attach writes + // the attachment instead of finding the thread already attached. + Effect.onError(() => + attachedTo === undefined + ? Effect.void + : removeThreadAttachment({ ...input, runtime: attachedTo }).pipe( + Effect.andThen( + Effect.suspend(() => { + dropReservation(); + // Revoke only a credential this attach freshly minted: a + // REUSED credential is by definition held by another + // live provider process, and revoking it thread-wide + // would break that process's MCP client mid-conversation. + if (preparedForCleanup?.issued !== true) return Effect.void; + const mcpCredentialId = preparedForCleanup.mcpCredentialId; + const attachedRuntime = attachedTo; + // As in release: a replacement session (or an open + // configuring one) may have taken the credential up. + return Ref.get(sessions).pipe( + Effect.flatMap((current) => + (mcpCredentialId !== undefined && + isMcpCredentialReserved(input.threadId, mcpCredentialId)) || + Array.from(current.values()).some( + (other) => + other.runtime !== attachedRuntime && + (other.attachedThreadIds.has(input.threadId) || + (mcpCredentialId !== undefined && + other.mcpCredentialIdByThread.get(input.threadId) === + mcpCredentialId)), + ) + ? Effect.void + : clearMcpSession(input.threadId, mcpCredentialId), + ), + ); + }), + ), + ), ), + ); + return threadAttachment.withLock(threadAttachmentKey(input), attach).pipe( // The entry's own record (written above while the thread is // attached) guards the credential from here on; the reservation // is only needed until then. Ensuring covers defects/interrupts. @@ -1311,7 +1372,7 @@ export const layerWithOptions = ( ); }); - const markBusy = (providerSessionId: ProviderSessionId) => + const markBusy = (providerSessionId: ProviderSessionId, turnKey: string) => withActivityError( providerSessionId, Effect.gen(function* () { @@ -1325,7 +1386,7 @@ export const layerWithOptions = ( const updated = new Map(current); updated.set(key, { ...entry, - busyCount: entry.busyCount + 1, + busyTurns: new Set(entry.busyTurns).add(turnKey), idleFiber: null, lastActivityAtMs: now, pinnedSinceMs: null, @@ -1336,7 +1397,10 @@ export const layerWithOptions = ( }), ); - const markIdle = (providerSessionId: ProviderSessionId) => + // Clearing a turn that is not marked busy (one whose failed start already + // cleared it, or a subagent turn the manager never started) only + // records activity. + const markIdle = (providerSessionId: ProviderSessionId, turnKey: string) => withActivityError( providerSessionId, Effect.gen(function* () { @@ -1347,10 +1411,12 @@ export const layerWithOptions = ( if (entry === undefined) { return current; } + const busyTurns = new Set(entry.busyTurns); + busyTurns.delete(turnKey); const updated = new Map(current); updated.set(key, { ...entry, - busyCount: Math.max(0, entry.busyCount - 1), + busyTurns, lastActivityAtMs: now, }); return updated; @@ -1536,13 +1602,32 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( - Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))), + // A start that fails or is stopped may never emit turn.terminal, + // so it clears its own turn or the session never goes idle. If + // the adapter emits the terminal anyway, clearing the same turn + // again changes nothing, so another thread's turn on a shared + // session stays busy either way. Effect.andThen( - runtime.startTurn(input).pipe(turnMetrics("send", input.modelSelection.model)), - ), - Effect.catch((error) => - observeActivity(providerSessionId, markIdle(providerSessionId)).pipe( - Effect.andThen(Effect.fail(error)), + Effect.acquireUseRelease( + observeActivity( + providerSessionId, + markBusy( + providerSessionId, + busyTurnKey(input.providerThread.id, input.runOrdinal), + ), + ), + () => + runtime.startTurn(input).pipe(turnMetrics("send", input.modelSelection.model)), + (_, exit) => + Exit.isFailure(exit) + ? observeActivity( + providerSessionId, + markIdle( + providerSessionId, + busyTurnKey(input.providerThread.id, input.runOrdinal), + ), + ) + : Effect.void, ), ), ), @@ -1605,7 +1690,10 @@ export const layerWithOptions = ( return observeActivity( entry.runtime.providerSessionId, event.type === "turn.terminal" - ? markIdle(entry.runtime.providerSessionId) + ? markIdle( + entry.runtime.providerSessionId, + busyTurnKey(event.providerThreadId, event.runOrdinal), + ) : touchActivity(entry.runtime.providerSessionId), ).pipe( Effect.andThen( @@ -1695,6 +1783,12 @@ export const layerWithOptions = ( ); }; + // Parent of every session scope. On layer close, shutdown releases the + // live sessions first, then closes any session whose open is still in + // flight, time-boxed so a stuck adapter cannot hold up server shutdown. + // Parallel, so one session whose close hangs does not stop the rest from + // closing within the time box. + const sessionScopes = yield* Scope.make("parallel"); const shutdown = Effect.gen(function* () { const activeSessions = [...(yield* Ref.get(sessions)).values()]; yield* Effect.forEach( @@ -1714,7 +1808,11 @@ export const layerWithOptions = ( { discard: true }, ); }); - yield* Effect.addFinalizer(() => shutdown); + yield* Effect.addFinalizer(() => + shutdown.pipe( + Effect.ensuring(closeScopeWithin(sessionScopes, { reason: "server_shutdown" })), + ), + ); return ProviderSessionManagerV2.of({ shutdown, @@ -1783,7 +1881,7 @@ export const layerWithOptions = ( dropMcpCredentialReservation(input.threadId, mcpCredentialId); } }); - const sessionScope = yield* Scope.make(); + const sessionScope = yield* Scope.fork(sessionScopes); const runtime = yield* adapter .openSession({ threadId: input.threadId, @@ -1805,21 +1903,30 @@ export const layerWithOptions = ( }) .pipe( Effect.provideService(Scope.Scope, sessionScope), - Effect.tapError(() => - Scope.close(sessionScope, Exit.void).pipe( - Effect.ignore, - Effect.andThen(dropReservation), - // Revoke only a credential this open freshly minted: a - // reused credential is held by another live provider - // process and must survive this open's failure. + // Any failure, including a Stop that interrupts a slow + // handshake, stops the provider process this open started. + // The session cleanup runs first, and the close is + // time-boxed: this runs under the session's open lock, so an + // adapter finalizer that never finishes must not hold the + // interrupter, the lock, or later opens. + Effect.onError(() => + dropReservation.pipe( + // Clear only a session this open freshly set up: a reused + // one is held by another live provider process and must + // survive this open's failure. Effect.andThen( prepared.issued ? clearMcpSession(input.threadId, mcpCredentialId) : Effect.void, ), + Effect.ensuring( + closeScopeWithin(sessionScope, { + providerSessionId: input.providerSessionId, + reason: "open_failed", + }), + ), ), ), - Effect.onInterrupt(() => dropReservation), Effect.mapError( (cause) => new ProviderSessionOpenError({ @@ -1854,7 +1961,7 @@ export const layerWithOptions = ( requestEventPermit: yield* Semaphore.make(1), scope: sessionScope, idleGeneration: 0, - busyCount: 0, + busyTurns: new Set(), lastActivityAtMs: now, idleFiber: null, pinnedSinceMs: null, @@ -1876,12 +1983,22 @@ export const layerWithOptions = ( payload: runtime.providerSession, }), ).pipe( - Effect.tapError(() => - releaseEntry({ - providerSessionId: input.providerSessionId, - reason: "runtime_error", - detail: "Failed to persist the provider-session attachment.", - }).pipe(logReleaseFailure(input.providerSessionId)), + // Released on interrupt too: this entry has no event pump or + // idle timer yet, so nothing else would ever release it. + Effect.onError((cause) => + releaseEntry( + Cause.hasInterruptsOnly(cause) + ? { + providerSessionId: input.providerSessionId, + reason: "manual_shutdown", + detail: "The provider session start was interrupted.", + } + : { + providerSessionId: input.providerSessionId, + reason: "runtime_error", + detail: "Failed to persist the provider-session attachment.", + }, + ).pipe(logReleaseFailure(input.providerSessionId)), ), ); yield* startEventPump(entry); From 517188b3caa135bc09ec0a4f93474ef17c8b5c4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 17:17:32 -0700 Subject: [PATCH 30/59] fix(web): show "No project" near the top of the new thread picker (#16628) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/CommandPalette.tsx | 197 ++++++++++----------- 1 file changed, 90 insertions(+), 107 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f7b78b5ae676..a0738dc85344 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -163,6 +163,7 @@ import { findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, + type CommandPaletteProject, type CommandPaletteSubmenuItem, type CommandPaletteView, filterCommandPaletteGroups, @@ -1309,89 +1310,94 @@ function OpenCommandPaletteDialog(props: { ], ); - const projectThreadItems = useMemo( - () => - enumerateCommandPaletteItems([ - ...buildProjectActionItems({ - // The no-project home shows once, as the "No project" item below. - projects: pickerProjects.filter( - (project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)), - ), - valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, - renderDescription: (project) => { - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote", - label: "Remote", - machine: "server" as const, - }; - return ( - - - {location.kind === "remote" ? ( - - ) : null} - {location.label} - - - {project.workspaceRoot} + const projectThreadItems = useMemo(() => { + const isScratch = (project: CommandPaletteProject) => + isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); + const projectItems = enumerateCommandPaletteItems( + buildProjectActionItems({ + // The no-project home shows once, as the "No project" item below. + projects: pickerProjects.filter((project) => !isScratch(project)), + valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = projectEnvironmentLocationById.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + []), + ...(location ? [location.label] : []), + ]; + }, + renderDescription: (project) => { + const location = projectEnvironmentLocationById.get(project.environmentId) ?? { + kind: "remote", + label: "Remote", + machine: "server" as const, + }; + return ( + + + {location.kind === "remote" ? ( + + ) : null} + {location.label} + + {project.workspaceRoot} + + ); + }, + icon: projectFaviconIcon, + runProject: async (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, ); - }, - icon: projectFaviconIcon, - runProject: async (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const contextualRefBelongsToGroup = - contextualProjectRef !== null && - group?.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === contextualProjectRef.environmentId && - projectRef.projectId === contextualProjectRef.projectId, - ); - await handleNewThread( - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); - }, - }), - ...(scratchTargetEnvironmentId === null - ? [] - : [ - { - kind: "action" as const, - value: "new-thread-in:no-project", - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, - ]), - ]), - [ - contextualProjectRef, - handleNewThread, - pickerProjects, - projectEnvironmentLocationById, - projectGroupByTargetKey, - scratchTargetEnvironmentId, - scratchWorkspaceRootFor, - startScratchThread, - ], - ); + await handleNewThread( + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + ); + if (scratchTargetEnvironmentId === null) return projectItems; + + // "No project" goes right after the current project: visible without + // scrolling past every project, while Enter still starts in the current + // one. When the current thread has no project, it is the current entry and + // goes first. It keeps its own shortcut, so the projects' mod+1..9 hold. + const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; + return [ + ...projectItems.slice(0, noProjectIndex), + { + kind: "action" as const, + value: "new-thread-in:no-project", + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ...projectItems.slice(noProjectIndex), + ]; + }, [ + contextualProjectRef, + handleNewThread, + pickerProjects, + projectEnvironmentLocationById, + projectGroupByTargetKey, + scratchTargetEnvironmentId, + scratchWorkspaceRootFor, + startScratchThread, + ]); const allThreadItems = useMemo( () => @@ -1848,35 +1854,12 @@ function OpenCommandPaletteDialog(props: { setNewProjectFlow(null); setViewStack([]); setQuery(""); - const currentPrefix = - currentProjectEnvironmentId && currentProjectId - ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}` - : null; - const prioritized = currentPrefix - ? [ - ...projectThreadItems.filter((item) => item.value === currentPrefix), - ...projectThreadItems.filter((item) => item.value !== currentPrefix), - ] - : projectThreadItems; + // projectThreadItems already lists the current project first. pushPaletteView({ addonIcon: , - groups: [ - { - value: "projects", - label: "Projects", - items: enumerateCommandPaletteItems(prioritized), - }, - ], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); - }, [ - clearOpenIntent, - browseNavigation, - currentProjectEnvironmentId, - currentProjectId, - openIntent, - projectThreadItems, - pushPaletteView, - ]); + }, [clearOpenIntent, browseNavigation, openIntent, projectThreadItems, pushPaletteView]); const actionItems: Array = []; From d021f57bf3a500e419e800c3eb957568bed8f713 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 17:51:52 -0700 Subject: [PATCH 31/59] refactor(server): instrument WS RPCs in group middleware (#15548) Co-authored-by: Claude Opus 5.5 (1M context) --- .../observability/RpcInstrumentation.test.ts | 587 ++-- .../src/observability/RpcInstrumentation.ts | 335 ++- apps/server/src/ws.ts | 2503 ++++++----------- docs/internals/effect-services.md | 16 +- docs/operations/observability.md | 4 +- 5 files changed, 1437 insertions(+), 2008 deletions(-) diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts index c7fb7d12b095..32baf29d068c 100644 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -1,313 +1,336 @@ import { assert, describe, it } from "@effect/vitest"; -import { WS_METHODS } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + type AuthEnvironmentScope, + ScheduledTaskError, + ScheduledTaskId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as RpcTest from "effect/rpc/RpcTest"; import * as TestClock from "effect/testing/TestClock"; -import { - observeRpcEffect, - observeRpcStream, - observeRpcStreamEffect, -} from "./RpcInstrumentation.ts"; - -const hasMetricSnapshot = ( - snapshots: ReadonlyArray, - id: string, - attributes: Readonly>, -) => - snapshots.some( - (snapshot) => - snapshot.id === id && - Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), - ); - -const findHistogramSnapshot = ( - snapshots: ReadonlyArray, - id: string, - attributes: Readonly>, -) => - snapshots.find( - (snapshot): snapshot is Extract => - snapshot.type === "Histogram" && - snapshot.id === id && - Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), - ); - -const collectSpanNames = ( - effect: Effect.Effect, -): Effect.Effect, E, R> => - Effect.gen(function* () { - const spanNames: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - - span.end = (endTime, exit) => { - end(endTime, exit); - if (span.sampled) { - spanNames.push(span.name); - } - }; - - return span; - }, - }); - - yield* effect.pipe(Effect.withTracer(tracer)); - - return spanNames; +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; +import { RpcInstrumentation, rpcInstrumentationLayer } from "./RpcInstrumentation.ts"; + +type WsRpcMethod = keyof typeof RPC_REQUIRED_SCOPES; + +/** The server group narrowed to `tags`, so a test only implements the handlers it calls. */ +const groupOf = >(...tags: Tags) => + WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tags as ReadonlyArray).includes(tag), + ), + ).middleware(RpcInstrumentation); + +/** The middleware ws.ts installs for a connection with `scopes`. */ +const connectionMiddleware = (scopes: ReadonlyArray) => + Layer.merge(RpcAuthorization.layer(scopes), rpcInstrumentationLayer); +const readOnlyConnection = connectionMiddleware([AuthOrchestrationReadScope]); +const taskId = ScheduledTaskId.make("scheduled-task:instrumented"); +const rpcSpanDefaults = { "rpc.transport": "websocket", "rpc.system": "effect-rpc" }; + +/** Runs a test with a fresh metric registry and a tracer that keeps every span it ends. */ +const withTelemetry = ( + test: (ended: ReadonlyArray) => Effect.Effect, +) => { + const ended: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + ended.push(span); + }; + return span; + }, }); - -describe("RpcInstrumentation", () => { - it.effect("records success metrics for unary RPC handlers", () => - Effect.gen(function* () { - yield* observeRpcEffect("rpc.instrumentation.success", Effect.succeed("ok"), { - "rpc.aggregate": "test", - }).pipe(Effect.withSpan("rpc.instrumentation.success.span")); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.success", - outcome: "success", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.success", - }), - true, - ); - }), + return test(ended).pipe( + Effect.scoped, + Effect.withTracer(tracer), + Effect.provideService(Metric.MetricRegistry, new Map()), ); +}; - it.effect("records failure outcomes for unary RPC handlers", () => - Effect.gen(function* () { - yield* Effect.exit( - observeRpcEffect("rpc.instrumentation.failure", Effect.fail("boom"), { - "rpc.aggregate": "test", - }).pipe(Effect.withSpan("rpc.instrumentation.failure.span")), - ); +const rpcSpans = (ended: ReadonlyArray) => + ended.filter((span) => span.name.startsWith("ws.rpc.")); - const snapshots = yield* Metric.snapshot; +// RpcTest keeps Effect's own RpcServer/RpcClient spans, which ws.ts turns off with +// `disableTracing: true`. Everything else comes from the middleware or the handlers. +const appSpans = (ended: ReadonlyArray) => + ended.filter((span) => !/^Rpc(Server|Client)\./.test(span.name)); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.failure", - outcome: "failure", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.failure", - }), - true, - ); - }), - ); +const exitTag = (span: Tracer.NativeSpan | undefined) => + span?.status._tag === "Ended" ? span.status.exit._tag : undefined; - it.effect("records subscription activation metrics for stream RPC handlers", () => - Effect.gen(function* () { - const events = yield* Stream.runCollect( - observeRpcStreamEffect( - "rpc.instrumentation.stream", - Effect.succeed(Stream.make("a", "b")), - { "rpc.aggregate": "test" }, - ).pipe(Stream.withSpan("rpc.instrumentation.stream.span")), - ); - - assert.deepStrictEqual(Array.from(events), ["a", "b"]); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.stream", - outcome: "success", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.stream", - }), - true, - ); - }), - ); - - it.effect("records failure outcomes for direct stream RPC handlers during consumption", () => - Effect.gen(function* () { - const exit = yield* Stream.runCollect( - observeRpcStream( - "rpc.instrumentation.stream.failure", - Stream.make("a").pipe(Stream.concat(Stream.fail("boom"))), - { "rpc.aggregate": "test" }, - ).pipe(Stream.withSpan("rpc.instrumentation.stream.failure.span")), - ).pipe(Effect.exit); - - assert.equal(Exit.isFailure(exit), true); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.stream.failure", - outcome: "failure", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.stream.failure", - }), - true, - ); - }), - ); - - it.effect("records direct stream durations from nanosecond clock readings", () => - Effect.gen(function* () { - const duration = Duration.nanos(1_500_000n); - const events = yield* Effect.gen(function* () { - const fiber = yield* Stream.runCollect( - observeRpcStream( - WS_METHODS.serverGetProcessDiagnostics, - Stream.fromEffect(Effect.sleep(duration).pipe(Effect.as("ok"))), - { - "rpc.aggregate": "test", - }, - ), - ).pipe(Effect.forkChild); +const parentSpanId = (span: Tracer.NativeSpan | undefined) => + span?.parent._tag === "Some" ? span.parent.value.spanId : undefined; - yield* Effect.yieldNow; - yield* TestClock.adjust(duration); - return yield* Fiber.join(fiber); - }).pipe(Effect.provide(TestClock.layer())); - - assert.deepStrictEqual(Array.from(events), ["ok"]); - - const snapshots = yield* Metric.snapshot; - const snapshot = findHistogramSnapshot(snapshots, "t3_rpc_request_duration", { - method: WS_METHODS.serverGetProcessDiagnostics, - }); - - assert.equal(snapshot?.state.count, 1); - assert.equal(snapshot?.state.sum, 1.5); - }), - ); - - it.effect("records failure outcomes when a stream RPC effect produces a failing stream", () => - Effect.gen(function* () { - const exit = yield* Stream.runCollect( - observeRpcStreamEffect( - "rpc.instrumentation.stream.effect.failure", - Effect.succeed(Stream.fail("boom")), - { "rpc.aggregate": "test" }, - ).pipe(Stream.withSpan("rpc.instrumentation.stream.effect.failure.span")), - ).pipe(Effect.exit); - - assert.equal(Exit.isFailure(exit), true); - - const snapshots = yield* Metric.snapshot; - - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_requests_total", { - method: "rpc.instrumentation.stream.effect.failure", - outcome: "failure", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "t3_rpc_request_duration", { - method: "rpc.instrumentation.stream.effect.failure", - }), - true, - ); - }), - ); - - it.effect("records spans for traced stream RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - Stream.runCollect( - observeRpcStream( - "rpc.instrumentation.traced.stream", - Stream.fromEffect( - Effect.succeed("ok").pipe(Effect.withSpan("rpc.instrumentation.traced.stream.child")), +const requestCount = ( + snapshots: ReadonlyArray, + method: string, + outcome: string, +) => + snapshots.find( + (snapshot): snapshot is Extract => + snapshot.type === "Counter" && + snapshot.id === "t3_rpc_requests_total" && + snapshot.attributes?.["method"] === method && + snapshot.attributes?.["outcome"] === outcome, + )?.state; + +const requestDuration = (snapshots: ReadonlyArray, method: string) => + snapshots.find( + (snapshot): snapshot is Extract => + snapshot.type === "Histogram" && + snapshot.id === "t3_rpc_request_duration" && + snapshot.attributes?.["method"] === method, + )?.state; + +describe("WS RPC instrumentation middleware", () => { + it.effect("records one span and request metric per call, including rejected calls", () => + withTelemetry((ended) => + Effect.gen(function* () { + const group = groupOf( + WS_METHODS.serverProbe, + WS_METHODS.scheduledTasksList, + WS_METHODS.serverRetryResourceTelemetry, + WS_METHODS.pullRequestsSubscribeRefreshes, + WS_METHODS.scheduledTasksSubscribe, + WS_METHODS.serverGetSettings, + ); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverProbe, () => + Effect.succeed({}).pipe(Effect.withSpan("serverProbe.child")), + ), + group.toLayerHandler(WS_METHODS.scheduledTasksList, () => + Effect.annotateCurrentSpan({ "scheduled_task.id": taskId }).pipe( + Effect.andThen(new ScheduledTaskError({ message: "List failed." })), + ), + ), + group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => + Effect.die("authorization let a rejected call through"), + ), + group.toLayerHandler(WS_METHODS.pullRequestsSubscribeRefreshes, () => + Stream.make(1, 2), + ), + group.toLayerHandler(WS_METHODS.scheduledTasksSubscribe, () => + Stream.concat( + Stream.make({ tasks: [] }), + Stream.fail(new ScheduledTaskError({ message: "Subscription failed." })), + ), + ), + group.toLayerHandler(WS_METHODS.serverGetSettings, () => Effect.die("broken")), + readOnlyConnection, ), - { "rpc.aggregate": "test" }, ), - ), - ); - - assert.equal(spanNames.includes("ws.rpc.rpc.instrumentation.traced.stream"), true); - assert.equal(spanNames.includes("rpc.instrumentation.traced.stream.child"), true); - }), - ); - - it.effect("does not create spans for disabled unary RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - observeRpcEffect( - WS_METHODS.serverGetTraceDiagnostics, - Effect.succeed("ok").pipe(Effect.withSpan("rpc.instrumentation.disabled.unary.child")), - { "rpc.aggregate": "test" }, - ), - ); - - assert.deepStrictEqual(spanNames, []); - }), + ); + + assert.deepStrictEqual(yield* client[WS_METHODS.serverProbe]({}), {}); + const listError = yield* client[WS_METHODS.scheduledTasksList]({}).pipe(Effect.flip); + assert.equal(listError._tag, "ScheduledTaskError"); + // Retrying telemetry needs operate scope, so the handler must not run. + const rejection = yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe( + Effect.flip, + ); + assert.equal(rejection._tag, "EnvironmentAuthorizationError"); + const refreshes = yield* Stream.runCollect( + client[WS_METHODS.pullRequestsSubscribeRefreshes]({}), + ); + assert.deepStrictEqual(Array.from(refreshes), [1, 2]); + const subscribeError = yield* Stream.runDrain( + client[WS_METHODS.scheduledTasksSubscribe]({}), + ).pipe(Effect.flip); + assert.equal(subscribeError._tag, "ScheduledTaskError"); + const settingsExit = yield* Effect.exit(client[WS_METHODS.serverGetSettings]({})); + assert.isTrue(Exit.hasDies(settingsExit)); + + assert.deepStrictEqual( + rpcSpans(ended).map((span) => [span.name, Object.fromEntries(span.attributes)]), + [ + [ + "ws.rpc.server.probe", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.serverProbe, + "rpc.aggregate": "server", + }, + ], + [ + "ws.rpc.scheduledTasks.list", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.scheduledTasksList, + "rpc.aggregate": "scheduledTasks", + "scheduled_task.id": taskId, + }, + ], + [ + "ws.rpc.server.retryResourceTelemetry", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.serverRetryResourceTelemetry, + "rpc.aggregate": "server", + }, + ], + [ + "ws.rpc.pullRequests.subscribeRefreshes", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.pullRequestsSubscribeRefreshes, + "rpc.aggregate": "pull-requests", + }, + ], + [ + "ws.rpc.scheduledTasks.subscribe", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.scheduledTasksSubscribe, + "rpc.aggregate": "scheduledTasks", + }, + ], + [ + "ws.rpc.server.getSettings", + { + ...rpcSpanDefaults, + "rpc.method": WS_METHODS.serverGetSettings, + "rpc.aggregate": "server", + }, + ], + ], + ); + assert.deepStrictEqual(rpcSpans(ended).map(exitTag), [ + "Success", + "Failure", + "Failure", + "Success", + "Failure", + "Failure", + ]); + const probeSpan = rpcSpans(ended)[0]; + const child = ended.find((span) => span.name === "serverProbe.child"); + assert.equal(parentSpanId(child), probeSpan?.spanId); + + const snapshots = yield* Metric.snapshot; + assert.deepStrictEqual( + [ + requestCount(snapshots, WS_METHODS.serverProbe, "success"), + requestCount(snapshots, WS_METHODS.scheduledTasksList, "failure"), + requestCount(snapshots, WS_METHODS.serverRetryResourceTelemetry, "failure"), + requestCount(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes, "success"), + requestCount(snapshots, WS_METHODS.scheduledTasksSubscribe, "failure"), + requestCount(snapshots, WS_METHODS.serverGetSettings, "failure"), + ].map((state) => state?.count), + [1, 1, 1, 1, 1, 1], + ); + for (const method of group.requests.keys()) { + assert.equal(requestDuration(snapshots, method)?.count, 1); + } + }), + ), ); - it.effect("does not create spans for disabled direct stream RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - Stream.runCollect( - observeRpcStream( - WS_METHODS.serverGetTraceDiagnostics, - Stream.fromEffect( - Effect.succeed("ok").pipe( - Effect.withSpan("rpc.instrumentation.disabled.stream.child"), + it.effect("keeps a stream's span and duration open until the subscription is interrupted", () => + withTelemetry((ended) => + Effect.gen(function* () { + const waiting = yield* Deferred.make(); + const group = groupOf(WS_METHODS.pullRequestsSubscribeRefreshes); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pullRequestsSubscribeRefreshes, () => + Stream.concat( + Stream.make(1), + Stream.fromEffect( + Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(Effect.never), + Effect.withSpan("refreshes.wait"), + ), + ), + ), ), + readOnlyConnection, ), - { "rpc.aggregate": "test" }, ), - ), - ); - - assert.deepStrictEqual(spanNames, []); - }), + ); + + // Wall and monotonic time disagree before the call starts, so a duration that mixes + // the two clocks is caught. + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.setTime(0); + const consumer = yield* Stream.runDrain( + client[WS_METHODS.pullRequestsSubscribeRefreshes]({}), + ).pipe(Effect.forkChild); + yield* Deferred.await(waiting); + yield* TestClock.adjust(Duration.millis(250)); + // A backward wall-clock correction must not shorten the measured duration. + yield* TestClock.setTime(0); + assert.deepStrictEqual(appSpans(ended), []); + + // The client waits for the server to stop the call, which ends the RPC span. + yield* Fiber.interrupt(consumer); + + const [rpcSpan] = rpcSpans(ended); + assert.equal(rpcSpan?.name, "ws.rpc.pullRequests.subscribeRefreshes"); + assert.equal(exitTag(rpcSpan), "Failure"); + const waitSpan = ended.find((span) => span.name === "refreshes.wait"); + assert.equal(parentSpanId(waitSpan), rpcSpan?.spanId); + + const snapshots = yield* Metric.snapshot; + assert.equal( + requestCount(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes, "interrupt")?.count, + 1, + ); + const duration = requestDuration(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes); + assert.equal(duration?.count, 1); + assert.equal(duration?.sum, 250); + }), + ), ); - it.effect("does not create spans for disabled stream effect RPC handlers", () => - Effect.gen(function* () { - const spanNames = yield* collectSpanNames( - Stream.runCollect( - observeRpcStreamEffect( - WS_METHODS.serverGetTraceDiagnostics, - Effect.succeed( - Stream.fromEffect( - Effect.succeed("ok").pipe( - Effect.withSpan("rpc.instrumentation.disabled.stream.effect.consume"), - ), + it.effect("records metrics but no spans for methods with tracing disabled", () => + withTelemetry((ended) => + Effect.gen(function* () { + const group = groupOf(WS_METHODS.serverSignalProcess); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverSignalProcess, (input) => + Effect.succeed({ + pid: input.pid, + signal: input.signal, + signaled: true, + message: Option.none(), + }).pipe(Effect.withSpan("signalProcess.child")), ), - ).pipe(Effect.withSpan("rpc.instrumentation.disabled.stream.effect.create")), - { "rpc.aggregate": "test" }, + connectionMiddleware([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), + ), ), - ), - ); + ); + + const input = { pid: 4242, startTimeMs: 0, signal: "SIGINT" } as const; + assert.equal((yield* client[WS_METHODS.serverSignalProcess](input)).signaled, true); - assert.deepStrictEqual(spanNames, []); - }), + assert.deepStrictEqual(appSpans(ended), []); + const snapshots = yield* Metric.snapshot; + assert.equal(requestCount(snapshots, WS_METHODS.serverSignalProcess, "success")?.count, 1); + }), + ), ); }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1f3d172b5763..f38e1aeff1d9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -1,14 +1,198 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; +import { ORCHESTRATION_V2_WS_METHODS, WS_METHODS, type WsRpcGroup } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Metric from "effect/Metric"; +import * as Layer from "effect/Layer"; import * as References from "effect/References"; -import * as Stream from "effect/Stream"; +import type * as RpcGroup from "effect/rpc/RpcGroup"; +import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; -import { outcomeFromExit } from "./Attributes.ts"; -import { metricAttributes, rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts"; +import { rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts"; + +type WsRpcMethod = RpcGroup.Rpcs["_tag"]; + +/** + * The `rpc.aggregate` span attribute of every WebSocket RPC. Trace queries and dashboards group by + * these labels, so they keep their historical values even where they differ from the method + * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error. + */ +const RPC_AGGREGATES = { + [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.searchThreads]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: "orchestrationV2", + [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: "orchestrationV2", + [WS_METHODS.projectsMutate]: "orchestration", + [WS_METHODS.serverProbe]: "server", + [WS_METHODS.serverGetConfig]: "server", + [WS_METHODS.serverRefreshProviders]: "server", + [WS_METHODS.serverUpdateProvider]: "server", + [WS_METHODS.providerAuthStart]: "provider", + [WS_METHODS.providerConsumeResetCredit]: "provider", + [WS_METHODS.providerAuthComplete]: "provider", + [WS_METHODS.chatGptReconnectProfile]: "provider", + [WS_METHODS.chatGptImportProfile]: "provider", + [WS_METHODS.chatGptHandoffSubscribe]: "provider", + [WS_METHODS.codexAuthCallbackSubscribe]: "provider", + [WS_METHODS.providerAuthRespond]: "provider", + [WS_METHODS.providerAuthCancel]: "provider", + [WS_METHODS.providerAuthLogout]: "provider", + [WS_METHODS.providerAuthSubscribe]: "provider", + [WS_METHODS.providerInstallStart]: "provider", + [WS_METHODS.providerInstallCancel]: "provider", + [WS_METHODS.providerInstallSubscribe]: "provider", + [WS_METHODS.providerInstallRemove]: "provider", + [WS_METHODS.serverUpdateServer]: "server", + [WS_METHODS.serverUpdateServerWithProgress]: "server", + [WS_METHODS.serverCommitDesktopUpdate]: "server", + [WS_METHODS.serverUpsertKeybinding]: "server", + [WS_METHODS.serverRemoveKeybinding]: "server", + [WS_METHODS.serverGetSettings]: "server", + [WS_METHODS.serverUpdateSettings]: "server", + [WS_METHODS.serverSearchAcpRegistry]: "server", + [WS_METHODS.serverPrepareAcpRegistryAgent]: "server", + [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: "server", + [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: "server", + [WS_METHODS.serverListAcpRegistrySessions]: "server", + [WS_METHODS.serverImportAcpRegistrySession]: "server", + [WS_METHODS.serverDeleteAcpRegistrySession]: "server", + [WS_METHODS.serverListAcpRegistryProviders]: "server", + [WS_METHODS.serverSetAcpRegistryProvider]: "server", + [WS_METHODS.serverDisableAcpRegistryProvider]: "server", + [WS_METHODS.serverLogoutAcpRegistry]: "server", + [WS_METHODS.serverDiscoverSourceControl]: "server", + [WS_METHODS.serverGetTraceDiagnostics]: "server", + [WS_METHODS.serverGetProcessDiagnostics]: "server", + [WS_METHODS.serverGetHostResources]: "server", + [WS_METHODS.serverGetProcessResourceHistory]: "server", + [WS_METHODS.serverGetResourceTelemetryHistory]: "server", + [WS_METHODS.serverRetryResourceTelemetry]: "server", + [WS_METHODS.serverGetUsageSummary]: "server", + [WS_METHODS.serverRefreshUsageRates]: "server", + [WS_METHODS.serverSignalProcess]: "server", + [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.serverReportHostPowerState]: "server", + [WS_METHODS.serverGetBackgroundPolicy]: "server", + [WS_METHODS.scheduledTasksList]: "scheduledTasks", + [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", + [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", + [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", + [WS_METHODS.scheduledTasksDelete]: "scheduledTasks", + [WS_METHODS.scheduledTasksRunNow]: "scheduledTasks", + [WS_METHODS.scheduledTasksRotateWebhookToken]: "scheduledTasks", + [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", + [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", + [WS_METHODS.secretsAnswerRequest]: "secrets", + [WS_METHODS.cloudGetRelayClientStatus]: "cloud", + [WS_METHODS.cloudInstallRelayClient]: "cloud", + [WS_METHODS.pullRequestsList]: "pull-requests", + [WS_METHODS.pullRequestsListStats]: "pull-requests", + [WS_METHODS.pullRequestsSummary]: "pull-requests", + [WS_METHODS.pullRequestsRouting]: "pull-requests", + [WS_METHODS.pullRequestsRoutingIdentity]: "pull-requests", + [WS_METHODS.pullRequestsStack]: "pull-requests", + [WS_METHODS.pullRequestsLinkedThreads]: "pull-requests", + [WS_METHODS.pullRequestsDetail]: "pull-requests", + [WS_METHODS.pullRequestsPreview]: "pull-requests", + [WS_METHODS.pullRequestsChecks]: "pull-requests", + [WS_METHODS.pullRequestsActivity]: "pull-requests", + [WS_METHODS.pullRequestsThreadComments]: "pull-requests", + [WS_METHODS.pullRequestsDiffFileContents]: "pull-requests", + [WS_METHODS.pullRequestsFilesViewed]: "pull-requests", + [WS_METHODS.pullRequestsRunAction]: "pull-requests", + [WS_METHODS.pullRequestsUpdate]: "pull-requests", + [WS_METHODS.pullRequestsComment]: "pull-requests", + [WS_METHODS.pullRequestsUpdateComment]: "pull-requests", + [WS_METHODS.pullRequestsSubmitReview]: "pull-requests", + [WS_METHODS.pullRequestsReplyToThread]: "pull-requests", + [WS_METHODS.pullRequestsSetThreadResolution]: "pull-requests", + [WS_METHODS.pullRequestsSetReaction]: "pull-requests", + [WS_METHODS.pullRequestsSetFilesViewed]: "pull-requests", + [WS_METHODS.pullRequestsInvalidate]: "pull-requests", + [WS_METHODS.pullRequestsSubscribeRefreshes]: "pull-requests", + [WS_METHODS.pullRequestsReviewerCandidates]: "pull-requests", + [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", + [WS_METHODS.pullRequestsLabelCandidates]: "pull-requests", + [WS_METHODS.pullRequestsSetLabels]: "pull-requests", + [WS_METHODS.sourceControlLookupRepository]: "source-control", + [WS_METHODS.sourceControlCloneRepository]: "source-control", + [WS_METHODS.sourceControlPublishRepository]: "source-control", + [WS_METHODS.projectCloneStart]: "source-control", + [WS_METHODS.projectCloneCancel]: "source-control", + [WS_METHODS.projectCloneRetry]: "source-control", + [WS_METHODS.subscribeProjectClones]: "source-control", + [WS_METHODS.projectsListEntries]: "workspace", + [WS_METHODS.projectsReadFile]: "workspace", + [WS_METHODS.projectsSearchContents]: "workspace", + [WS_METHODS.projectsSearchEntries]: "workspace", + [WS_METHODS.projectsWriteFile]: "workspace", + [WS_METHODS.projectsEnsureScratch]: "orchestration", + [WS_METHODS.projectsCreateNew]: "orchestration", + [WS_METHODS.shellOpenInEditor]: "workspace", + [WS_METHODS.filesystemBrowse]: "workspace", + [WS_METHODS.agentSessionsScan]: "workspace", + [WS_METHODS.agentSessionsImport]: "workspace", + [WS_METHODS.assetsCreateUrl]: "workspace", + [WS_METHODS.assetsPersistChatAttachments]: "orchestration", + [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", + [WS_METHODS.attachmentsDelete]: "workspace", + [WS_METHODS.providerUploadFeedback]: "provider", + [WS_METHODS.subscribeVcsStatus]: "vcs", + [WS_METHODS.subscribeWorktreeSetup]: "vcs", + [WS_METHODS.worktreeSetupCancel]: "vcs", + [WS_METHODS.subscribeResourceTelemetry]: "server", + [WS_METHODS.vcsRefreshStatus]: "vcs", + [WS_METHODS.vcsPull]: "git", + [WS_METHODS.gitRunStackedAction]: "vcs", + [WS_METHODS.gitResolvePullRequest]: "git", + [WS_METHODS.gitPreparePullRequestThread]: "git", + [WS_METHODS.vcsListRefs]: "vcs", + [WS_METHODS.vcsCreateWorktree]: "vcs", + [WS_METHODS.vcsRemoveWorktree]: "vcs", + [WS_METHODS.vcsCreateRef]: "vcs", + [WS_METHODS.vcsSwitchRef]: "vcs", + [WS_METHODS.vcsInit]: "vcs", + [WS_METHODS.reviewGetDiffPreview]: "review", + [WS_METHODS.reviewGetDiffFileContents]: "review", + [WS_METHODS.terminalOpen]: "terminal", + [WS_METHODS.terminalAttach]: "terminal", + [WS_METHODS.terminalWrite]: "terminal", + [WS_METHODS.terminalResize]: "terminal", + [WS_METHODS.terminalClear]: "terminal", + [WS_METHODS.terminalRestart]: "terminal", + [WS_METHODS.terminalClose]: "terminal", + [WS_METHODS.subscribeTerminalEvents]: "terminal", + [WS_METHODS.subscribeTerminalMetadata]: "terminal", + [WS_METHODS.previewOpen]: "preview", + [WS_METHODS.previewNavigate]: "preview", + [WS_METHODS.previewResize]: "preview", + [WS_METHODS.previewAdjust]: "preview", + [WS_METHODS.previewRefresh]: "preview", + [WS_METHODS.previewClose]: "preview", + [WS_METHODS.previewList]: "preview", + [WS_METHODS.previewClearProfile]: "preview", + [WS_METHODS.previewReportStatus]: "preview", + [WS_METHODS.subscribePreviewEvents]: "preview", + [WS_METHODS.subscribeDiscoveredLocalServers]: "preview", + [WS_METHODS.deviceConfigure]: "device", + [WS_METHODS.deviceTestHost]: "device", + [WS_METHODS.deviceList]: "device", + [WS_METHODS.deviceOpen]: "device", + [WS_METHODS.deviceClose]: "device", + [WS_METHODS.deviceShutdown]: "device", + [WS_METHODS.deviceDetail]: "device", + [WS_METHODS.deviceAction]: "device", + [WS_METHODS.subscribeDeviceState]: "device", + [WS_METHODS.subscribeServerConfig]: "server", + [WS_METHODS.subscribeServerLifecycle]: "server", + [WS_METHODS.subscribeAuthAccess]: "auth", + [WS_METHODS.subscribeBackgroundPolicy]: "server", +} as const satisfies Readonly>; const RPC_SPAN_PREFIX = "ws.rpc"; const DEFAULT_RPC_SPAN_ATTRIBUTES = { @@ -22,117 +206,36 @@ const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ WS_METHODS.serverSignalProcess, ]); -function shouldTraceRpc(method: string): boolean { - return !RPC_METHODS_WITH_TRACING_DISABLED.has(method); -} - -const rpcSpanAttributes = ( - method: string, - traceAttributes?: Readonly>, -): Record => ({ - ...DEFAULT_RPC_SPAN_ATTRIBUTES, - "rpc.method": method, - ...traceAttributes, -}); - -const withRpcEffectTracing = ( - method: string, - effect: Effect.Effect, - traceAttributes?: Readonly>, -): Effect.Effect => - shouldTraceRpc(method) - ? effect.pipe( - Effect.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { - attributes: rpcSpanAttributes(method, traceAttributes), - }), - ) - : effect.pipe(Effect.provideService(References.TracerEnabled, false)); +/** + * Records each WebSocket RPC's span and request metrics. `ws.ts` adds it to the server's group + * after `RpcScopeAuthorization`, so it wraps authorization and also records rejected calls. + */ +export class RpcInstrumentation extends RpcMiddleware.Service()( + "t3/server/RpcInstrumentation", +) {} -const withRpcStreamTracing = ( - method: string, - stream: Stream.Stream, - traceAttributes?: Readonly>, -): Stream.Stream => - shouldTraceRpc(method) - ? stream.pipe( - Stream.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { - attributes: rpcSpanAttributes(method, traceAttributes), - }), - ) - : stream.pipe(Stream.provideService(References.TracerEnabled, false)); - -const recordRpcStreamMetrics = ( - method: string, - startedAt: bigint, - exit: Exit.Exit, -): Effect.Effect => - Effect.gen(function* () { - yield* Metric.update( - Metric.withAttributes(rpcRequestDuration, metricAttributes({ method })), - Duration.nanos((yield* Clock.monotonicTimeNanos) - startedAt), - ); - yield* Metric.update( - Metric.withAttributes( - rpcRequestsTotal, - metricAttributes({ - method, - outcome: outcomeFromExit(exit), - }), - ), - 1, - ); - }); +/** + * Wraps each WebSocket RPC call in its `ws.rpc.` span and records its request counter and + * duration. For a stream RPC, the middleware receives the whole stream run, so the span and the + * metrics cover the subscription until it ends, fails, or is interrupted. Methods in + * `RPC_METHODS_WITH_TRACING_DISABLED` record metrics but no spans, for the call or anything it runs. + */ +export const rpcInstrumentationLayer = Layer.succeed(RpcInstrumentation)((effect, { rpc }) => { + const method = rpc._tag; + const measured = effect.pipe( + withMetrics({ counter: rpcRequestsTotal, timer: rpcRequestDuration, attributes: { method } }), + ); -export const observeRpcEffect = ( - method: string, - effect: Effect.Effect, - traceAttributes?: Readonly>, -): Effect.Effect => { - const instrumented = effect.pipe( - withMetrics({ - counter: rpcRequestsTotal, - timer: rpcRequestDuration, + if (RPC_METHODS_WITH_TRACING_DISABLED.has(method)) { + return measured.pipe(Effect.provideService(References.TracerEnabled, false)); + } + return measured.pipe( + Effect.withSpan(`${RPC_SPAN_PREFIX}.${method}`, { attributes: { - method, + ...DEFAULT_RPC_SPAN_ATTRIBUTES, + "rpc.method": method, + "rpc.aggregate": RPC_AGGREGATES[method as WsRpcMethod], }, }), ); - - return withRpcEffectTracing(method, instrumented, traceAttributes); -}; - -export const observeRpcStream = ( - method: string, - stream: Stream.Stream, - traceAttributes?: Readonly>, -): Stream.Stream => { - const instrumented = Stream.unwrap( - Effect.gen(function* () { - const startedAt = yield* Clock.monotonicTimeNanos; - return stream.pipe(Stream.onExit((exit) => recordRpcStreamMetrics(method, startedAt, exit))); - }), - ); - - return withRpcStreamTracing(method, instrumented, traceAttributes); -}; - -export const observeRpcStreamEffect = ( - method: string, - effect: Effect.Effect, EffectError, EffectContext>, - traceAttributes?: Readonly>, -): Stream.Stream => { - const instrumented = Stream.unwrap( - Effect.gen(function* () { - const startedAt = yield* Clock.monotonicTimeNanos; - // onError also runs when the stream is interrupted before it is produced. - const stream = yield* effect.pipe( - Effect.onError((cause) => recordRpcStreamMetrics(method, startedAt, Exit.failCause(cause))), - ); - return stream.pipe( - Stream.onExit((streamExit) => recordRpcStreamMetrics(method, startedAt, streamExit)), - ); - }), - ); - - return withRpcStreamTracing(method, instrumented, traceAttributes); -}; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index dcf9fad56835..ad1b9815acc3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -159,11 +159,6 @@ import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts"; -import { - observeRpcEffect, - observeRpcStream, - observeRpcStreamEffect, -} from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as AcpRegistrySupport from "./provider/acp/AcpRegistrySupport.ts"; @@ -215,6 +210,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; +import { RpcInstrumentation, rpcInstrumentationLayer } from "./observability/RpcInstrumentation.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; @@ -541,7 +537,8 @@ function projectFileFailureContext( const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const ServerWsRpcGroup = WsRpcGroup; +// Middleware added later wraps middleware added earlier, so instrumentation wraps authorization. +const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); // When a resuming client's cursor is more than this many events behind the // current head, skip the per-event catch-up replay and send a fresh shell // snapshot instead. Replaying each intervening event costs a shell refetch; @@ -1808,851 +1805,595 @@ const layerWsRpc = ( const handlers = ServerWsRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.dispatchCommand, - startup - .enqueueCommand( - // A retry also restarts the preparation work the launch owns. - (command.type === "prepared-run.retry" - ? threadLaunch.retryPreparation(command) - : ThreadMessageIntake.dispatchCommand( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: - "creationSource" in command ? command.creationSource : "web", - }), - ) - ).pipe(Effect.provide(intakeContext)), - ) - .pipe( - Effect.tap(() => recordClientCommandAnalytics(command)), - Effect.map((result) => ({ sequence: result.sequence })), - Effect.mapError((cause) => { - const detail = userFacingDispatchErrorMessage(cause); - return new OrchestrationV2DispatchCommandError({ - commandId: command.commandId, - commandType: command.type, - message: detail ?? "Failed to dispatch orchestration V2 command", - ...(detail === undefined ? {} : { detail }), - cause, - }); - }), - ), - { - "rpc.aggregate": "orchestrationV2", - "orchestration_v2.command_id": command.commandId, - "orchestration_v2.command_type": command.type, - "orchestration_v2.thread_id": - command.type === "thread.fork" || command.type === "thread.merge_back" - ? command.targetThreadId - : command.type === "delegated_task.request" || - command.type === "delegated_task.wake-policy" || - command.type === "delegated_task.completion-delivery.acknowledge" || - command.type === "delegated_task.completion-delivery.dispose" || - command.type === "thread.created.record" - ? command.parentThreadId - : command.threadId, - ...(command.type === "thread.fork" || command.type === "thread.merge_back" - ? { "orchestration_v2.source_thread_id": command.sourceThreadId } - : {}), - }, + Effect.annotateCurrentSpan({ + "orchestration_v2.command_id": command.commandId, + "orchestration_v2.command_type": command.type, + "orchestration_v2.thread_id": + command.type === "thread.fork" || command.type === "thread.merge_back" + ? command.targetThreadId + : command.type === "delegated_task.request" || + command.type === "delegated_task.wake-policy" || + command.type === "delegated_task.completion-delivery.acknowledge" || + command.type === "delegated_task.completion-delivery.dispose" || + command.type === "thread.created.record" + ? command.parentThreadId + : command.threadId, + ...(command.type === "thread.fork" || command.type === "thread.merge_back" + ? { "orchestration_v2.source_thread_id": command.sourceThreadId } + : {}), + }).pipe( + Effect.andThen( + startup + .enqueueCommand( + // A retry also restarts the preparation work the launch owns. + (command.type === "prepared-run.retry" + ? threadLaunch.retryPreparation(command) + : ThreadMessageIntake.dispatchCommand( + ThreadManagementService.withCreationProvenance(command, { + createdBy: "user", + creationSource: + "creationSource" in command ? command.creationSource : "web", + }), + ) + ).pipe(Effect.provide(intakeContext)), + ) + .pipe( + Effect.tap(() => recordClientCommandAnalytics(command)), + Effect.map((result) => ({ sequence: result.sequence })), + Effect.mapError((cause) => { + const detail = userFacingDispatchErrorMessage(cause); + return new OrchestrationV2DispatchCommandError({ + commandId: command.commandId, + commandType: command.type, + message: detail ?? "Failed to dispatch orchestration V2 command", + ...(detail === undefined ? {} : { detail }), + cause, + }); + }), + ), + ), ), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, - readWorkflowScript({ scriptPath: input.scriptPath }), - { "rpc.aggregate": "orchestration" }, - ), + readWorkflowScript({ scriptPath: input.scriptPath }), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getTurnItem, - threadManagement.getTurnItem(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: "Failed to load turn item", - cause, - }), - ), + threadManagement.getTurnItem(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: "Failed to load turn item", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getTurnDiff, - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), - ), + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, - }), - ), + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.searchThreads, - threadSearch.search(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationSearchThreadsError({ - message: "Failed to search threads", - cause, - }), - ), + threadSearch.search(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationSearchThreadsError({ + message: "Failed to search threads", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, - getOrchestrationV2ArchivedShellSnapshot, - { "rpc.aggregate": "orchestration" }, - ), + getOrchestrationV2ArchivedShellSnapshot, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.getThreadProjection, - // Pre-pagination clients still call this compatibility endpoint. - // Keep stale clients from materializing an unbounded transcript. - threadManagement - .getThreadSnapshotWindow(input.threadId, { - rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, - }) - .pipe( - Effect.map((snapshot) => - projectThreadProjectionForWire( - buildBoundedThreadProjection({ - projection: snapshot.projection, - snapshotSequence: snapshot.snapshotSequence, - }).projection, + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen( + // Pre-pagination clients still call this compatibility endpoint. + // Keep stale clients from materializing an unbounded transcript. + threadManagement + .getThreadSnapshotWindow(input.threadId, { + rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + }) + .pipe( + Effect.map((snapshot) => + projectThreadProjectionForWire( + buildBoundedThreadProjection({ + projection: snapshot.projection, + snapshotSequence: snapshot.snapshotSequence, + }).projection, + ), + ), + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed to load orchestration V2 thread ${input.threadId}`, + cause, + }), ), ), - Effect.mapError( - (cause) => - new OrchestrationV2GetThreadProjectionError({ - threadId: input.threadId, - message: `Failed to load orchestration V2 thread ${input.threadId}`, - cause, - }), - ), - ), - { - "rpc.aggregate": "orchestrationV2", - "orchestration_v2.thread_id": input.threadId, - }, + ), ), [ORCHESTRATION_V2_WS_METHODS.launchThread]: (input) => - observeRpcEffect( - ORCHESTRATION_V2_WS_METHODS.launchThread, - startup - .enqueueCommand( - ThreadMessageIntake.launchThread({ - commandId: input.commandId, - ...(input.threadId === undefined ? {} : { threadId: input.threadId }), - ...(input.reuseExistingThread === undefined - ? {} - : { reuseExistingThread: input.reuseExistingThread }), - projectId: input.projectId, - title: input.title, - ...(input.generateTitle === undefined - ? {} - : { generateTitle: input.generateTitle }), - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - workspaceStrategy: input.workspaceStrategy, - ...(input.initialMessage === undefined - ? {} - : { - initialMessage: { - ...(input.initialMessage.messageId === undefined - ? {} - : { messageId: input.initialMessage.messageId }), - text: input.initialMessage.text, - attachments: input.initialMessage.attachments, - ...(input.initialMessage.context === undefined - ? {} - : { context: input.initialMessage.context }), - }, - }), - createdBy: "user", - creationSource: input.creationSource ?? "web", - }).pipe(Effect.provide(intakeContext)), - ) - .pipe( - Effect.tap(() => - analytics - .record("client.thread.started", originProps) - .pipe( - Effect.andThen( - input.initialMessage === undefined - ? Effect.void - : analytics.record("client.turn.requested", originProps), + Effect.annotateCurrentSpan({ + "orchestration_v2.command_id": input.commandId, + "orchestration_v2.project_id": input.projectId, + }).pipe( + Effect.andThen( + startup + .enqueueCommand( + ThreadMessageIntake.launchThread({ + commandId: input.commandId, + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + ...(input.reuseExistingThread === undefined + ? {} + : { reuseExistingThread: input.reuseExistingThread }), + projectId: input.projectId, + title: input.title, + ...(input.generateTitle === undefined + ? {} + : { generateTitle: input.generateTitle }), + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + workspaceStrategy: input.workspaceStrategy, + ...(input.initialMessage === undefined + ? {} + : { + initialMessage: { + ...(input.initialMessage.messageId === undefined + ? {} + : { messageId: input.initialMessage.messageId }), + text: input.initialMessage.text, + attachments: input.initialMessage.attachments, + ...(input.initialMessage.context === undefined + ? {} + : { context: input.initialMessage.context }), + }, + }), + createdBy: "user", + creationSource: input.creationSource ?? "web", + }).pipe(Effect.provide(intakeContext)), + ) + .pipe( + Effect.tap(() => + analytics + .record("client.thread.started", originProps) + .pipe( + Effect.andThen( + input.initialMessage === undefined + ? Effect.void + : analytics.record("client.turn.requested", originProps), + ), + Effect.ignore, ), - Effect.ignore, - ), + ), + Effect.map((result) => ({ + ...result, + projection: projectThreadProjectionForWire(result.projection), + })), + Effect.catchTags({ + AttachmentClaimError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: cause.message, + cause, + }), + ThreadLaunchError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Failed to launch thread", + cause, + }), + ServerRuntimeStartupError: (cause) => + new OrchestrationV2ThreadLaunchError({ + commandId: input.commandId, + projectId: input.projectId, + message: "Failed to launch thread", + cause, + }), + }), ), - Effect.map((result) => ({ - ...result, - projection: projectThreadProjectionForWire(result.projection), - })), - Effect.catchTags({ - AttachmentClaimError: (cause) => - new OrchestrationV2ThreadLaunchError({ - commandId: input.commandId, - projectId: input.projectId, - message: cause.message, - cause, - }), - ThreadLaunchError: (cause) => - new OrchestrationV2ThreadLaunchError({ - commandId: input.commandId, - projectId: input.projectId, - message: "Failed to launch thread", - cause, - }), - ServerRuntimeStartupError: (cause) => - new OrchestrationV2ThreadLaunchError({ - commandId: input.commandId, - projectId: input.projectId, - message: "Failed to launch thread", - cause, - }), - }), - ), - { - "rpc.aggregate": "orchestration", - "orchestration_v2.command_id": input.commandId, - "orchestration_v2.project_id": input.projectId, - }, + ), ), [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: (_input) => - observeRpcStreamEffect( - ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell, - subscribeOrchestrationV2ArchivedShell(), - { "rpc.aggregate": "orchestration" }, - ), + Stream.unwrap(subscribeOrchestrationV2ArchivedShell()), [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: (input) => - observeRpcStreamEffect( - ORCHESTRATION_V2_WS_METHODS.subscribeShell, - subscribeOrchestrationV2Shell(input), - { - "rpc.aggregate": "orchestrationV2", - }, - ), + Stream.unwrap(subscribeOrchestrationV2Shell(input)), [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input) => - observeRpcStreamEffect( - ORCHESTRATION_V2_WS_METHODS.subscribeThread, - subscribeOrchestrationV2Thread(input), - { - "rpc.aggregate": "orchestrationV2", - "orchestration_v2.thread_id": input.threadId, - }, + Stream.unwrap( + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(subscribeOrchestrationV2Thread(input)), + ), ), [WS_METHODS.scheduledTasksList]: (_input) => - observeRpcEffect( - WS_METHODS.scheduledTasksList, - scheduledTasks.list().pipe(Effect.map(withVisibleWebhookUrls)), - { "rpc.aggregate": "scheduledTasks" }, - ), + scheduledTasks.list().pipe(Effect.map(withVisibleWebhookUrls)), [WS_METHODS.scheduledTasksSubscribe]: (_input) => - observeRpcStream( - WS_METHODS.scheduledTasksSubscribe, - scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)), - { "rpc.aggregate": "scheduledTasks" }, - ), - [WS_METHODS.scheduledTasksUpsert]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksUpsert, scheduledTasks.upsert(input), { - "rpc.aggregate": "scheduledTasks", - }), + scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)), + [WS_METHODS.scheduledTasksUpsert]: (input) => scheduledTasks.upsert(input), [WS_METHODS.scheduledTasksSetEnabled]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksSetEnabled, scheduledTasks.setEnabled(input), { - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": input.id, - }), + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.setEnabled(input)), + ), [WS_METHODS.scheduledTasksDelete]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksDelete, scheduledTasks.delete(input), { - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": input.id, - }), + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.delete(input)), + ), [WS_METHODS.scheduledTasksRunNow]: (input) => - observeRpcEffect(WS_METHODS.scheduledTasksRunNow, scheduledTasks.runNow(input), { - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": input.id, - }), + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.runNow(input)), + ), [WS_METHODS.scheduledTasksRotateWebhookToken]: (input) => - observeRpcEffect( - WS_METHODS.scheduledTasksRotateWebhookToken, - scheduledTasks.rotateWebhookToken(input), - { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id }, + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.rotateWebhookToken(input)), ), [WS_METHODS.secretsAnswerRequest]: (input) => - observeRpcEffect(WS_METHODS.secretsAnswerRequest, secretRequests.answer(input), { - "rpc.aggregate": "secrets", - "orchestration_v2.thread_id": input.threadId, - }), + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(secretRequests.answer(input)), + ), [WS_METHODS.scheduledTasksListWebhookDeliveries]: (input) => - observeRpcEffect( - WS_METHODS.scheduledTasksListWebhookDeliveries, - scheduledTasks.listWebhookDeliveries(input), - { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id }, + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.listWebhookDeliveries(input)), ), [WS_METHODS.scheduledTasksGetWebhookDelivery]: (input) => - observeRpcEffect( - WS_METHODS.scheduledTasksGetWebhookDelivery, - scheduledTasks.getWebhookDelivery(input), - { "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id }, - ), - [WS_METHODS.serverProbe]: (_input) => - observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetConfig, - loadServerConfig({ usageLimitsCommand: false }), - { - "rpc.aggregate": "server", - }, + Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( + Effect.andThen(scheduledTasks.getWebhookDelivery(input)), ), + [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), + [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig({ usageLimitsCommand: false }), [WS_METHODS.serverSearchAcpRegistry]: (input) => - observeRpcEffect( - WS_METHODS.serverSearchAcpRegistry, - acpRegistryCatalog - .search(input) - .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), - { "rpc.aggregate": "server" }, - ), + acpRegistryCatalog + .search(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), [WS_METHODS.serverPrepareAcpRegistryAgent]: (input) => - observeRpcEffect( - WS_METHODS.serverPrepareAcpRegistryAgent, - acpRegistryCatalog - .prepare(input) - .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), - { - "rpc.aggregate": "server", - "acp_registry.agent_id": input.agentId, - }, + Effect.annotateCurrentSpan({ "acp_registry.agent_id": input.agentId }).pipe( + Effect.andThen( + acpRegistryCatalog + .prepare(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), + ), ), [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => - observeRpcEffect( - WS_METHODS.serverUninstallAcpRegistryManagedBinary, - acpRegistryCatalog - .uninstallManagedBinary(input) - .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), - { - "rpc.aggregate": "server", - "acp_registry.agent_id": input.agentId, - }, + Effect.annotateCurrentSpan({ "acp_registry.agent_id": input.agentId }).pipe( + Effect.andThen( + acpRegistryCatalog + .uninstallManagedBinary(input) + .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), + ), ), [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: (input) => - observeRpcEffect( - WS_METHODS.serverAcceptAcpRegistryUrlAuth, - acpRegistryRuntimeCoordinator - .acceptUrlAuthentication(input) - .pipe(Effect.map((accepted) => ({ accepted }))), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - }, + Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId }).pipe( + Effect.andThen( + acpRegistryRuntimeCoordinator + .acceptUrlAuthentication(input) + .pipe(Effect.map((accepted) => ({ accepted }))), + ), ), [WS_METHODS.serverListAcpRegistrySessions]: (input) => - observeRpcEffect( - WS_METHODS.serverListAcpRegistrySessions, - listAcpRegistrySessions(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(listAcpRegistrySessions(input))), [WS_METHODS.serverImportAcpRegistrySession]: (input) => - observeRpcEffect( - WS_METHODS.serverImportAcpRegistrySession, - importAcpRegistrySession(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(importAcpRegistrySession(input))), [WS_METHODS.serverDeleteAcpRegistrySession]: (input) => - observeRpcEffect( - WS_METHODS.serverDeleteAcpRegistrySession, - deleteAcpRegistrySession(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(deleteAcpRegistrySession(input))), [WS_METHODS.serverListAcpRegistryProviders]: (input) => - observeRpcEffect( - WS_METHODS.serverListAcpRegistryProviders, - listAcpRegistryProviders(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(listAcpRegistryProviders(input))), [WS_METHODS.serverSetAcpRegistryProvider]: (input) => - observeRpcEffect(WS_METHODS.serverSetAcpRegistryProvider, setAcpRegistryProvider(input), { - "rpc.aggregate": "server", + Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId, "project.id": input.projectId, - }), + }).pipe(Effect.andThen(setAcpRegistryProvider(input))), [WS_METHODS.serverDisableAcpRegistryProvider]: (input) => - observeRpcEffect( - WS_METHODS.serverDisableAcpRegistryProvider, - disableAcpRegistryProvider(input), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - "project.id": input.projectId, - }, - ), + Effect.annotateCurrentSpan({ + "provider.instance_id": input.instanceId, + "project.id": input.projectId, + }).pipe(Effect.andThen(disableAcpRegistryProvider(input))), [WS_METHODS.serverLogoutAcpRegistry]: (input) => - observeRpcEffect( - WS_METHODS.serverLogoutAcpRegistry, - Effect.gen(function* () { - const { instance, manager } = yield* acpSessionManager(input.instanceId); - const snapshot = yield* instance.snapshot.getSnapshot; - if (snapshot.auth.canLogout !== true) { - return yield* new AcpRegistryOperationError({ - reason: "logout_unsupported", - message: "The ACP agent does not advertise logout.", - }); - } - if (instance.auth) { - yield* providerAuth.logout(input).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "logout_failed", - message: "Could not sign out of the ACP agent.", - cause, - }), - ), - ); - } else { - yield* providerSessionManager.closeInstance(input.instanceId).pipe( - Effect.mapError( - (cause) => - new AcpRegistryOperationError({ - reason: "logout_failed", - message: "Could not stop live sessions before ACP logout.", - cause, - }), - ), - ); - yield* manager.logout(config.cwd); - } - yield* providerRegistry.refreshInstance(input.instanceId); - return { loggedOut: true } as const; - }), - { - "rpc.aggregate": "server", - "provider.instance_id": input.instanceId, - }, - ), - [WS_METHODS.serverRefreshProviders]: (input) => - observeRpcEffect( - WS_METHODS.serverRefreshProviders, - Effect.gen(function* () { - // Only explicit catalog refreshes bypass T3's caches. Workspace - // discovery and background status checks retain their timers. - if (input.refreshModels) { - yield* modelManifest.forceRefresh; - const instances = yield* providerInstances.listInstances; - yield* Effect.forEach( - instances.filter( - (instance) => - input.instanceId === undefined || input.instanceId === instance.instanceId, - ), - (instance) => - Effect.gen(function* () { - yield* instance.invalidateCaches ?? Effect.void; - const maintenance = yield* instance.snapshot.resolveMaintenance({ - fresh: true, - }); - if (maintenance.packageName) - providerVersionCache.delete(maintenance.packageName); - }), - { concurrency: "unbounded", discard: true }, - ); - } - // An untargeted refresh is "re-read everything's status", which - // includes quota from configured usage-limit sources. Awaited, - // not forked: the RPC scope closes on return and would - // interrupt a fork before the hub answered. - if (input.instanceId === undefined) { - yield* usageLimitSources.refresh; - } - let providers = yield* input.cwd !== undefined && input.instanceId !== undefined - ? providerRegistry.refreshWorkspaceSnapshot({ - instanceId: input.instanceId, - cwd: input.cwd, - fresh: input.fresh === true, - }) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh(); - if (input.refreshModels) { - const instances = yield* providerInstances.listInstances; - for (const instance of instances) { - if ( - !instance.refreshModels || - (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || - !providers.some( - (provider) => - provider.instanceId === instance.instanceId && - provider.enabled && - provider.installed, - ) - ) - continue; - yield* instance.refreshModels().pipe( + Effect.annotateCurrentSpan({ "provider.instance_id": input.instanceId }).pipe( + Effect.andThen( + Effect.gen(function* () { + const { instance, manager } = yield* acpSessionManager(input.instanceId); + const snapshot = yield* instance.snapshot.getSnapshot; + if (snapshot.auth.canLogout !== true) { + return yield* new AcpRegistryOperationError({ + reason: "logout_unsupported", + message: "The ACP agent does not advertise logout.", + }); + } + if (instance.auth) { + yield* providerAuth.logout(input).pipe( + Effect.mapError( + (cause) => + new AcpRegistryOperationError({ + reason: "logout_failed", + message: "Could not sign out of the ACP agent.", + cause, + }), + ), + ); + } else { + yield* providerSessionManager.closeInstance(input.instanceId).pipe( Effect.mapError( - (error) => - new ProviderSetupError({ - instanceId: instance.instanceId, - operation: "refresh-models", - detail: error.detail, + (cause) => + new AcpRegistryOperationError({ + reason: "logout_failed", + message: "Could not stop live sessions before ACP logout.", + cause, }), ), ); - providers = yield* providerRegistry.refreshInstance(instance.instanceId); + yield* manager.logout(config.cwd); } - } - return { providers }; - }), - { "rpc.aggregate": "server" }, + yield* providerRegistry.refreshInstance(input.instanceId); + return { loggedOut: true } as const; + }), + ), ), - [WS_METHODS.providerUploadFeedback]: (input) => - observeRpcEffect( - WS_METHODS.providerUploadFeedback, - Effect.gen(function* () { - const projection = yield* threadManagement.getThreadRecords(input.threadId, [ - "providerThreads", - ]); - const providerThread = - projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ) ?? projection.providerThreads.at(-1); - const providerSessionId = providerThread?.providerSessionId ?? null; - if (providerThread === undefined || providerSessionId === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "No provider session has run in this thread yet.", + [WS_METHODS.serverRefreshProviders]: (input) => + Effect.gen(function* () { + // Only explicit catalog refreshes bypass T3's caches. Workspace + // discovery and background status checks retain their timers. + if (input.refreshModels) { + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) + providerVersionCache.delete(maintenance.packageName); }), + { concurrency: "unbounded", discard: true }, + ); + } + // An untargeted refresh is "re-read everything's status", which + // includes quota from configured usage-limit sources. Awaited, + // not forked: the RPC scope closes on return and would + // interrupt a fork before the hub answered. + if (input.instanceId === undefined) { + yield* usageLimitSources.refresh; + } + let providers = yield* input.cwd !== undefined && input.instanceId !== undefined + ? providerRegistry.refreshWorkspaceSnapshot({ + instanceId: input.instanceId, + cwd: input.cwd, + fresh: input.fresh === true, + }) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(); + if (input.refreshModels) { + const instances = yield* providerInstances.listInstances; + for (const instance of instances) { + if ( + !instance.refreshModels || + (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || + !providers.some( + (provider) => + provider.instanceId === instance.instanceId && + provider.enabled && + provider.installed, + ) + ) + continue; + yield* instance.refreshModels().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "refresh-models", + detail: error.detail, + }), + ), ); + providers = yield* providerRegistry.refreshInstance(instance.instanceId); } - const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); - if (runtime === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ + } + return { providers }; + }), + [WS_METHODS.providerUploadFeedback]: (input) => + Effect.gen(function* () { + const projection = yield* threadManagement.getThreadRecords(input.threadId, [ + "providerThreads", + ]); + const providerThread = + projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) ?? projection.providerThreads.at(-1); + const providerSessionId = providerThread?.providerSessionId ?? null; + if (providerThread === undefined || providerSessionId === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "No provider session has run in this thread yet.", + }), + ); + } + const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); + if (runtime === null) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "The provider session is no longer running. Send a message first.", + }), + ); + } + if (runtime.uploadFeedback === undefined) { + return yield* Effect.fail( + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: `Provider '${runtime.driver}' does not support feedback uploads.`, + }), + ); + } + return yield* runtime.uploadFeedback({ + providerThread, + ...(input.reason === undefined ? {} : { reason: input.reason }), + }); + }).pipe( + Effect.mapError((cause) => + isProviderUploadFeedbackError(cause) + ? cause + : new ProviderUploadFeedbackError({ threadId: input.threadId, - cause: "The provider session is no longer running. Send a message first.", + cause, }), - ); - } - if (runtime.uploadFeedback === undefined) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: `Provider '${runtime.driver}' does not support feedback uploads.`, - }), - ); - } - return yield* runtime.uploadFeedback({ - providerThread, - ...(input.reason === undefined ? {} : { reason: input.reason }), - }); - }).pipe( - Effect.mapError((cause) => - isProviderUploadFeedbackError(cause) - ? cause - : new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause, - }), - ), ), - { "rpc.aggregate": "provider" }, ), [WS_METHODS.serverUpdateProvider]: (input) => - observeRpcEffect( - WS_METHODS.serverUpdateProvider, - providerMaintenanceRunner.updateProvider(input), - { - "rpc.aggregate": "server", - }, - ), + providerMaintenanceRunner.updateProvider(input), [WS_METHODS.providerConsumeResetCredit]: (input) => - observeRpcEffect( - WS_METHODS.providerConsumeResetCredit, - Effect.gen(function* () { - if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); - const instance = yield* providerInstances.getInstance(input.instanceId); - // A disabled instance must not spend anything on its account. - if (instance === undefined || !instance.enabled) { - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "consume-reset-credit", - detail: instance ? "This provider is disabled." : "Provider instance not found.", - }); - } - if (instance.consumeResetCredit === undefined) { - return yield* new ProviderSetupError({ - instanceId: input.instanceId, - operation: "consume-reset-credit", - detail: "This provider does not bank reset credits.", - }); - } - const outcome = yield* instance.consumeResetCredit().pipe( - Effect.mapError( - (error) => - new ProviderSetupError({ - instanceId: input.instanceId, - operation: "consume-reset-credit", - detail: error.detail, - cause: error, - }), - ), - ); - return { outcome }; - }), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerAuthStart]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthStart, - providerAuth.start(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), + Effect.gen(function* () { + if ("sourceId" in input) return yield* usageLimitSources.consumeResetCredit(input); + const instance = yield* providerInstances.getInstance(input.instanceId); + // A disabled instance must not spend anything on its account. + if (instance === undefined || !instance.enabled) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: instance ? "This provider is disabled." : "Provider instance not found.", + }); + } + if (instance.consumeResetCredit === undefined) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: "This provider does not bank reset credits.", + }); + } + const outcome = yield* instance.consumeResetCredit().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: error.detail, + cause: error, + }), + ), + ); + return { outcome }; + }), + [WS_METHODS.providerAuthStart]: (input) => providerAuth.start(input, currentSessionId), [WS_METHODS.providerAuthRespond]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthRespond, - providerAuth.respond(input, currentSessionId), - { - "rpc.aggregate": "provider", - instanceId: input.instanceId, - }, + Effect.annotateCurrentSpan({ instanceId: input.instanceId }).pipe( + Effect.andThen(providerAuth.respond(input, currentSessionId)), ), [WS_METHODS.providerAuthComplete]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthComplete, - providerAuth.complete(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), + providerAuth.complete(input, currentSessionId), [WS_METHODS.chatGptReconnectProfile]: (input) => providerAuth.reconnectProfile(input), [WS_METHODS.chatGptImportProfile]: (input) => providerAuth.importProfile(input), [WS_METHODS.chatGptHandoffSubscribe]: (input) => subscribeChatGptHandoff(input, currentSessionId), - [WS_METHODS.codexAuthCallbackSubscribe]: (input) => - observeRpcStream( - WS_METHODS.codexAuthCallbackSubscribe, - subscribeCodexAuthCallback(input), - { - "rpc.aggregate": "provider", - }, - ), - [WS_METHODS.providerAuthCancel]: (input) => - observeRpcEffect( - WS_METHODS.providerAuthCancel, - providerAuth.cancel(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerAuthLogout]: (input) => - observeRpcEffect(WS_METHODS.providerAuthLogout, providerAuth.logout(input), { - "rpc.aggregate": "provider", - }), + [WS_METHODS.codexAuthCallbackSubscribe]: (input) => subscribeCodexAuthCallback(input), + [WS_METHODS.providerAuthCancel]: (input) => providerAuth.cancel(input, currentSessionId), + [WS_METHODS.providerAuthLogout]: (input) => providerAuth.logout(input), [WS_METHODS.providerAuthSubscribe]: (input) => - observeRpcStream( - WS_METHODS.providerAuthSubscribe, - providerAuth.subscribe(input, currentSessionId), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerInstallStart]: (input) => - observeRpcEffect(WS_METHODS.providerInstallStart, providerInstallation.start(input), { - "rpc.aggregate": "provider", - }), - [WS_METHODS.providerInstallCancel]: (input) => - observeRpcEffect(WS_METHODS.providerInstallCancel, providerInstallation.cancel(input), { - "rpc.aggregate": "provider", - }), - [WS_METHODS.providerInstallSubscribe]: (input) => - observeRpcStream( - WS_METHODS.providerInstallSubscribe, - providerInstallation.subscribe(input), - { "rpc.aggregate": "provider" }, - ), - [WS_METHODS.providerInstallRemove]: (input) => - observeRpcEffect(WS_METHODS.providerInstallRemove, providerInstallation.remove(input), { - "rpc.aggregate": "provider", - }), - [WS_METHODS.serverUpdateServer]: (input) => - observeRpcEffect(WS_METHODS.serverUpdateServer, serverSelfUpdate.update(input), { - "rpc.aggregate": "server", - }), + providerAuth.subscribe(input, currentSessionId), + [WS_METHODS.providerInstallStart]: (input) => providerInstallation.start(input), + [WS_METHODS.providerInstallCancel]: (input) => providerInstallation.cancel(input), + [WS_METHODS.providerInstallSubscribe]: (input) => providerInstallation.subscribe(input), + [WS_METHODS.providerInstallRemove]: (input) => providerInstallation.remove(input), + [WS_METHODS.serverUpdateServer]: (input) => serverSelfUpdate.update(input), [WS_METHODS.serverUpdateServerWithProgress]: (input) => - observeRpcStream( - WS_METHODS.serverUpdateServerWithProgress, - Stream.callback((queue) => - serverSelfUpdate - .update(input, (stage) => + Stream.callback((queue) => + serverSelfUpdate + .update(input, (stage) => + Queue.offer(queue, { + type: "progress", + stage, + }).pipe(Effect.asVoid), + ) + .pipe( + Effect.flatMap((result) => Queue.offer(queue, { - type: "progress", - stage, - }).pipe(Effect.asVoid), - ) - .pipe( - Effect.flatMap((result) => - Queue.offer(queue, { - type: "complete", - result, - }), - ), - Effect.catchTags({ - ServerSelfUpdateError: (error) => Queue.fail(queue, error), + type: "complete", + result, }), - Effect.andThen(Queue.end(queue)), - Effect.forkScoped, ), - ), - { "rpc.aggregate": "server" }, + Effect.catchTags({ + ServerSelfUpdateError: (error) => Queue.fail(queue, error), + }), + Effect.andThen(Queue.end(queue)), + Effect.forkScoped, + ), ), [WS_METHODS.serverCommitDesktopUpdate]: (input) => - observeRpcEffect( - WS_METHODS.serverCommitDesktopUpdate, - serverSelfUpdate.commitDesktopUpdate(input.requestId), - { "rpc.aggregate": "server" }, - ), + serverSelfUpdate.commitDesktopUpdate(input.requestId), [WS_METHODS.serverUpsertKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverUpsertKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; - }), - { "rpc.aggregate": "server" }, - ), + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), [WS_METHODS.serverRemoveKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverRemoveKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; - }), - { "rpc.aggregate": "server" }, - ), + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetSettings, - serverSettings.getSettings.pipe( - Effect.map(ServerSettings.redactServerSettingsForClient), - ), - { - "rpc.aggregate": "server", - }, - ), + serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => - observeRpcEffect( - WS_METHODS.serverUpdateSettings, - Effect.gen(function* () { - const deviceHosts = patch.deviceHosts - ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( - Effect.provide(deviceHostContext), - ) - : undefined; - const nextPatch = { ...patch, ...(deviceHosts ? { deviceHosts } : {}) }; - const settings = yield* providerInstanceMutation === undefined - ? serverSettings.updateSettings(nextPatch) - : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); - return ServerSettings.redactServerSettingsForClient(settings); - }), - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverDiscoverSourceControl]: (_input) => - observeRpcEffect( - WS_METHODS.serverDiscoverSourceControl, - sourceControlDiscovery.discover, - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverGetTraceDiagnostics]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetTraceDiagnostics, - TraceDiagnostics.readTraceDiagnostics({ - traceFilePath: config.serverTracePath, - maxFiles: config.traceMaxFiles, - }), - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverGetProcessDiagnostics]: (_input) => - observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { - "rpc.aggregate": "server", + Effect.gen(function* () { + const deviceHosts = patch.deviceHosts + ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( + Effect.provide(deviceHostContext), + ) + : undefined; + const nextPatch = { ...patch, ...(deviceHosts ? { deviceHosts } : {}) }; + const settings = yield* providerInstanceMutation === undefined + ? serverSettings.updateSettings(nextPatch) + : serverSettings.updateProviderInstance(providerInstanceMutation, nextPatch); + return ServerSettings.redactServerSettingsForClient(settings); }), - [WS_METHODS.serverGetHostResources]: (_input) => - observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { - "rpc.aggregate": "server", + [WS_METHODS.serverDiscoverSourceControl]: (_input) => sourceControlDiscovery.discover, + [WS_METHODS.serverGetTraceDiagnostics]: (_input) => + TraceDiagnostics.readTraceDiagnostics({ + traceFilePath: config.serverTracePath, + maxFiles: config.traceMaxFiles, }), + [WS_METHODS.serverGetProcessDiagnostics]: (_input) => processDiagnostics.read, + [WS_METHODS.serverGetHostResources]: (_input) => hostResources.read, [WS_METHODS.serverGetProcessResourceHistory]: (input) => - observeRpcEffect( - WS_METHODS.serverGetProcessResourceHistory, - processResourceMonitor.readHistory(input), - { - "rpc.aggregate": "server", - }, - ), + processResourceMonitor.readHistory(input), [WS_METHODS.serverGetResourceTelemetryHistory]: (input) => - observeRpcEffect( - WS_METHODS.serverGetResourceTelemetryHistory, - resourceTelemetry.readHistory(input), - { - "rpc.aggregate": "server", - }, - ), - [WS_METHODS.serverGetUsageSummary]: (input) => - observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRefreshUsageRates]: (_input) => - observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRetryResourceTelemetry]: (_input) => - observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverSignalProcess]: (input) => - observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { - "rpc.aggregate": "server", - }), + resourceTelemetry.readHistory(input), + [WS_METHODS.serverGetUsageSummary]: (input) => usage.readSummary(input), + [WS_METHODS.serverRefreshUsageRates]: (_input) => usage.refreshRates, + [WS_METHODS.serverRetryResourceTelemetry]: (_input) => resourceTelemetry.retry, + [WS_METHODS.serverSignalProcess]: (input) => processDiagnostics.signal(input), [WS_METHODS.serverReportClientActivity]: (input, metadata) => Ref.update(rpcClientIds, (clientIds) => { const next = new Set(clientIds); @@ -2660,583 +2401,303 @@ const layerWsRpc = ( return next; }).pipe( Effect.andThen( - observeRpcEffect( - WS_METHODS.serverReportClientActivity, - backgroundPolicy.reportClientActivity( - currentSessionId, - RpcClientId.make(metadata.client.id), - input, - ), - { "rpc.aggregate": "server" }, + backgroundPolicy.reportClientActivity( + currentSessionId, + RpcClientId.make(metadata.client.id), + input, ), ), ), [WS_METHODS.serverReportHostPowerState]: (input) => - observeRpcEffect( - WS_METHODS.serverReportHostPowerState, - backgroundPolicy.reportHostPowerState(input), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverGetBackgroundPolicy]: (_input) => - observeRpcEffect(WS_METHODS.serverGetBackgroundPolicy, backgroundPolicy.snapshot, { - "rpc.aggregate": "server", - }), - [WS_METHODS.cloudGetRelayClientStatus]: (_input) => - observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { - "rpc.aggregate": "cloud", - }), + backgroundPolicy.reportHostPowerState(input), + [WS_METHODS.serverGetBackgroundPolicy]: (_input) => backgroundPolicy.snapshot, + [WS_METHODS.cloudGetRelayClientStatus]: (_input) => relayClient.resolve, [WS_METHODS.cloudInstallRelayClient]: (_input) => - observeRpcStream( - WS_METHODS.cloudInstallRelayClient, - Stream.callback( - (queue) => - relayClient - .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) - .pipe( - Effect.flatMap((status) => - Queue.offer(queue, { - type: "complete", - status, + Stream.callback((queue) => + relayClient + .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) + .pipe( + Effect.flatMap((status) => + Queue.offer(queue, { + type: "complete", + status, + }), + ), + Effect.catchTags({ + RelayClientInstallError: (error) => + Queue.fail( + queue, + new RelayClientInstallFailedError({ + reason: error.reason, + message: error.message, }), ), - Effect.catchTags({ - RelayClientInstallError: (error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: error.reason, - message: error.message, - }), - ), - }), - Effect.andThen(Queue.end(queue)), - Effect.forkScoped, - ), - ), - { "rpc.aggregate": "cloud" }, - ), - [WS_METHODS.pullRequestsList]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { - "rpc.aggregate": "pull-requests", - }), - [WS_METHODS.pullRequestsListStats]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { - "rpc.aggregate": "pull-requests", - }), - [WS_METHODS.pullRequestsRoutingIdentity]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsRoutingIdentity, - pullRequests.routingIdentity(input), - { - "rpc.aggregate": "pull-requests", - }, + }), + Effect.andThen(Queue.end(queue)), + Effect.forkScoped, + ), ), - [WS_METHODS.pullRequestsRouting]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsRouting, pullRequests.routing(input), { - "rpc.aggregate": "pull-requests", - }), + [WS_METHODS.pullRequestsList]: (input) => pullRequests.list(input), + [WS_METHODS.pullRequestsListStats]: (input) => pullRequests.listStats(input), + [WS_METHODS.pullRequestsRoutingIdentity]: (input) => pullRequests.routingIdentity(input), + [WS_METHODS.pullRequestsRouting]: (input) => pullRequests.routing(input), [WS_METHODS.pullRequestsSummary]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSummary, - withPullRequestViewer(input, pullRequests.summary(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.summary(input)), [WS_METHODS.pullRequestsStack]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsStack, - withPullRequestViewer(input, pullRequests.stack(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.stack(input)), [WS_METHODS.pullRequestsLinkedThreads]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsLinkedThreads, - resolvePullRequestSyncKey(input).pipe( - Effect.flatMap((key) => - key === null - ? Effect.succeed({ threads: [] }) - : listLinkedPullRequestThreads(key).pipe( - Effect.provideService(SqlClient.SqlClient, sql), - ), - ), + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null + ? Effect.succeed({ threads: [] }) + : listLinkedPullRequestThreads(key).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ), ), - { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsDetail]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsDetail, - withPullRequestViewer(input, pullRequests.detail(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.detail(input)), [WS_METHODS.pullRequestsPreview]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsPreview, - withPullRequestViewer(input, pullRequests.preview(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.preview(input)), [WS_METHODS.pullRequestsChecks]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsChecks, - withPullRequestViewer(input, pullRequests.checks(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.checks(input)), [WS_METHODS.pullRequestsActivity]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsActivity, - withPullRequestViewer(input, pullRequests.activity(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.activity(input)), [WS_METHODS.pullRequestsThreadComments]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsThreadComments, - withPullRequestViewer(input, pullRequests.threadComments(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.threadComments(input)), [WS_METHODS.pullRequestsDiffFileContents]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsDiffFileContents, - withPullRequestViewer(input, pullRequests.diffFileContents(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.diffFileContents(input)), [WS_METHODS.pullRequestsFilesViewed]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsFilesViewed, - withPullRequestViewer(input, pullRequests.filesViewed(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.filesViewed(input)), [WS_METHODS.pullRequestsSetFilesViewed]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetFilesViewed, - withPullRequestViewer(input, pullRequests.setFilesViewed(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.setFilesViewed(input)), [WS_METHODS.pullRequestsRunAction]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsRunAction, - withPullRequestViewer(input, pullRequests.runAction(input)).pipe( - Effect.tap(() => - resolvePullRequestSyncKey(input).pipe( - Effect.flatMap((key) => - key === null ? Effect.void : pullRequestSync.requestSync(key), - ), + withPullRequestViewer(input, pullRequests.runAction(input)).pipe( + Effect.tap(() => + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), ), ), ), - { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsUpdate]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsUpdate, - withPullRequestViewer(input, pullRequests.update(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.update(input)), [WS_METHODS.pullRequestsComment]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsComment, - withPullRequestViewer(input, pullRequests.comment(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.comment(input)), [WS_METHODS.pullRequestsUpdateComment]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsUpdateComment, - withPullRequestViewer(input, pullRequests.updateComment(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.updateComment(input)), [WS_METHODS.pullRequestsSubmitReview]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSubmitReview, - withPullRequestViewer(input, pullRequests.submitReview(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.submitReview(input)), [WS_METHODS.pullRequestsReplyToThread]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsReplyToThread, - withPullRequestViewer(input, pullRequests.replyToThread(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.replyToThread(input)), [WS_METHODS.pullRequestsSetThreadResolution]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetThreadResolution, - withPullRequestViewer(input, pullRequests.setThreadResolution(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.setThreadResolution(input)), [WS_METHODS.pullRequestsSetReaction]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetReaction, - withPullRequestViewer(input, pullRequests.setReaction(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.setReaction(input)), [WS_METHODS.pullRequestsInvalidate]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsInvalidate, - pullRequests.invalidate(input, { notifyReaders: true }).pipe( - // A reader asking for fresh host state also wants the thread badges it feeds to - // catch up, including a merged link the sweep would otherwise never revisit. - Effect.andThen( - input.reference === undefined || input.filesViewedOnly === true - ? Effect.void - : resolvePullRequestSyncKey(input.reference).pipe( - Effect.flatMap((key) => - key === null ? Effect.void : pullRequestSync.requestSync(key), - ), + pullRequests.invalidate(input, { notifyReaders: true }).pipe( + // A reader asking for fresh host state also wants the thread badges it feeds to + // catch up, including a merged link the sweep would otherwise never revisit. + Effect.andThen( + input.reference === undefined || input.filesViewedOnly === true + ? Effect.void + : resolvePullRequestSyncKey(input.reference).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), ), - ), + ), ), - { "rpc.aggregate": "pull-requests" }, - ), - [WS_METHODS.pullRequestsSubscribeRefreshes]: () => - observeRpcStream( - WS_METHODS.pullRequestsSubscribeRefreshes, - pullRequests.subscribeRefreshes, - { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => pullRequests.subscribeRefreshes, [WS_METHODS.pullRequestsReviewerCandidates]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsReviewerCandidates, - withPullRequestViewer(input, pullRequests.reviewerCandidates(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.reviewerCandidates(input)), [WS_METHODS.pullRequestsRequestReviewers]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsRequestReviewers, - withPullRequestViewer(input, pullRequests.requestReviewers(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.requestReviewers(input)), [WS_METHODS.pullRequestsLabelCandidates]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsLabelCandidates, - withPullRequestViewer(input, pullRequests.labelCandidates(input)), - { "rpc.aggregate": "pull-requests" }, - ), + withPullRequestViewer(input, pullRequests.labelCandidates(input)), [WS_METHODS.pullRequestsSetLabels]: (input) => - observeRpcEffect( - WS_METHODS.pullRequestsSetLabels, - withPullRequestViewer(input, pullRequests.setLabels(input)), - { - "rpc.aggregate": "pull-requests", - }, - ), + withPullRequestViewer(input, pullRequests.setLabels(input)), [WS_METHODS.sourceControlLookupRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlLookupRepository, - sourceControlRepositories.lookupRepository(input), - { - "rpc.aggregate": "source-control", - }, - ), + sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlCloneRepository, - sourceControlRepositories.cloneRepository(input), - { - "rpc.aggregate": "source-control", - }, - ), + sourceControlRepositories.cloneRepository(input), [WS_METHODS.projectCloneStart]: (input) => - observeRpcEffect( - WS_METHODS.projectCloneStart, - projectCloneTracker.start(input, { - createProject: (project) => - projectService - .create({ - commandId: CommandId.make(`project-clone-create:${project.projectId}`), - projectId: project.projectId, - title: project.title, - workspaceRoot: project.workspaceRoot, - createWorkspaceRootIfMissing: true, - }) - .pipe( - Effect.asVoid, - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ - message: "Failed to create clone project.", - cause, - }), - ), - ), - onCloned: (project) => - repositoryIdentityResolver.resolve(project.workspaceRoot, { refresh: true }).pipe( - Effect.andThen( - projectService.update({ - commandId: CommandId.make(`project-clone-done:${project.projectId}`), - projectId: project.projectId, - }), + projectCloneTracker.start(input, { + createProject: (project) => + projectService + .create({ + commandId: CommandId.make(`project-clone-create:${project.projectId}`), + projectId: project.projectId, + title: project.title, + workspaceRoot: project.workspaceRoot, + createWorkspaceRootIfMissing: true, + }) + .pipe( + Effect.asVoid, + Effect.mapError( + (cause) => + new OrchestrationDispatchCommandError({ + message: "Failed to create clone project.", + cause, + }), ), - Effect.andThen(refreshGitStatus(project.workspaceRoot)), - Effect.ignoreCause({ log: true }), ), - }), - { "rpc.aggregate": "source-control" }, - ), - [WS_METHODS.projectsEnsureScratch]: () => - observeRpcEffect( - WS_METHODS.projectsEnsureScratch, - managedFolders.ensureScratchProject.pipe( - Effect.mapError( - (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), + onCloned: (project) => + repositoryIdentityResolver.resolve(project.workspaceRoot, { refresh: true }).pipe( + Effect.andThen( + projectService.update({ + commandId: CommandId.make(`project-clone-done:${project.projectId}`), + projectId: project.projectId, + }), + ), + Effect.andThen(refreshGitStatus(project.workspaceRoot)), + Effect.ignoreCause({ log: true }), ), + }), + [WS_METHODS.projectsEnsureScratch]: () => + managedFolders.ensureScratchProject.pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), - { "rpc.aggregate": "orchestration" }, ), [WS_METHODS.projectsCreateNew]: (input) => - observeRpcEffect( - WS_METHODS.projectsCreateNew, - managedFolders - .createNamedProject(input) - .pipe( - Effect.mapError( - (cause) => - new OrchestrationDispatchCommandError({ message: cause.message, cause }), - ), + managedFolders + .createNamedProject(input) + .pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), - { "rpc.aggregate": "orchestration" }, - ), + ), [WS_METHODS.projectCloneCancel]: (input) => - observeRpcEffect( - WS_METHODS.projectCloneCancel, - projectCloneTracker - .cancel(input.projectId) - .pipe(Effect.map((applied) => ({ applied }))), - { "rpc.aggregate": "source-control" }, - ), + projectCloneTracker.cancel(input.projectId).pipe(Effect.map((applied) => ({ applied }))), [WS_METHODS.projectCloneRetry]: (input) => - observeRpcEffect( - WS_METHODS.projectCloneRetry, - projectCloneTracker.retry(input.projectId).pipe(Effect.map((applied) => ({ applied }))), - { "rpc.aggregate": "source-control" }, - ), - [WS_METHODS.subscribeProjectClones]: () => - observeRpcStream(WS_METHODS.subscribeProjectClones, projectCloneTracker.stream, { - "rpc.aggregate": "source-control", - }), + projectCloneTracker.retry(input.projectId).pipe(Effect.map((applied) => ({ applied }))), + [WS_METHODS.subscribeProjectClones]: () => projectCloneTracker.stream, [WS_METHODS.sourceControlPublishRepository]: (input) => - observeRpcEffect( - WS_METHODS.sourceControlPublishRepository, - sourceControlRepositories.publishRepository(input).pipe( - // A new remote can change the cached identity. Only the `cwd` entry - // refreshes, so after a publish from a linked worktree the project - // root entry waits for its TTL. - Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), - Effect.tap(() => refreshGitStatus(input.cwd)), - ), - { - "rpc.aggregate": "source-control", - }, + sourceControlRepositories.publishRepository(input).pipe( + // A new remote can change the cached identity. Only the `cwd` entry + // refreshes, so after a publish from a linked worktree the project + // root entry waits for its TTL. + Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), + Effect.tap(() => refreshGitStatus(input.cwd)), ), [WS_METHODS.projectsSearchEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchEntries, - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), - ), + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsSearchContents]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchContents, - workspaceEntries.searchContents(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchContentsError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), - ), + workspaceEntries.searchContents(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchContentsError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsListEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsListEntries, - workspaceEntries.list(input).pipe( - Effect.mapError( - (cause) => - new ProjectListEntriesError({ - ...input, - ...projectEntriesFailureContext(cause), - cause, - }), - ), + workspaceEntries.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListEntriesError({ + ...input, + ...projectEntriesFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsReadFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsReadFile, - workspaceFileSystem.readFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectReadFileError({ - ...input, - ...projectFileFailureContext(cause), - cause, - }), - ), + workspaceFileSystem.readFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsWriteFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ - cwd: input.cwd, - relativePath: input.relativePath, - ...projectFileFailureContext(cause), - cause, - }), - ), + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), [WS_METHODS.projectsMutate]: (mutation) => - observeRpcEffect( - WS_METHODS.projectsMutate, - startup.enqueueCommand(mutateProject(mutation)).pipe( - Effect.mapError( - (cause) => - new ProjectMutationError({ - commandId: mutation.commandId, - message: - cause._tag === "ProjectNotEmptyError" - ? cause.message - : "Failed to mutate project.", - cause, - }), - ), + startup.enqueueCommand(mutateProject(mutation)).pipe( + Effect.mapError( + (cause) => + new ProjectMutationError({ + commandId: mutation.commandId, + message: + cause._tag === "ProjectNotEmptyError" + ? cause.message + : "Failed to mutate project.", + cause, + }), ), - { "rpc.aggregate": "orchestration" }, ), - [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { - "rpc.aggregate": "workspace", - }), + [WS_METHODS.shellOpenInEditor]: (input) => externalLauncher.launchEditor(input), [WS_METHODS.filesystemBrowse]: (input) => - observeRpcEffect( - WS_METHODS.filesystemBrowse, - workspaceEntries.browse(input).pipe( - Effect.mapError( - (cause) => - new FilesystemBrowseError({ - ...input, - ...filesystemBrowseFailureContext(cause), - cause, - }), - ), + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + ...input, + ...filesystemBrowseFailureContext(cause), + cause, + }), ), - { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.attachmentsCreateUploadUrl]: (input) => - observeRpcEffect(WS_METHODS.attachmentsCreateUploadUrl, issueAttachmentUploadUrl(input), { - "rpc.aggregate": "workspace", - }), - [WS_METHODS.attachmentsDelete]: (input) => - observeRpcEffect( - WS_METHODS.attachmentsDelete, - deletePendingAttachment(input.attachmentId), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.agentSessionsScan]: () => - observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { - "rpc.aggregate": "workspace", - }), + [WS_METHODS.attachmentsCreateUploadUrl]: (input) => issueAttachmentUploadUrl(input), + [WS_METHODS.attachmentsDelete]: (input) => deletePendingAttachment(input.attachmentId), + [WS_METHODS.agentSessionsScan]: () => agentSessionScanner.scan, [WS_METHODS.agentSessionsImport]: (input) => - observeRpcEffect( - WS_METHODS.agentSessionsImport, - agentSessionImporter.importRecentAgentThreads(input), - { "rpc.aggregate": "workspace" }, - ), + agentSessionImporter.importRecentAgentThreads(input), [WS_METHODS.assetsCreateUrl]: (input) => - observeRpcEffect( - WS_METHODS.assetsCreateUrl, - Effect.gen(function* () { - const path = yield* Path.Path; - // An absolute media path can be linked from a thread on another environment. - if ( - input.resource._tag === "attachment" || - input.resource._tag === "native-app-icon" || - input.resource._tag === "tool-output-image" || - // GitHub media names the repository it authenticates through itself. - input.resource._tag === "github-media" || - (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) - ) { - return yield* issueAssetUrl({ resource: input.resource }); - } - if (input.resource._tag === "draft-workspace-file") { - // A project draft names its workspace directly; there is no - // thread to resolve one from. - return yield* issueAssetUrl({ - resource: input.resource, - workspaceRoot: input.resource.cwd, - }); - } - if (input.resource._tag === "project-favicon") { - const project = yield* projectStore - .findActiveByWorkspaceRoot(input.resource.cwd) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceContextResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - if (Option.isNone(project)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - // A cloned project exists before its files do. Clients ask again - // when the clone lands (see createProjectFaviconUrlAtomFamily). - const clone = yield* projectCloneTracker.get(project.value.projectId); - return yield* issueAssetUrl({ - resource: input.resource, - ...(project.value.faviconPath - ? { projectFaviconPath: project.value.faviconPath } - : {}), - projectCheckoutPending: - clone !== null && - clone.phase !== "done" && - clone.destinationPath === project.value.workspaceRoot, - }); - } - const thread = yield* threadManagement - .getThreadRecords(input.resource.threadId, []) + Effect.gen(function* () { + const path = yield* Path.Path; + // An absolute media path can be linked from a thread on another environment. + if ( + input.resource._tag === "attachment" || + input.resource._tag === "native-app-icon" || + input.resource._tag === "tool-output-image" || + // GitHub media names the repository it authenticates through itself. + input.resource._tag === "github-media" || + (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path)) + ) { + return yield* issueAssetUrl({ resource: input.resource }); + } + if (input.resource._tag === "draft-workspace-file") { + // A project draft names its workspace directly; there is no + // thread to resolve one from. + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: input.resource.cwd, + }); + } + if (input.resource._tag === "project-favicon") { + const project = yield* projectStore + .findActiveByWorkspaceRoot(input.resource.cwd) .pipe( Effect.mapError( (cause) => @@ -3246,7 +2707,28 @@ const layerWsRpc = ( }), ), ); - const project = yield* projectService.getById(thread.thread.projectId).pipe( + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ + resource: input.resource, + }); + } + // A cloned project exists before its files do. Clients ask again + // when the clone lands (see createProjectFaviconUrlAtomFamily). + const clone = yield* projectCloneTracker.get(project.value.projectId); + return yield* issueAssetUrl({ + resource: input.resource, + ...(project.value.faviconPath + ? { projectFaviconPath: project.value.faviconPath } + : {}), + projectCheckoutPending: + clone !== null && + clone.phase !== "done" && + clone.destinationPath === project.value.workspaceRoot, + }); + } + const thread = yield* threadManagement + .getThreadRecords(input.resource.threadId, []) + .pipe( Effect.mapError( (cause) => new AssetWorkspaceContextResolutionError({ @@ -3255,357 +2737,187 @@ const layerWsRpc = ( }), ), ); - if (Option.isNone(project)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - return yield* issueAssetUrl({ + const project = yield* projectService.getById(thread.thread.projectId).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource, - workspaceRoot: thread.thread.worktreePath ?? project.value.workspaceRoot, }); - }), - { "rpc.aggregate": "workspace" }, - ), + } + return yield* issueAssetUrl({ + resource: input.resource, + workspaceRoot: thread.thread.worktreePath ?? project.value.workspaceRoot, + }); + }), [WS_METHODS.assetsPersistChatAttachments]: (input) => - observeRpcEffect( - WS_METHODS.assetsPersistChatAttachments, - persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), - { "rpc.aggregate": "orchestration" }, - ), + persistChatAttachments(input).pipe(Effect.map((attachments) => ({ attachments }))), [WS_METHODS.subscribeVcsStatus]: (input) => - observeRpcStream( - WS_METHODS.subscribeVcsStatus, - vcsStatusBroadcaster.streamStatus(input, { - automaticRemoteRefreshInterval: automaticGitFetchInterval, - }), - { - "rpc.aggregate": "vcs", - }, - ), - [WS_METHODS.subscribeWorktreeSetup]: (input) => - observeRpcStream( - WS_METHODS.subscribeWorktreeSetup, - worktreeSetupTracker.stream(input.threadId), - { "rpc.aggregate": "vcs" }, - ), + vcsStatusBroadcaster.streamStatus(input, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), + [WS_METHODS.subscribeWorktreeSetup]: (input) => worktreeSetupTracker.stream(input.threadId), [WS_METHODS.worktreeSetupCancel]: (input) => - observeRpcEffect( - WS_METHODS.worktreeSetupCancel, - worktreeSetupTracker - .cancel(input.threadId) - .pipe(Effect.map((cancelled) => ({ cancelled }))), - { "rpc.aggregate": "vcs" }, - ), - [WS_METHODS.vcsRefreshStatus]: (input) => - observeRpcEffect( - WS_METHODS.vcsRefreshStatus, - vcsStatusBroadcaster.refreshStatus(input.cwd), - { - "rpc.aggregate": "vcs", - }, - ), + worktreeSetupTracker + .cancel(input.threadId) + .pipe(Effect.map((cancelled) => ({ cancelled }))), + [WS_METHODS.vcsRefreshStatus]: (input) => vcsStatusBroadcaster.refreshStatus(input.cwd), [WS_METHODS.vcsPull]: (input) => - observeRpcEffect( - WS_METHODS.vcsPull, - gitWorkflow.pullCurrentBranch(input.cwd).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Effect.failCause(cause), - onSuccess: (result) => - refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), - }), - ), - { "rpc.aggregate": "git" }, + gitWorkflow.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), ), [WS_METHODS.gitRunStackedAction]: (input) => - observeRpcStream( - WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: (result) => - (input.threadId === undefined - ? Effect.void - : linkCreatedPullRequest({ - threadId: input.threadId, - result, - commandId: serverCommandId("pr-created-link"), - }).pipe( - Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), - Effect.provideService(ProjectService.ProjectService, projectService), - ) - ).pipe( - Effect.andThen( - refreshPushedPullRequests(input, result).pipe( - Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), - Effect.provideService(ProjectStore.ProjectStoreV2, projectStore), - Effect.provideService( - PullRequestService.PullRequestService, - pullRequests, - ), + Stream.callback((queue) => + gitWorkflow + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: (result) => + (input.threadId === undefined + ? Effect.void + : linkCreatedPullRequest({ + threadId: input.threadId, + result, + commandId: serverCommandId("pr-created-link"), + }).pipe( + Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), + Effect.provideService(ProjectService.ProjectService, projectService), + ) + ).pipe( + Effect.andThen( + refreshPushedPullRequests(input, result).pipe( + Effect.provideService(Orchestrator.OrchestratorV2, orchestrationEngine), + Effect.provideService(ProjectStore.ProjectStoreV2, projectStore), + Effect.provideService( + PullRequestService.PullRequestService, + pullRequests, ), ), - Effect.andThen(refreshGitStatus(input.cwd)), - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), - }), - ), - ), - { "rpc.aggregate": "vcs" }, - ), - [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect( - WS_METHODS.gitResolvePullRequest, - gitWorkflow.resolvePullRequest(input), - { - "rpc.aggregate": "git", - }, + Effect.andThen(refreshGitStatus(input.cwd)), + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), ), + [WS_METHODS.gitResolvePullRequest]: (input) => gitWorkflow.resolvePullRequest(input), [WS_METHODS.gitPreparePullRequestThread]: (input) => - observeRpcEffect( - WS_METHODS.gitPreparePullRequestThread, - gitWorkflow - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.vcsListRefs]: (input) => - observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { - "rpc.aggregate": "vcs", - }), + gitWorkflow + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.vcsListRefs]: (input) => gitWorkflow.listRefs(input), [WS_METHODS.vcsCreateWorktree]: (input) => - observeRpcEffect( - WS_METHODS.vcsCreateWorktree, - gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsRemoveWorktree]: (input) => - observeRpcEffect( - WS_METHODS.vcsRemoveWorktree, - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsCreateRef]: (input) => - observeRpcEffect( - WS_METHODS.vcsCreateRef, - gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => - observeRpcEffect( - WS_METHODS.vcsSwitchRef, - gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsInit]: (input) => - observeRpcEffect( - WS_METHODS.vcsInit, - vcsProvisioning - .initRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "vcs" }, - ), - [WS_METHODS.reviewGetDiffPreview]: (input) => - observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { - "rpc.aggregate": "review", - }), - [WS_METHODS.reviewGetDiffFileContents]: (input) => - observeRpcEffect( - WS_METHODS.reviewGetDiffFileContents, - review.getDiffFileContents(input), - { "rpc.aggregate": "review" }, - ), - [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), + vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), + [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), + [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), [WS_METHODS.terminalAttach]: (input) => - observeRpcStream( - WS_METHODS.terminalAttach, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - { "rpc.aggregate": "terminal" }, ), - [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), + [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), + [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), + [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), + [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), + [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.subscribeTerminalEvents]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalEvents, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - { "rpc.aggregate": "terminal" }, ), [WS_METHODS.subscribeTerminalMetadata]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalMetadata, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), ), - { "rpc.aggregate": "terminal" }, ), - [WS_METHODS.previewOpen]: (input) => - observeRpcEffect(WS_METHODS.previewOpen, previewManager.open(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewNavigate]: (input) => - observeRpcEffect(WS_METHODS.previewNavigate, previewManager.navigate(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewResize]: (input) => - observeRpcEffect(WS_METHODS.previewResize, previewManager.resize(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewAdjust]: (input) => - observeRpcEffect(WS_METHODS.previewAdjust, previewManager.adjust(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewRefresh]: (input) => - observeRpcEffect(WS_METHODS.previewRefresh, previewManager.refresh(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewClose]: (input) => - observeRpcEffect(WS_METHODS.previewClose, previewManager.close(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewList]: (input) => - observeRpcEffect(WS_METHODS.previewList, previewManager.list(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.previewClearProfile]: (input) => - observeRpcEffect( - WS_METHODS.previewClearProfile, - serverBrowser.clearProfile(input.profileId), - { "rpc.aggregate": "preview" }, - ), - [WS_METHODS.previewReportStatus]: (input) => - observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { - "rpc.aggregate": "preview", - }), - [WS_METHODS.subscribePreviewEvents]: (_input) => - observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { - "rpc.aggregate": "preview", - }), - [WS_METHODS.deviceConfigure]: (input) => - observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceTestHost]: (input) => - observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { - "rpc.aggregate": "device", - }), + [WS_METHODS.previewOpen]: (input) => previewManager.open(input), + [WS_METHODS.previewNavigate]: (input) => previewManager.navigate(input), + [WS_METHODS.previewResize]: (input) => previewManager.resize(input), + [WS_METHODS.previewAdjust]: (input) => previewManager.adjust(input), + [WS_METHODS.previewRefresh]: (input) => previewManager.refresh(input), + [WS_METHODS.previewClose]: (input) => previewManager.close(input), + [WS_METHODS.previewList]: (input) => previewManager.list(input), + [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), + [WS_METHODS.previewReportStatus]: (input) => previewManager.reportStatus(input), + [WS_METHODS.subscribePreviewEvents]: (_input) => previewManager.events, + [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), + [WS_METHODS.deviceTestHost]: (input) => deviceService.testHost(input), [WS_METHODS.deviceList]: (input) => - observeRpcEffect( - WS_METHODS.deviceList, - input.inspectOnly && !input.updateTool - ? deviceService.inspect - : authorizeEffect( - requiredScopeForDeviceList(input), - input.updateTool - ? deviceService.updateTool(input.updateTool) - : input.retryHostId - ? deviceService.retryHost(input.retryHostId) - : deviceService.list, - ), - { - "rpc.aggregate": "device", - }, - ), - [WS_METHODS.deviceOpen]: (input) => - observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceClose]: (input) => - observeRpcEffect(WS_METHODS.deviceClose, deviceService.close(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceShutdown]: (input) => - observeRpcEffect(WS_METHODS.deviceShutdown, deviceService.shutdown(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceDetail]: (input) => - observeRpcEffect(WS_METHODS.deviceDetail, deviceService.detail(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.deviceAction]: (input) => - observeRpcEffect(WS_METHODS.deviceAction, deviceService.action(input), { - "rpc.aggregate": "device", - }), - [WS_METHODS.subscribeDeviceState]: (_input) => - observeRpcStream( - WS_METHODS.subscribeDeviceState, - DeviceService.stateStream(deviceService), - { "rpc.aggregate": "device" }, - ), + input.inspectOnly && !input.updateTool + ? deviceService.inspect + : authorizeEffect( + requiredScopeForDeviceList(input), + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, + ), + [WS_METHODS.deviceOpen]: (input) => deviceService.open(input), + [WS_METHODS.deviceClose]: (input) => deviceService.close(input), + [WS_METHODS.deviceShutdown]: (input) => deviceService.shutdown(input), + [WS_METHODS.deviceDetail]: (input) => deviceService.detail(input), + [WS_METHODS.deviceAction]: (input) => deviceService.action(input), + [WS_METHODS.subscribeDeviceState]: (_input) => DeviceService.stateStream(deviceService), [WS_METHODS.subscribeDiscoveredLocalServers]: (input) => - observeRpcStream( - WS_METHODS.subscribeDiscoveredLocalServers, - Stream.callback((queue) => - Effect.gen(function* () { - const configuredUrls = input.configuredUrls ?? []; - yield* portDiscovery.retain; - const initial = yield* portDiscovery.scan(configuredUrls); - const initialScannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { - servers: initial, - scannedAt: initialScannedAt, - configuredUrlProbing: true, - }); - yield* portDiscovery.subscribe( - { configuredUrls, initialSnapshot: initial }, - (servers) => - Effect.gen(function* () { - const scannedAt = DateTime.formatIso(yield* DateTime.now); - yield* Queue.offer(queue, { - servers, - scannedAt, - configuredUrlProbing: true, - }); - }), - ); - }), - ), - { "rpc.aggregate": "preview" }, + Stream.callback((queue) => + Effect.gen(function* () { + const configuredUrls = input.configuredUrls ?? []; + yield* portDiscovery.retain; + const initial = yield* portDiscovery.scan(configuredUrls); + const initialScannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers: initial, + scannedAt: initialScannedAt, + configuredUrlProbing: true, + }); + yield* portDiscovery.subscribe( + { configuredUrls, initialSnapshot: initial }, + (servers) => + Effect.gen(function* () { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + yield* Queue.offer(queue, { + servers, + scannedAt, + configuredUrlProbing: true, + }); + }), + ); + }), ), [WS_METHODS.subscribeServerConfig]: (input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerConfig, + Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; const config = yield* loadServerConfig({ usageLimitsCommand }); @@ -3705,11 +3017,9 @@ const layerWsRpc = ( withLateEditorConfig(config, liveUpdates, externalLauncher), ); }), - { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeServerLifecycle]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerLifecycle, + Stream.unwrap( Effect.gen(function* () { const liveBuffer = yield* Queue.unbounded(); yield* Effect.forkScoped( @@ -3727,11 +3037,9 @@ const layerWsRpc = ( ); return Stream.concat(rpcInitialItems(snapshotEvents), liveEvents); }), - { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeAuthAccess]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeAuthAccess, + Stream.unwrap( Effect.gen(function* () { const initialSnapshot = yield* loadAuthAccessSnapshot(); const revisionRef = yield* Ref.make(1); @@ -3761,27 +3069,18 @@ const layerWsRpc = ( liveEvents, ); }), - { "rpc.aggregate": "auth" }, ), [WS_METHODS.subscribeBackgroundPolicy]: (_input) => - observeRpcStream( - WS_METHODS.subscribeBackgroundPolicy, - Stream.unwrap( - Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => - Stream.concat(Stream.make(latest), changes), - ), + Stream.unwrap( + Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), ), - { "rpc.aggregate": "server" }, ), [WS_METHODS.subscribeResourceTelemetry]: (_input) => - observeRpcStream( - WS_METHODS.subscribeResourceTelemetry, - Stream.unwrap( - Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => - Stream.concat(Stream.make(latest), changes), - ), + Stream.unwrap( + Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), ), - { "rpc.aggregate": "server" }, ), }); return handlers; @@ -3841,7 +3140,9 @@ export const layer = Layer.unwrap( const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; yield* RpcServer.make(ServerWsRpcGroup, WS_RPC_SERVER_OPTIONS).pipe( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), - Effect.provide(RpcAuthorization.layer(session.scopes)), + Effect.provide( + Layer.merge(RpcAuthorization.layer(session.scopes), rpcInstrumentationLayer), + ), Effect.forkScoped, ); // @effect-diagnostics-next-line returnEffectInGen:off diff --git a/docs/internals/effect-services.md b/docs/internals/effect-services.md index 49a7d2424cac..4da6357c0a91 100644 --- a/docs/internals/effect-services.md +++ b/docs/internals/effect-services.md @@ -24,15 +24,17 @@ message) are fine next to the service; the capability itself is the method. ```ts // ws.ts: a thin handler [WS_METHODS.projectsCreateNew]: (input) => - observeRpcEffect( - WS_METHODS.projectsCreateNew, - projectFolders.createNamedProject(input).pipe( - Effect.mapError((cause) => new ProjectCreateNewError({ cause })), - ), - { "rpc.aggregate": "orchestration" }, - ), + projectFolders + .createNamedProject(input) + .pipe(Effect.mapError((cause) => new ProjectCreateNewError({ cause }))), ``` +Handlers don't add their own spans or request metrics. Group middleware authorizes every call +([`RpcAuthorization.ts`](../../apps/server/src/auth/RpcAuthorization.ts)), and the server's group +also instruments it +([`RpcInstrumentation.ts`](../../apps/server/src/observability/RpcInstrumentation.ts)). A handler +with per-call context, such as a thread id, adds it with `Effect.annotateCurrentSpan`. + ## Shape of a service module One module per service, in this order: imports, errors and schemas, the `Context.Service` tag with diff --git a/docs/operations/observability.md b/docs/operations/observability.md index c97a187eb08a..c199f6aba731 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -635,8 +635,8 @@ wins for its signal. `otlp` is the default, and any other exporter name, such as Current high-value span and metric boundaries include: -- Effect RPC websocket request spans from `effect/rpc` -- RPC request metrics in `apps/server/src/observability/RpcInstrumentation.ts` +- WebSocket RPC request spans (`ws.rpc.`) and metrics in + `apps/server/src/observability/RpcInstrumentation.ts` - startup phases - orchestration command processing - provider session and turn operations From f8ed2a039b7deaa324beda29b44cc74c846311f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 18:06:57 -0700 Subject: [PATCH 32/59] chore(deps): upgrade @pierre/diffs to 1.5.2 and @pierre/trees to beta.6 (#16644) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 2 +- .../mobile/src/features/review/reviewModel.ts | 5 +- apps/web/package.json | 2 +- .../components/diffs/AnnotatableCodeView.tsx | 2 +- .../diffs/StyledDiffCodeView.test.tsx | 7 +- .../components/diffs/StyledDiffCodeView.tsx | 8 +- .../src/components/files/FilePreviewPanel.tsx | 286 +++++++++++------ .../files/ReadOnlySourcePreview.tsx | 2 +- .../files/fileContentRevision.test.ts | 25 +- .../components/files/fileContentRevision.ts | 18 -- .../files/fileEditorHighlight.test.ts | 299 ------------------ .../files/fileEditorLanguageReadiness.test.ts | 199 ------------ .../files/fileEditorVirtualization.test.ts | 56 ++-- .../files/projectFilesQueryState.ts | 12 + .../pullRequest/PullRequestCodeTab.tsx | 4 +- apps/web/src/lib/diffRendering.test.ts | 4 + apps/web/src/lib/diffRendering.ts | 15 +- apps/web/vite.config.ts | 2 +- patches/@pierre%2Fdiffs@1.3.0-beta.10.patch | 238 -------------- patches/@pierre%2Fdiffs@1.5.2.patch | 146 +++++++++ pnpm-lock.yaml | 118 +++++-- pnpm-workspace.yaml | 6 +- 22 files changed, 505 insertions(+), 951 deletions(-) delete mode 100644 apps/web/src/components/files/fileEditorHighlight.test.ts delete mode 100644 apps/web/src/components/files/fileEditorLanguageReadiness.test.ts delete mode 100644 patches/@pierre%2Fdiffs@1.3.0-beta.10.patch create mode 100644 patches/@pierre%2Fdiffs@1.5.2.patch diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 602ffb66bace..53b5476d3a2d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -131,7 +131,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@pierre/trees": "1.0.0-beta.4", + "@pierre/trees": "1.0.0-beta.6", "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", "babel-preset-expo": "~58.0.8", diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 86ded9a6264e..d2e6c1254617 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -2,7 +2,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; @@ -374,8 +373,8 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray total + hunk.additionLines, 0); const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; diff --git a/apps/web/package.json b/apps/web/package.json index 53c53c1c7cf9..d17795f15c5d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,7 +25,7 @@ "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", - "@pierre/trees": "1.0.0-beta.4", + "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index 52b538f92b0f..ab4f545ed07b 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -35,7 +35,7 @@ interface DiffCommentAnnotationGroup { } type DiffCommentLineAnnotation = DiffLineAnnotation; -export type AnnotatableCodeViewHandle = CodeViewHandle; +export type AnnotatableCodeViewHandle = CodeViewHandle; const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; function annotationSide(range: SelectedLineRange): AnnotationSide { diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index ee249888a415..3f01a62e549f 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -98,7 +98,7 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), - CodeView: (props: CodeViewProps) => { + CodeView: (props: CodeViewProps) => { return props.items?.map((item) => item.type === "file" ? : null, ); @@ -115,6 +115,7 @@ function FileOutput({ file }: { file: FileContents }) { testState.renderPools.push(pool); const renderer = new FileRenderer( { theme: "pierre-dark", preferredHighlighter: "shiki-wasm" }, + undefined, render, pool, ); @@ -301,8 +302,10 @@ describe("code-view worker lifecycle", () => { expect(pool.getStats().totalWorkers).toBe(2); expect(testState.terminations).toHaveLength(0); await act(async () => renderer!.update(renderViews(0))); + // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down. + const timedOut = expect(pending).rejects.toThrow("worker initialization timed out"); await vi.advanceTimersByTimeAsync(30_000); - await pending; + await timedOut; await Promise.all(testState.terminations); await act(async () => { for (const deliver of testState.heldResponses) deliver(); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index cdd9fe272225..e18d9ca4ba82 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -261,16 +261,16 @@ const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} `; export type StyledDiffCodeViewOptions = Omit< - NonNullable["options"]>, + NonNullable["options"]>, "unsafeCSS" | "itemMetrics" | "layout" >; type StyledDiffCodeViewProps = ( - | Omit, "options"> - | Omit, "options"> + | Omit, "options"> + | Omit, "options"> ) & { readonly options?: StyledDiffCodeViewOptions; - readonly viewerRef?: Ref>; + readonly viewerRef?: Ref>; /** * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has * to restyle chrome the viewer owns — such as replacing its per-file line counts. diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 5ae0753429fd..5ce7a4cc2b2a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -11,9 +11,23 @@ import { isWorkspaceImagePreviewPath, isWorkspaceVideoPreviewPath, } from "@t3tools/shared/filePreview"; -import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; -import { Editor } from "@pierre/diffs/editor"; -import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; +import { + DEFAULT_TOKENIZE_MAX_LENGTH, + VirtualizedFile, + getFiletypeFromFileName, + type File as FileInstance, + type FileContents, + type PostRenderPhase, + type SelectedLineRange, +} from "@pierre/diffs"; +import { + Editor, + type EditorChangeEvent, + type EditorFactory, + type EditorOptions, +} from "@pierre/diffs/edit"; +import type { WorkerPoolManager } from "@pierre/diffs/worker"; +import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; import { isAtomCommandInterrupted, @@ -80,7 +94,7 @@ import { import SourceFilePreview from "./ReadOnlySourcePreview"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; -import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; +import { projectFileCacheKey } from "./fileContentRevision"; import { filePreviewReadErrorMessage, isMarkdownPreviewFile, @@ -91,6 +105,7 @@ import { import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, + getProjectFileContents, setProjectFileQueryData, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -117,7 +132,12 @@ const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile"; const RENDER_TABLE_STORAGE_KEY = "t3code.renderTable"; -type FilePostRender = NonNullable["onPostRender"]>; +// Shared by the read-only and annotated surfaces, so it is generic over annotation metadata. +type FilePostRender = ( + fileContainer: HTMLElement, + instance: FileInstance, + phase: PostRenderPhase, +) => void; function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; @@ -547,6 +567,67 @@ function useFileLineReveal( ); } +const createFileEditor: EditorFactory = ( + editorType, + options, + editStateKey, +) => new Editor(editorType, options, editStateKey); + +function editableFileContents( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, + contents: string, +): FileContents { + return { + name: relativePath, + contents, + cacheKey: `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`, + }; +} + +function needsWorkerHighlight(workerPool: WorkerPoolManager | undefined, file: FileContents) { + if (workerPool?.isWorkingPool() !== true) return false; + if ((file.lang ?? getFiletypeFromFileName(file.name)) === "text") return false; + let lines = 1; + for ( + let index = file.contents.indexOf("\n"); + index !== -1; + index = file.contents.indexOf("\n", index + 1) + ) { + lines += 1; + } + return lines <= DEFAULT_TOKENIZE_MAX_LENGTH; +} + +/** + * Pierre highlights an active edit session on the main thread, so each version + * of the file becomes editable only once it has rendered the worker's + * highlight. A failed worker highlight falls back to main-thread highlighting. + */ +function useEditableAfterHighlight(file: FileContents) { + const workerPool = useWorkerPool(); + const [highlightedFile, setHighlightedFile] = useState(null); + const needsHighlight = useMemo(() => needsWorkerHighlight(workerPool, file), [file, workerPool]); + const ready = !needsHighlight || highlightedFile === file; + + useEffect(() => { + if (ready || workerPool === undefined) return; + workerPool.primeFileHighlightCache(file).catch(() => setHighlightedFile(file)); + }, [file, ready, workerPool]); + + const onPostRender = useCallback( + (renderedFile: FileContents | undefined, phase: PostRenderPhase) => { + if (ready || phase === "unmount" || renderedFile?.cacheKey !== file.cacheKey) return; + // The pool caches a result just before the instance renders it, so a + // render that sees the cache has painted highlighted rows. + if (workerPool?.getFileResultCache(file) !== undefined) setHighlightedFile(file); + }, + [file, ready, workerPool], + ); + return { ready, onPostRender }; +} + interface EditableFileSurfaceProps { environmentId: EnvironmentId; cwd: string; @@ -597,46 +678,65 @@ function EditableFileSurface({ relativePath, onPendingChange, }); - const editor = useMemo( - () => - new Editor({ - persistState: true, - persistStateStorage: "inMemory", - onChange: (file, nextLineAnnotations) => { - setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); - saveCoordinator.change(file.contents); - if (nextLineAnnotations) { - const remapped = remapFileCommentAnnotations( - nextLineAnnotations as FileCommentLineAnnotation[], - ); - setLineAnnotations(remapped); - for (const annotation of remapped) { - for (const entry of annotation.metadata.entries) { - if (entry.kind !== "comment") continue; - addReviewComment( - composerDraftTarget, - buildFileReviewComment({ - id: entry.id, - filePath: relativePath, - startLine: entry.startLine, - endLine: entry.endLine, - text: entry.text, - contents: file.contents, - }), - ); - } - } - } - }, - }), - [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], + // The editor owns the draft, so its own edits echoing back through the file + // query keep the file identity. Only a change from elsewhere replaces it. + const [externalFile, setExternalFile] = useState(() => + editableFileContents(environmentId, cwd, relativePath, contents), + ); + const [editedContents, setEditedContents] = useState(null); + if (contents !== (editedContents ?? externalFile.contents)) { + setExternalFile(editableFileContents(environmentId, cwd, relativePath, contents)); + setEditedContents(null); + } + const { ready: editable, onPostRender: onEditablePostRender } = + useEditableAfterHighlight(externalFile); + const editorRef = useRef | null>(null); + const editorOptions = useMemo>( + () => ({ + onAttach: (editor) => { + editorRef.current = editor; + }, + onComplete: () => { + editorRef.current = null; + }, + }), + [], ); - useEffect( - () => () => { - editor.cleanUp(); + // Mirrors the draft out to the save queue, the optimistic file query and the + // composer's review comments. + const handleEditChange = useCallback( + ({ + file, + lineAnnotations: nextLineAnnotations, + }: EditorChangeEvent<"file", FileCommentAnnotationGroup, undefined>) => { + // Adopting an external change reports it as an edit; it is already on disk. + if (file.contents === getProjectFileContents(environmentId, cwd, relativePath)) return; + setEditedContents(file.contents); + setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); + saveCoordinator.change(file.contents); + if (!nextLineAnnotations) return; + const remapped = remapFileCommentAnnotations(nextLineAnnotations); + // The editor hands back the array it was given until an edit moves an annotation. + setLineAnnotations((current) => (current === nextLineAnnotations ? current : remapped)); + for (const annotation of remapped) { + for (const entry of annotation.metadata.entries) { + if (entry.kind !== "comment") continue; + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text: entry.text, + contents: file.contents, + }), + ); + } + } }, - [editor], + [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], ); const removeAnnotationEntry = useCallback( @@ -695,47 +795,44 @@ function EditableFileSurface({ ], ); - const beginComment = useCallback( - (range: SelectedLineRange) => { - editor.setSelections([]); - editor.blur(); - const { startLine, endLine } = normalizeFileCommentRange(range); - const draftEntry: FileCommentAnnotationEntry = { - id: nextFileCommentId(), - kind: "draft", - startLine, - endLine, - text: "", - }; - setLineAnnotations((current) => { - const withoutDraft = current.flatMap((annotation) => { - const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft"); - return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; - }); - const existingIndex = withoutDraft.findIndex( - (annotation) => annotation.lineNumber === endLine, - ); - if (existingIndex < 0) { - return [ - ...withoutDraft, - { - lineNumber: endLine, - metadata: { entries: [draftEntry] }, - }, - ]; - } - return withoutDraft.map((annotation, index) => - index === existingIndex - ? { - ...annotation, - metadata: { entries: [...annotation.metadata.entries, draftEntry] }, - } - : annotation, - ); + const beginComment = useCallback((range: SelectedLineRange) => { + editorRef.current?.setSelections([]); + editorRef.current?.blur(); + const { startLine, endLine } = normalizeFileCommentRange(range); + const draftEntry: FileCommentAnnotationEntry = { + id: nextFileCommentId(), + kind: "draft", + startLine, + endLine, + text: "", + }; + setLineAnnotations((current) => { + const withoutDraft = current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft"); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; }); - }, - [editor], - ); + const existingIndex = withoutDraft.findIndex( + (annotation) => annotation.lineNumber === endLine, + ); + if (existingIndex < 0) { + return [ + ...withoutDraft, + { + lineNumber: endLine, + metadata: { entries: [draftEntry] }, + }, + ]; + } + return withoutDraft.map((annotation, index) => + index === existingIndex + ? { + ...annotation, + metadata: { entries: [...annotation.metadata.entries, draftEntry] }, + } + : annotation, + ); + }); + }, []); const hasOpenCommentForm = lineAnnotations.some((annotation) => annotation.metadata.entries.some((entry) => entry.kind === "draft"), ); @@ -744,11 +841,11 @@ function EditableFileSurface({ if (!root) return; return installFileEditorDismissal({ root, - editor, + editor: { setSelections: (selections) => editorRef.current?.setSelections(selections) }, isBlocked: () => hasOpenCommentForm, onDismiss: () => setSelectedRange(null), }); - }, [editor, hasOpenCommentForm, setSelectedRange]); + }, [hasOpenCommentForm, setSelectedRange]); const handleLineSelectionEnd = useCallback( (range: SelectedLineRange | null) => { setSelectedRange(range); @@ -762,6 +859,7 @@ function EditableFileSurface({ const handlePostRender = useCallback( (fileContainer, instance, phase) => { onPostRender(fileContainer, instance, phase); + onEditablePostRender(instance.file, phase); if (selectionFrameRef.current !== null) { cancelAnimationFrame(selectionFrameRef.current); @@ -775,11 +873,11 @@ function EditableFileSurface({ instance.setSelectedLines(selectedRange, { notify: false }); }); }, - [onPostRender, selectedRange], + [onEditablePostRender, onPostRender, selectedRange], ); return ( - +
    - file={{ - name: relativePath, - contents, - cacheKey: projectFileEditorCacheKey( - environmentId, - cwd, - relativePath, - contents, - editor.getFile(), - ), - }} + file={externalFile} + edit={editable} + editorOptions={editorOptions} + onEditChange={handleEditChange} options={{ disableFileHeader: true, enableGutterUtility: !hasOpenCommentForm, @@ -832,7 +923,6 @@ function EditableFileSurface({
    )} className="min-h-full" - contentEditable />
    diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index 8a59982c6f95..a4438ebf72a0 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -17,7 +17,7 @@ export default function ReadOnlySourcePreview(props: { readonly name: string; readonly text: string; readonly cacheKey?: string; - readonly onPostRender?: FileOptions["onPostRender"]; + readonly onPostRender?: FileOptions["onPostRender"]; }) { const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); diff --git a/apps/web/src/components/files/fileContentRevision.test.ts b/apps/web/src/components/files/fileContentRevision.test.ts index 4a8bb3d5522b..e54f28e0d235 100644 --- a/apps/web/src/components/files/fileContentRevision.test.ts +++ b/apps/web/src/components/files/fileContentRevision.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; +import { projectFileCacheKey } from "./fileContentRevision"; describe("file cache identity", () => { it("changes for same-length edits", () => { @@ -8,27 +8,4 @@ describe("file cache identity", () => { projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"), ); }); - - it("keeps editor identity stable for locally edited contents", () => { - const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "after", undefined); - - expect( - projectFileEditorCacheKey("local", "/repo", "file.json", "after edit", { - cacheKey, - contents: "after edit", - }), - ).toBe(cacheKey); - }); - - it("rotates editor identity for external contents and environments", () => { - const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "before", undefined); - const editorFile = { cacheKey, contents: "before" }; - - expect( - projectFileEditorCacheKey("local", "/repo", "file.json", "external edit", editorFile), - ).not.toBe(cacheKey); - expect(projectFileEditorCacheKey("remote", "/repo", "file.json", "before", undefined)).not.toBe( - cacheKey, - ); - }); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index b4e1698a34dc..f376f7fde9ec 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -10,21 +10,3 @@ function fileContentRevision(contents: string): string { export function projectFileCacheKey(cwd: string, relativePath: string, contents: string): string { return `${cwd}:${relativePath}:${fileContentRevision(contents)}`; } - -interface EditorFileIdentity { - readonly cacheKey?: string; - readonly contents: string; -} - -export function projectFileEditorCacheKey( - environmentId: string, - cwd: string, - relativePath: string, - contents: string, - editorFile: EditorFileIdentity | undefined, -): string { - if (editorFile?.contents === contents && editorFile.cacheKey) { - return editorFile.cacheKey; - } - return `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`; -} diff --git a/apps/web/src/components/files/fileEditorHighlight.test.ts b/apps/web/src/components/files/fileEditorHighlight.test.ts deleted file mode 100644 index 39624d953c09..000000000000 --- a/apps/web/src/components/files/fileEditorHighlight.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { - FileRenderer, - getSharedHighlighter, - type BaseCodeOptions, - type DiffsHighlighter, - type FileContents, - type HighlightedToken, - type RenderRange, -} from "@pierre/diffs"; -import { TextDocument } from "@pierre/diffs/editor"; -import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; -import * as NodeWorkerThreads from "node:worker_threads"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -type DocumentChange = NonNullable["applyEdits"]>>; -interface Tokenizer { - readonly themeType: "light" | "dark"; - tokenize(change: DocumentChange, range: RenderRange): Map; - cleanUp(): void; -} - -// This dependency-internal tokenizer is the one used by Editor.#rerender. -const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); -const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { - EditorTokenizer: new (options: { - codeOptions: BaseCodeOptions; - highlighter: DiffsHighlighter; - textDocument: TextDocument; - setStyle: (style: string) => void; - onDeferTokenize: (lines: Map, theme: "light" | "dark") => void; - }) => Tokenizer; -}; - -const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); -const source = Array.from( - { length: 7_000 }, - (_, index) => - `export const section${index} =

    Long wrapped source line ${index} for the file editor.

    ;`, -).join("\n"); -const options = { - theme: "pierre-dark", - themeType: "dark", - preferredHighlighter: "shiki-wasm", - useTokenTransformer: true, - overflow: "wrap", - disableFileHeader: true, -} as const; -const range: RenderRange = { - startingLine: 6_950, - totalLines: 150, - bufferBefore: 0, - bufferAfter: 0, -}; - -interface HeldResponse { - data: WorkerResponse; - deliver: () => void; -} -let responses: HeldResponse[]; -let responseWaiters: ((response: HeldResponse) => void)[]; -let terminationPromises: Promise[]; -let pool: WorkerPoolManager; -let renderer: FileRenderer; -let tokenizer: Tokenizer; -let file: FileContents; -let document: TextDocument; -const animationFrames = new Set>(); - -function nextResponse(): Promise { - const response = responses.shift(); - return response - ? Promise.resolve(response) - : new Promise((resolve) => responseWaiters.push(resolve)); -} - -class WorkerTransport { - private readonly worker = new NodeWorkerThreads.Worker( - `const { parentPort, workerData } = require("node:worker_threads"); - globalThis.self = { - addEventListener(type, listener) { - if (type === "message") parentPort.on("message", data => listener({ data })); - if (type === "error") process.on("uncaughtException", listener); - } - }; - globalThis.postMessage = data => parentPort.postMessage(data); - import(workerData.moduleUrl);`, - { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, - ); - - addEventListener( - type: "message" | "error", - listener: (event: { data: WorkerResponse } | Error) => void, - ) { - if (type === "error") { - this.worker.on("error", listener); - return; - } - this.worker.on("message", (data: WorkerResponse) => { - const response = { data, deliver: () => listener({ data }) }; - if (data.type !== "success" || data.requestType !== "file") { - response.deliver(); - return; - } - const waiter = responseWaiters.shift(); - if (waiter) waiter(response); - else responses.push(response); - }); - } - - postMessage(message: WorkerRequest) { - this.worker.postMessage(message, []); - } - - terminate() { - terminationPromises.push(this.worker.terminate()); - } -} - -function applyChange(change: DocumentChange) { - // Keep the installed editor's non-DOM order, including the existing contents patch. - renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType); - file.contents = document.getText(); - if (change.lineDelta !== 0) renderer.applyDocumentChange(document); -} - -function append(text: string) { - const position = document.positionAt(document.getText().length); - const change = document.applyEdits([ - { range: { start: position, end: position }, newText: text }, - ]); - expect(change).toBeDefined(); - applyChange(change!); -} - -function undo() { - const change = document.undo()?.[0]; - expect(change).toBeDefined(); - applyChange(change!); -} - -function renderContents() { - const result = renderer.renderFile(file, range); - expect(result?.totalLines).toBe(document.lineCount); - return renderer.renderFullHTML(result!); -} - -beforeEach(async () => { - responses = []; - responseWaiters = []; - terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - const frame = setImmediate(() => { - animationFrames.delete(frame); - callback(0); - }); - animationFrames.add(frame); - return frame; - }); - vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => { - animationFrames.delete(frame); - clearImmediate(frame); - }); - vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); - pool = new WorkerPoolManager( - // Adapt browser transport only; Pierre's real worker produces each response. - { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, - options, - ); - await pool.initialize(["tsx"]); - const highlighter = await getSharedHighlighter({ - themes: ["pierre-dark"], - langs: ["tsx"], - preferredHighlighter: "shiki-wasm", - }); - file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" }; - document = new TextDocument(file.name, source, "tsx"); - renderer = new FileRenderer(options, () => {}, pool); - tokenizer = new EditorTokenizer({ - codeOptions: options, - highlighter, - textDocument: document, - setStyle: () => {}, - onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme), - }); - renderContents(); -}); - -async function cleanUpFixture() { - tokenizer?.cleanUp(); - renderer?.cleanUp(); - pool?.terminate(); - await Promise.all(terminationPromises); - // Pool termination can queue a final broadcast after its workers have exited. - for (const frame of animationFrames) clearImmediate(frame); - animationFrames.clear(); - vi.unstubAllGlobals(); -} - -afterEach(cleanUpFixture); - -describe("editable file highlighting", () => { - it("cleans up an already terminated worker pool", async () => { - (await nextResponse()).deliver(); - expect(pool.getStats().totalWorkers).toBe(1); - pool.terminate(); - await Promise.all(terminationPromises); - expect(pool.getStats().totalWorkers).toBe(0); - - const animationFrame = globalThis.requestAnimationFrame; - const cancelFrame = globalThis.cancelAnimationFrame; - const window = globalThis.window; - try { - await cleanUpFixture(); - // Deliver the real Immediate queue after cleanup has removed the browser globals. - await new Promise((resolve) => setImmediate(resolve)); - } finally { - vi.stubGlobal("requestAnimationFrame", animationFrame); - vi.stubGlobal("cancelAnimationFrame", cancelFrame); - vi.stubGlobal("window", window); - } - }); - - it("still accepts an asynchronous highlight when the file has not changed", async () => { - expect(renderContents()).not.toContain('style="color:'); - (await nextResponse()).deliver(); - expect(renderContents()).toContain('style="color:'); - expect(pool.getFileResultCache(file)).toBeDefined(); - }); - - it.each([1, 60])( - "ignores a dispatched highlight after %i Enter edits and highlights the new version", - async (count) => { - const oldResponse = await nextResponse(); - for (let index = 0; index < count; index += 1) append("\n"); - append("export const EDITED_MARKER = 1;"); - oldResponse.deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - const currentResponse = await nextResponse(); - currentResponse.deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); - expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); - expect(document.lineCount).toBe(7_000 + count); - }, - ); - - it("does not replace a same-line edit with stale tokens", async () => { - const oldResponse = await nextResponse(); - append(" EDITED_MARKER"); - oldResponse.deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - expect(document.lineCount).toBe(7_000); - (await nextResponse()).deliver(); - expect(renderContents()).toContain("EDITED_MARKER"); - }); - - it("keeps undo edits after an older highlight arrives", async () => { - const oldResponse = await nextResponse(); - append("\nexport const RETAINED_MARKER = 1;"); - append("\nexport const UNDONE_MARKER = 2;"); - undo(); - oldResponse.deliver(); - const html = renderContents(); - expect(html).toContain("RETAINED_MARKER"); - expect(html).not.toContain("UNDONE_MARKER"); - (await nextResponse()).deliver(); - expect(renderContents()).toContain("RETAINED_MARKER"); - undo(); - expect(document.getText()).toBe(source); - expect(renderContents()).not.toContain("RETAINED_MARKER"); - const redone = document.redo()?.[0]; - expect(redone).toBeDefined(); - applyChange(redone!); - expect(renderContents()).toContain("RETAINED_MARKER"); - }); - - it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => { - (await nextResponse()).deliver(); - expect(pool.getFileResultCache(file)).toBeDefined(); - append("\nexport const EDITED_MARKER = 1;"); - expect(pool.getFileResultCache(file)).toBeUndefined(); - expect(renderContents()).toContain("EDITED_MARKER"); - const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); - expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); - }); - - it("reopens the edited file with the same cache key while an old response is pending", async () => { - const oldResponse = await nextResponse(); - append("\nexport const REOPENED_MARKER = 1;"); - renderer.cleanUp(); - renderer = new FileRenderer(options, () => {}, pool); - file = { ...file }; - expect(renderContents()).toContain("REOPENED_MARKER"); - oldResponse.deliver(); - (await nextResponse()).deliver(); - expect(renderContents()).toContain("REOPENED_MARKER"); - expect(renderContents()).toContain('style="color:'); - }); -}); diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts deleted file mode 100644 index d6f64a55c9b1..000000000000 --- a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - FileRenderer, - disposeHighlighter, - getSharedHighlighter, - type BaseCodeOptions, - type DiffsHighlighter, - type FileContents, - type HighlightedToken, -} from "@pierre/diffs"; -import { TextDocument } from "@pierre/diffs/editor"; -import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; -import * as NodeWorkerThreads from "node:worker_threads"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -type DocumentChange = NonNullable["applyEdits"]>>; -interface Tokenizer { - tokenize(change: DocumentChange): Map; - cleanUp(): void; -} - -const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); -const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { - EditorTokenizer: new (options: { - codeOptions: BaseCodeOptions; - highlighter: DiffsHighlighter; - textDocument: TextDocument; - setStyle: (style: string) => void; - onDeferTokenize: () => void; - }) => Tokenizer; -}; - -const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); -const options = { - theme: "pierre-dark", - themeType: "dark", - preferredHighlighter: "shiki-wasm", - useTokenTransformer: true, -} as const; -const source = "export const View = () =>
    Ready
    ;"; -let pool: WorkerPoolManager; -let renderer: FileRenderer; -let terminationPromises: Promise[]; -const pendingAnimationFrames = new Set>(); - -class WorkerTransport { - private readonly worker = new NodeWorkerThreads.Worker( - `const { parentPort, workerData } = require("node:worker_threads"); - globalThis.self = { - addEventListener(type, listener) { - if (type === "message") parentPort.on("message", data => listener({ data })); - if (type === "error") process.on("uncaughtException", listener); - } - }; - globalThis.postMessage = data => parentPort.postMessage(data); - import(workerData.moduleUrl);`, - { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, - ); - - addEventListener( - type: "message" | "error", - listener: (event: { data: WorkerResponse } | Error) => void, - ) { - if (type === "error") this.worker.on("error", listener); - else this.worker.on("message", (data: WorkerResponse) => listener({ data })); - } - - postMessage(message: WorkerRequest) { - this.worker.postMessage(message, []); - } - - terminate() { - terminationPromises.push(this.worker.terminate()); - } -} - -function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) { - const document = new TextDocument(file.name, file.contents, language); - const tokenizer = new EditorTokenizer({ - codeOptions: options, - highlighter, - textDocument: document, - setStyle: () => {}, - onDeferTokenize: () => {}, - }); - try { - const end = document.positionAt(file.contents.length); - const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]); - expect(change).toBeDefined(); - // This is the synchronous first edit, before the tokenizer's debounced prebuild. - const dirtyLines = tokenizer.tokenize(change!); - expect([...dirtyLines.keys()]).toEqual([0, 1]); - expect(document.getText()).toBe(`${file.contents}\n`); - } finally { - tokenizer.cleanUp(); - } -} - -beforeEach(async () => { - terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - const handle = setImmediate(() => { - pendingAnimationFrames.delete(handle); - callback(0); - }); - pendingAnimationFrames.add(handle); - return handle; - }); - vi.stubGlobal("cancelAnimationFrame", (handle: ReturnType) => { - pendingAnimationFrames.delete(handle); - clearImmediate(handle); - }); - vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); - await disposeHighlighter(); - pool = new WorkerPoolManager( - // Adapt transport only. The installed Pierre worker resolves and highlights the file. - { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, - options, - ); - await pool.initialize(); - renderer = new FileRenderer(options, undefined, pool); -}); - -afterEach(async () => { - renderer?.cleanUp(); - pool?.terminate(); - await Promise.all(terminationPromises); - await disposeHighlighter(); - // Drain the pool's final state broadcast before removing the animation frame stubs. - await new Promise((resolve) => setImmediate(resolve)); - for (const handle of pendingAnimationFrames) clearImmediate(handle); - pendingAnimationFrames.clear(); - vi.unstubAllGlobals(); -}); - -describe("editable file language readiness", () => { - it.each(["hydrate", "renderFile"] as const)( - "%s prepares the inferred language before the first edit of a worker-highlighted file", - async (method) => { - const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" }; - await pool.primeFileHighlightCache(file); - expect(pool.getFileResultCache(file)).toBeDefined(); - const mainHighlighter = await getSharedHighlighter({ - themes: ["pierre-dark"], - langs: ["text"], - }); - expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); - renderer[method](file); - // Read-only worker rendering must not load editor grammars on the main thread. - expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); - const highlighter = await renderer.initializeHighlighter(); - firstEnter(highlighter, file, "tsx"); - }, - ); - - it.each(["hydrate", "renderFile"] as const)( - "%s respects an explicit language when the filename suggests plain text", - async (method) => { - const file: FileContents = { - name: "source.txt", - lang: "tsx", - contents: source, - cacheKey: "explicit-tsx", - }; - await pool.primeFileHighlightCache(file); - renderer[method](file); - firstEnter(await renderer.initializeHighlighter(), file, "tsx"); - }, - ); - - it("loads a newly opened language after reusing a worker-backed renderer", async () => { - const previousFile: FileContents = { - name: "previous.ts", - contents: "export const value = 1;", - cacheKey: "previous-ts", - }; - await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] }); - renderer.renderFile(previousFile); - firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript"); - const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" }; - renderer.renderFile(nextFile); - firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx"); - }); - - it("prepares a hydrated non-worker file even when its theme was already loaded", async () => { - renderer.cleanUp(); - renderer = new FileRenderer(options); - const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" }; - renderer.hydrate(file); - firstEnter(await renderer.initializeHighlighter(), file, "tsx"); - }); - - it("keeps plain text editable without loading an unrelated grammar", async () => { - const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" }; - renderer.renderFile(file); - const highlighter = await renderer.initializeHighlighter(); - firstEnter(highlighter, file, "text"); - expect(highlighter.getLoadedLanguages()).not.toContain("tsx"); - }); -}); diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts index cf293dd47254..111ff291a7a8 100644 --- a/apps/web/src/components/files/fileEditorVirtualization.test.ts +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -4,7 +4,7 @@ import { Virtualizer, type FileContents, } from "@pierre/diffs"; -import { Editor, TextDocument } from "@pierre/diffs/editor"; +import { Editor, TextDocument } from "@pierre/diffs/edit"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const renderingManagerUrl = new URL( @@ -22,10 +22,21 @@ class MeasuredElement { children: MeasuredElement[] = []; dataset: Record = {}; nextElementSibling: MeasuredElement | null = null; + shadowRoot: MeasuredElement | null = null; width = 283; constructor(readonly height = 0) {} + attachShadow() { + this.shadowRoot ??= new MeasuredElement(); + return this.shadowRoot; + } + + appendChild(child: MeasuredElement) { + this.children.push(child); + return child; + } + getBoundingClientRect() { MeasuredElement.geometryReads += 1; return { top: 0, height: this.height, width: this.width }; @@ -108,8 +119,10 @@ class LayoutVirtualizer extends Virtualizer { class MeasuredFile extends VirtualizedFile { override top = 0; + // Rows are measured by hand here; the virtualizer only needs to reconcile them. + override onRender = () => false; - override attachEditor(editor: Parameters[0]) { + override __attachEditor(editor: Parameters[0]) { this.editor = editor; return () => { this.editor = undefined; @@ -117,7 +130,9 @@ class MeasuredFile extends VirtualizedFile { } async initialize(file: FileContents) { - this.prepareCodeViewItem(file, 0); + this.updateCodeViewLayout(file, 0); + // Document changes require the session an attached editor installs. + (this as unknown as { installEditSession(file: FileContents): void }).installEditSession(file); await this.fileRenderer.initializeHighlighter(); expect( this.fileRenderer.renderFile(file, { @@ -180,7 +195,7 @@ class MeasuredFile extends VirtualizedFile { } const instances: MeasuredFile[] = []; -const editors: Editor[] = []; +const editors: Editor<"file", undefined, undefined>[] = []; beforeAll(async () => { await getSharedHighlighter({ @@ -225,7 +240,7 @@ async function makeFixture( cacheKey: `wrapped:${overflow}`, lang: "text", }; - const document = new TextDocument(file.name, contents, "text"); + const document = new TextDocument<"file", undefined>(file.name, contents, "text"); const instance = new MeasuredFile( { overflow, @@ -274,7 +289,7 @@ describe("wrapped editor document changes", () => { const before = instance.getLinePosition(previousLastLine); expect(before).toEqual({ top: 120328, height: 60 }); const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; - expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 }); append(); @@ -282,7 +297,7 @@ describe("wrapped editor document changes", () => { expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); expect(instance.getVirtualizedHeight()).toBe(120376); - expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 }); }); it("invalidates changed and shifted rows after an insertion in the middle", async () => { @@ -373,7 +388,7 @@ describe("wrapped editor document changes", () => { const { instance, file, append } = await makeFixture(); append(); instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); - instance.prepareCodeViewItem(file, 0); + instance.updateCodeViewLayout(file, 0); expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); }); @@ -381,7 +396,7 @@ describe("wrapped editor document changes", () => { const { instance, file, append } = await makeFixture(); append(); instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); - instance.prepareCodeViewItem(file, 0); + instance.updateCodeViewLayout(file, 0); expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); }); }); @@ -476,11 +491,11 @@ describe("wrapped measurement widths", () => { "document", Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), ); - const first = new Editor(); + const first = new Editor("file"); editors.push(first); first.edit(instance); first.cleanUp(); - const second = new Editor(); + const second = new Editor("file"); editors.push(second); second.edit(instance); instance.resizeContent(482.25); @@ -531,12 +546,16 @@ class EditorElement extends MeasuredElement { style: Record = {}; parentElement: EditorElement | null = null; - appendChild(child: EditorElement) { + override appendChild(child: EditorElement) { child.parentElement = this; this.children.push(child); return child; } + append(child: EditorElement) { + this.appendChild(child); + } + prepend(child: EditorElement) { child.parentElement = this; this.children.unshift(child); @@ -630,14 +649,15 @@ async function makeEditorFixture(lineCount: number) { langs: ["text"], preferredHighlighter: "shiki-wasm", }); - const editor = new Editor(); + const editor = new Editor("file"); editors.push(editor); editor.edit(instance); - editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { - startingLine: 0, - totalLines: 1, - bufferBefore: 0, - bufferAfter: 0, + editor.__syncRenderView({ + highlighter, + fileContainer: measuredElement(host), + file, + lineAnnotations: undefined, + renderRange: { startingLine: 0, totalLines: 1, bufferBefore: 0, bufferAfter: 0 }, }); const append = (count: number) => { const lines = editor.getText().split("\n"); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 0b3ca2040e88..ad33483ecd53 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -89,6 +89,18 @@ export function getOptimisticProjectFileQueryData( return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null; } +/** The contents the Files panel shows, read outside React so it is current within a frame. */ +export function getProjectFileContents( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +): string | undefined { + const optimistic = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath); + if (optimistic) return optimistic.contents; + const result = appAtomRegistry.get(getProjectFileQueryAtom(environmentId, cwd, relativePath)); + return Option.getOrUndefined(AsyncResult.value(result))?.contents; +} + export function confirmProjectFileQueryData( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index ae8a2b2d344e..e1c6f1de0453 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -254,7 +254,9 @@ function PullRequestCodeTab({ readonly slices: ReadonlyArray; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map()); - const [viewer, setViewer] = useState | null>(null); + const [viewer, setViewer] = useState | null>( + null, + ); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 7b8857f32a12..20114f6ef1b5 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -402,6 +402,10 @@ describe("a file whose name a patch header cannot carry plainly", () => { expect(pathOf(quotedPatch("line\\nfile.txt"))).toBe("line\nfile.txt"); }); + it("keeps a backslash the name really has", () => { + expect(pathOf(quotedPatch("back\\\\slash.txt"))).toBe("back\\slash.txt"); + }); + it("reads the octal a host with core.quotePath on writes for a name outside ASCII", () => { expect(pathOf(quotedPatch("caf\\303\\251/r\\303\\251sum\\303\\251.ts"))).toBe("café/résumé.ts"); }); diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index c99349a7dafd..dcc6bb2b79d7 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,7 +1,6 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import { parseDiffFromFile } from "@pierre/diffs"; import type { FileDiffMetadata } from "@pierre/diffs/types"; -import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const DIFF_THEME_NAMES = { light: "pierre-light", @@ -201,17 +200,11 @@ export function getRenderablePatch( } /** - * What the patch called the file, as the file's own name. Git writes a name holding a tab, a - * newline, a quote or a backslash quoted and escaped, and the parser hands one of those back still - * escaped. A viewed mark, a review comment and a file's contents are all asked for by this path, - * and the host knows the file only under the name it really has. + * The file's own name. Git quotes and escapes a name holding a tab, a newline, a quote or a + * backslash, and the parser decodes it, so this is the name the host knows the file by. */ -function fileDiffPath(raw: string): string { - return unquoteGitPatchPath(raw); -} - export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - return fileDiffPath(fileDiff.name ?? fileDiff.prevName ?? ""); + return fileDiff.name ?? fileDiff.prevName ?? ""; } /** @@ -219,7 +212,7 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { * path, and the hosts that resolve a diff position against both sides need both names. */ export function resolveFileDiffPreviousPath(fileDiff: FileDiffMetadata): string { - return fileDiffPath(fileDiff.prevName ?? fileDiff.name ?? ""); + return fileDiff.prevName ?? fileDiff.name ?? ""; } /** diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index fd60c4f2a397..cf343e26a4fc 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -190,7 +190,7 @@ export default defineConfig(() => { "@clerk/clerk-js", "@clerk/react/internal", "@pierre/diffs", - "@pierre/diffs/editor", + "@pierre/diffs/edit", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js", "effect/Array", diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch deleted file mode 100644 index 5cf80feb3356..000000000000 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ /dev/null @@ -1,238 +0,0 @@ -diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts ---- a/dist/components/VirtualizedFile.d.ts -+++ b/dist/components/VirtualizedFile.d.ts -@@ -42,7 +42,7 @@ declare class VirtualizedFile extends File { - private computeApproximateSize; - setVisibility(visible: boolean): void; - rerender(): void; -- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; -+ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; - protected renderPreparedFile({ - fileContainer, - file, -diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js ---- a/dist/components/VirtualizedFile.js -+++ b/dist/components/VirtualizedFile.js -@@ -20,6 +20,7 @@ - cache = { - heights: /* @__PURE__ */ new Map(), - checkpoints: [], -+ codeWidth: void 0, - fileAnnotationHeight: 0 - }; - isVisible = false; -@@ -31,6 +32,8 @@ - super(options, workerManager, isContainerManaged); - this.virtualizer = virtualizer; - this.metrics = metrics; -+ const simpleVirtualizer = this.getSimpleVirtualizer(); -+ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); - } - setMetrics(metrics, force = false) { - if (!force && areObjectsEqual(this.metrics, metrics)) return; -@@ -70,10 +73,12 @@ - if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); - super.setThemeType(themeType); - } -- resetLayoutCache(recompute = false, resetRenderRange = true) { -+ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { - this.layoutDirty = true; -- this.cache.fileAnnotationHeight = 0; -- if (this.cache.heights.size > 0) this.cache.heights.clear(); -+ if (startLine === 0) this.cache.fileAnnotationHeight = 0; -+ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. -+ if (startLine === 0) this.cache.heights.clear(); -+ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); - if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; - if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; - if (recompute && this.isSimpleMode()) this.computeApproximateSize(); -@@ -91,6 +96,13 @@ - if (this.code == null) return hasHeightChange; - const content = this.code.children[1]; - if (!(content instanceof HTMLElement)) return hasHeightChange; -+ const codeWidth = this.code.getBoundingClientRect().width; -+ if (!(codeWidth > 0)) return hasHeightChange; -+ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { -+ this.resetLayoutCache(false, false); -+ hasHeightChange = true; -+ } -+ this.cache.codeWidth = codeWidth; - const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); - if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { - const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; -@@ -287,11 +299,11 @@ - this.forceRenderOverride = true; - this.virtualizer.instanceChanged(this, false); - } -- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { -+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { - const previousRenderRange = this.renderRange; - super.applyDocumentChange(textDocument, newLineAnnotations); - this.getSimpleVirtualizer()?.markDOMDirty(); -- this.resetLayoutCache(this.isSimpleMode(), false); -+ this.resetLayoutCache(this.isSimpleMode(), false, startLine); - if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) { - const windowSpecs = this.virtualizer.getWindowSpecs(); - const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs); -diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index ff78e2a..f9df318 100644 ---- a/dist/editor/editor.js -+++ b/dist/editor/editor.js -@@ -146,14 +146,11 @@ var Editor = class { - const file = fileInstance.__getCurrentFile?.(); - if (file !== void 0) requirePersistedCacheKey(file); - } -- const { useTokenTransformer, enableGutterUtility, enableLineSelection, lineHoverHighlight = "disabled", ...rest } = fileInstance.options; -- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled") { -+ const { useTokenTransformer, ...rest } = fileInstance.options; -+ if (useTokenTransformer !== true) { - fileInstance.setOptions({ - ...rest, -- useTokenTransformer: true, -- enableGutterUtility: false, -- enableLineSelection: false, -- lineHoverHighlight: "disabled" -+ useTokenTransformer: true - }); - fileInstance.rerender(); - } -@@ -908,6 +905,7 @@ var Editor = class { - return lineNumber - 1; - }; - this.#editorEventDisposes.push(addEventListener(gutterEl, "pointerdown", (e) => { -+ if (this.#fileInstance?.options.enableLineSelection === true) return; - const gutterRow = resolveGutterTarget(e.composedPath()[0]); - if (gutterRow?.dataset.lineType === "change-deletion") { - const code = gutterRow.closest("[data-code]"); -@@ -1522,6 +1520,12 @@ var Editor = class { - if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; - } - fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); -+ if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); -- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); -+ if (didLineCountChange) { -+ const previousLineCount = change.lineCount - change.lineDelta; -+ // A wider or narrower line-number gutter can rewrap unchanged rows. -+ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; -+ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); -+ } - if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); - if (newLineAnnotations !== void 0) { -@@ -1788,6 +1787,7 @@ var Editor = class { - } - } - #setSelectedLinesSafe(range, lineNumberOnly = false) { -+ if (this.#fileInstance?.options.controlledSelection === true) return; - try { - this.#fileInstance?.setSelectedLines(range, { - notify: false, -diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts ---- a/dist/managers/ResizeManager.d.ts -+++ b/dist/managers/ResizeManager.d.ts -@@ -5,6 +5,8 @@ - columnVariables?: ResizeManagerColumnVariableMode; - } - declare class ResizeManager { -+ /** Schedule owner measurement after an observed code or gutter size change. */ -+ onResize?: () => void; - private static resizeObserver; - private static managersByElement; - private static getResizeObserver; -diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js ---- a/dist/managers/ResizeManager.js -+++ b/dist/managers/ResizeManager.js -@@ -19,6 +19,7 @@ - for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); - } - observedNodes = /* @__PURE__ */ new Map(); -+ onResize; - setup(pre, { disableAnnotations, columnVariables = "apply" }) { - const annotationUpdates = /* @__PURE__ */ new Set(); - const applyColumnVariables = columnVariables === "apply"; -@@ -212,6 +213,7 @@ - this.applyAnnotationUpdates(annotationUpdates); - annotationUpdates.clear(); - this.applyColumnUpdates(codeUpdates); -+ if (codeUpdates.size > 0) this.onResize?.(); - codeUpdates.clear(); - } - applyAnnotationUpdates(annotationUpdates) { -diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js -index e9f62f5..af82a46 100644 ---- a/dist/react/utils/useFileInstance.js -+++ b/dist/react/utils/useFileInstance.js -@@ -91,10 +91,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu - }; - if (needsEditorOptions) merged = { - ...merged, -- useTokenTransformer: true, -- enableGutterUtility: false, -- enableLineSelection: false, -- lineHoverHighlight: "disabled" -+ useTokenTransformer: true - }; - return merged; - } -diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js ---- a/dist/renderers/FileRenderer.js -+++ b/dist/renderers/FileRenderer.js -@@ -107,10 +107,10 @@ - result: massiveFile ? void 0 : cache?.result, - renderRange: void 0 - }; -+ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - if (this.workerManager?.isWorkingPool() === true) { - if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); - } else if (this.highlighter == null) { -- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - this.initializeHighlighter(); - } - } -@@ -163,6 +163,8 @@ - if (this.renderCache == null) return; - const { file, result } = this.renderCache; - if (result == null) return; -+ this.workerManager?.cleanUpTasks(this); -+ if (file.cacheKey != null) this.workerManager?.evictFileFromCache(file.cacheKey); - const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; - for (const [line, tokens] of dirtyLines) { - if (lineCache != null && line < lineCache.lines.length) { -@@ -268,6 +270,7 @@ - const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); - const newContent = !areFilesEqual(file, this.renderCache.file); - const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); -+ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - if (this.workerManager?.isWorkingPool() === true) { - if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) { - this.renderCache.file = file; -@@ -278,7 +281,6 @@ - } - if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); - } else { -- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); - const hasThemes = this.highlighter != null && areThemesAttached(options.theme); - const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); - const canHighlight = !forcePlainText && hasLangs; -diff --git a/package.json b/package.json -index ff61c90..1e170e5 100644 ---- a/package.json -+++ b/package.json -@@ -55,6 +55,18 @@ - "./worker/worker-portable.js": { - "types": "./dist/worker/worker-portable.d.ts", - "import": "./dist/worker/worker-portable.js" -+ }, -+ "./types": { -+ "types": "./dist/types.d.ts", -+ "import": "./dist/types.js" -+ }, -+ "./utils/getFiletypeFromFileName": { -+ "types": "./dist/utils/getFiletypeFromFileName.d.ts", -+ "import": "./dist/utils/getFiletypeFromFileName.js" -+ }, -+ "./utils/parsePatchFiles": { -+ "types": "./dist/utils/parsePatchFiles.d.ts", -+ "import": "./dist/utils/parsePatchFiles.js" - } - }, - "publishConfig": { diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch new file mode 100644 index 000000000000..f1984ea41835 --- /dev/null +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -0,0 +1,146 @@ +diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +--- a/dist/components/VirtualizedFile.d.ts ++++ b/dist/components/VirtualizedFile.d.ts +@@ -49,7 +49,7 @@ + setVisibility(visible: boolean): void; + rerender(): void; + syncGhostTextRows(): void; +- applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; ++ applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; + render({ fileContainer, file, forceRender, lineAnnotations, ...props }: FileRenderProps): boolean; + protected finalizeRender(): void; + private updatePendingRender; +diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +--- a/dist/components/VirtualizedFile.js ++++ b/dist/components/VirtualizedFile.js +@@ -23,6 +23,7 @@ + heights: /* @__PURE__ */ new Map(), + checkpoints: [], + fileAnnotationHeight: 0, ++ codeWidth: void 0, + ghostTextRows: NO_GHOST_TEXT_ROWS + }; + pendingRender; +@@ -35,6 +36,8 @@ + super(options, workerManager, isContainerManaged); + this.virtualizer = virtualizer; + this.metrics = metrics; ++ const simpleVirtualizer = this.getSimpleVirtualizer(); ++ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); + } + setMetrics(metrics, force = false) { + const nextMetrics = computeVirtualFileMetrics(metrics); +@@ -86,10 +89,12 @@ + if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); + super.setThemeType(themeType); + } +- resetLayoutCache(recompute = false, resetRenderRange = true) { ++ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { + this.layoutDirty = true; +- this.cache.fileAnnotationHeight = 0; +- if (this.cache.heights.size > 0) this.cache.heights.clear(); ++ if (startLine === 0) this.cache.fileAnnotationHeight = 0; ++ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ if (startLine === 0) this.cache.heights.clear(); ++ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); + if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; + this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; + if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; +@@ -135,6 +140,13 @@ + if (this.code == null) return hasHeightChange; + const content = this.code.children[1]; + if (!(content instanceof HTMLElement)) return hasHeightChange; ++ const codeWidth = this.code.getBoundingClientRect().width; ++ if (!(codeWidth > 0)) return hasHeightChange; ++ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { ++ this.resetLayoutCache(false, false); ++ hasHeightChange = true; ++ } ++ this.cache.codeWidth = codeWidth; + if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { + const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; + if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { +@@ -358,12 +370,12 @@ + codeView.instanceChanged(this, true); + } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); + } +- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { + const { renderRange: previousRenderRange } = this; + this.getAdvancedVirtualizer()?.capturePendingLayoutAnchor(); + super.applyDocumentChange(textDocument, newLineAnnotations); + this.getSimpleVirtualizer()?.markDOMDirty(); +- this.resetLayoutCache(this.isSimpleMode(), false); ++ this.resetLayoutCache(this.isSimpleMode(), false, startLine); + const file = this.getRenderedFile(); + if (!this.isSimpleMode()) this.computeApproximateSize(true); + else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { +diff --git a/dist/editor/editor.js b/dist/editor/editor.js +--- a/dist/editor/editor.js ++++ b/dist/editor/editor.js +@@ -1899,7 +1899,11 @@ + changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, + documentLineCount: textDocument.lineCount + }); +- if (didLineCountChange) applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); ++ if (didLineCountChange) { ++ // A wider or narrower line-number gutter can rewrap unchanged rows. ++ const layoutStartLine = String(change.previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); ++ } + if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); + if (newLineAnnotations != null) { + this.#lineAnnotations = newLineAnnotations; +diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +--- a/dist/managers/ResizeManager.d.ts ++++ b/dist/managers/ResizeManager.d.ts +@@ -5,6 +5,8 @@ + columnVariables?: ResizeManagerColumnVariableMode; + } + declare class ResizeManager { ++ /** Schedule owner measurement after an observed code or gutter size change. */ ++ onResize?: () => void; + private static resizeObserver; + private static managersByElement; + private static getResizeObserver; +diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +--- a/dist/managers/ResizeManager.js ++++ b/dist/managers/ResizeManager.js +@@ -19,6 +19,7 @@ + for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); + } + observedNodes = /* @__PURE__ */ new Map(); ++ onResize; + setup(pre, { disableAnnotations, columnVariables = "apply" }) { + const annotationUpdates = /* @__PURE__ */ new Set(); + const applyColumnVariables = columnVariables === "apply"; +@@ -212,6 +213,7 @@ + this.applyAnnotationUpdates(annotationUpdates); + annotationUpdates.clear(); + this.applyColumnUpdates(codeUpdates); ++ if (codeUpdates.size > 0) this.onResize?.(); + codeUpdates.clear(); + } + applyAnnotationUpdates(annotationUpdates) { +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -58,6 +58,18 @@ + "./worker/worker-portable.js": { + "types": "./dist/worker/worker-portable.d.ts", + "import": "./dist/worker/worker-portable.js" ++ }, ++ "./types": { ++ "types": "./dist/types.d.ts", ++ "import": "./dist/types.js" ++ }, ++ "./utils/getFiletypeFromFileName": { ++ "types": "./dist/utils/getFiletypeFromFileName.d.ts", ++ "import": "./dist/utils/getFiletypeFromFileName.js" ++ }, ++ "./utils/parsePatchFiles": { ++ "types": "./dist/utils/parsePatchFiles.d.ts", ++ "import": "./dist/utils/parsePatchFiles.js" + } + }, + "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff5ba44f197d..17a19d1ffbeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ catalogs: specifier: 1.8.0 version: 1.8.0 '@pierre/diffs': - specifier: 1.3.0-beta.10 - version: 1.3.0-beta.10 + specifier: 1.5.2 + version: 1.5.2 jose: specifier: 6.2.2 version: 6.2.2 @@ -103,7 +103,7 @@ patchedDependencies: '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 - '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 + '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -277,7 +277,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -502,8 +502,8 @@ importers: specifier: 4.0.1 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@pierre/trees': - specifier: 1.0.0-beta.4 - version: 1.0.0-beta.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.0.0-beta.6 + version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) '@types/react': specifier: ~19.3.0 version: 19.3.0 @@ -666,10 +666,10 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': - specifier: 1.0.0-beta.4 - version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 1.0.0-beta.6 + version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -4444,18 +4444,27 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@pierre/diffs@1.3.0-beta.10': - resolution: {integrity: sha512-efyFM9GRfI6WkmHJP0CnZBopuM8yCwGqIKbZHoe1D5PV15VDkr7Vpi8EZt40AYrN1km//utQtYhHDSwt2KwjSg==} + '@pierre/diffs@1.5.2': + resolution: {integrity: sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true '@pierre/theme@1.1.0': resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} - '@pierre/theming@0.0.2': - resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} + '@pierre/theme@2.0.0': + resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==} + engines: {vscode: ^1.0.0} + + '@pierre/theming@1.0.0': + resolution: {integrity: sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g==} peerDependencies: '@pierre/theme': ^1.1.0 '@shikijs/themes': ^3.0.0 || ^4.0.0 @@ -4474,8 +4483,28 @@ packages: shiki: optional: true - '@pierre/trees@1.0.0-beta.4': - resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==} + '@pierre/theming@1.0.1': + resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==} + peerDependencies: + '@pierre/theme': ^1.1.0 || ^2.0.0 + '@shikijs/themes': ^3.0.0 || ^4.0.0 + react: ^18.3.1 || ^19.0.0 + react-dom: ^18.3.1 || ^19.0.0 + shiki: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@pierre/theme': + optional: true + '@shikijs/themes': + optional: true + react: + optional: true + react-dom: + optional: true + shiki: + optional: true + + '@pierre/trees@1.0.0-beta.6': + resolution: {integrity: sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -14869,65 +14898,96 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@pierre/theme': 1.1.0 - '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) + '@pierre/theme': 2.0.0 + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 + shiki: 4.2.0 + optionalDependencies: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - shiki: 4.2.0 transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@pierre/theme': 1.1.0 - '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@pierre/theme': 2.0.0 + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 + shiki: 4.2.0 + optionalDependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - shiki: 4.2.0 transitivePeerDependencies: - '@shikijs/themes' - '@pierre/theme@1.1.0': {} + '@pierre/theme@1.1.0': + optional: true + + '@pierre/theme@2.0.0': {} - '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': + '@pierre/theming@1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 1.1.0 + '@shikijs/themes': 4.3.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + shiki: 4.2.0 + + '@pierre/theming@1.0.0(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': + optionalDependencies: + '@pierre/theme': 2.0.0 '@shikijs/themes': 4.2.0 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) shiki: 4.2.0 - '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': optionalDependencies: - '@pierre/theme': 1.1.0 + '@pierre/theme': 2.0.0 + '@shikijs/themes': 4.2.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + shiki: 4.2.0 + + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + optionalDependencies: + '@pierre/theme': 2.0.0 '@shikijs/themes': 4.3.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) shiki: 4.2.0 - '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/trees@1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': dependencies: + '@pierre/theming': 1.0.0(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) preact: 11.0.0-beta.0 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@pierre/theme' + - '@shikijs/themes' + - shiki - '@pierre/trees@1.0.0-beta.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/trees@1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': dependencies: + '@pierre/theming': 1.0.0(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) preact: 11.0.0-beta.0 preact-render-to-string: 6.6.5(preact@11.0.0-beta.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - '@pierre/theme' + - '@shikijs/themes' + - shiki '@polka/url@1.0.0-next.29': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e88eb115e7c..92373445f5ed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,7 @@ catalog: "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.3.0-beta.10 + "@pierre/diffs": 1.5.2 "@tailwindcss/node": 4.3.3 "@tailwindcss/oxide": 4.3.3 "@tailwindcss/vite": 4.3.3 @@ -270,7 +270,9 @@ patchedDependencies: "@opencode/client@2.0.23": patches/@opencode__client@2.0.23.patch "@opencode/protocol@2.0.23": patches/@opencode__protocol@2.0.23.patch "@opencode/schema@2.0.23": patches/@opencode__schema@2.0.23.patch - "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch + # Exports the dependency-light subpaths mobile and the web diff parser import, and keeps + # measured wrapped-row heights above an edit (fileEditorVirtualization.test.ts). + "@pierre/diffs@1.5.2": patches/@pierre%2Fdiffs@1.5.2.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch From c4481b9b07d9108d3aa93494a0ec5dd28ed8fd80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 18:10:51 -0700 Subject: [PATCH 33/59] fix(relay): a host restarting onto a deleted tunnel gets a new one (#16649) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ManagedEndpointProvider.test.ts | 29 +++++++++++++++++-- .../environments/ManagedEndpointProvider.ts | 26 +++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index b48a01cc6d03..9896fc324b36 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -1118,7 +1118,7 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); - it.effect("does no Cloudflare work when the registered origin is unchanged", () => { + it.effect("only confirms the tunnel exists when the registered origin is unchanged", () => { const tunnelCalls: TunnelCall[] = []; const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); @@ -1137,7 +1137,31 @@ describe("ManagedEndpointProvider", () => { endpoint: provisioned.endpoint, }), ).toBe("ready"); - expect(tunnelCalls).toEqual([]); + expect(tunnelCalls).toEqual([{ operation: "get", input: "tunnel-id" }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks for recovery when the recorded tunnel was deleted", () => { + const tunnelCalls: TunnelCall[] = []; + const layer = layerProvider(makePersistentTunnelClient(tunnelCalls)); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const; + const provisioned = yield* provider.provision({ ...key, origin }); + // A shutdown release deletes the tunnel but keeps the recorded id; the + // host was killed before it dropped its stored config. + expect(yield* provider.release(key)).toBe(true); + + expect( + yield* provider.reconcileOrigin({ + ...key, + tunnelId: provisioned.runtime.tunnelId!, + origin, + endpoint: provisioned.endpoint, + }), + ).toBe("recovery_required"); }).pipe(Effect.provide(layer)); }); @@ -1163,6 +1187,7 @@ describe("ManagedEndpointProvider", () => { }), ).toBe("ready"); expect(tunnelCalls).toEqual([ + { operation: "get", input: "tunnel-id" }, { operation: "putConfiguration", input: { diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index aba8b426ab5f..eb6b7b7b73a9 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -55,6 +55,7 @@ const ManagedEndpointProvisioningStage = Schema.Literals([ "mark-allocation-ready", "load-allocation", "verify-endpoint", + "verify-tunnel", "sync-origin", ]); @@ -208,6 +209,7 @@ export interface ManagedEndpointTunnel { readonly status?: string | null; readonly createdAt?: string | null; readonly connsInactiveAt?: string | null; + readonly deletedAt?: string | null; } export interface ManagedEndpointTunnelListRequest { @@ -581,6 +583,30 @@ export const make = Effect.gen(function* () { hostname: allocation.hostname, }); } + // A release keeps the recorded tunnel id, so the record alone cannot + // tell a live tunnel from one deleted by a shutdown whose host was + // killed before it dropped its config. Ask Cloudflare, or the host + // starts a connector that can never connect. + const recorded = yield* tunnels.get(input.tunnelId).pipe( + Effect.asSome, + Effect.catchTags({ + ManagedEndpointTunnelClientError: (cause) => + isManagedEndpointNotFound(cause.cause) + ? Effect.succeedNone + : Effect.fail( + new ManagedEndpointProvisioningFailed({ + userId: input.userId, + environmentId: input.environmentId, + stage: "verify-tunnel", + tunnelId: input.tunnelId, + cause, + }), + ), + }), + ); + if (Option.isNone(recorded) || recorded.value.deletedAt) { + return "recovery_required"; + } if ( allocation.origin?.localHttpHost === input.origin.localHttpHost && allocation.origin.localHttpPort === input.origin.localHttpPort From 740bda40c92e1ded65dd2b65ef7c583357b1d258 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 6 Oct 2026 18:11:46 -0700 Subject: [PATCH 34/59] fix(server): recover a deleted tunnel when Cloudflare says "Tunnel not found" (#16648) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/cloud/ManagedEndpointRuntime.test.ts | 91 +++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 41 ++++++++- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 42c5d9b20e1d..4b06ae2124ff 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -185,11 +185,27 @@ describe("CloudManagedEndpointRuntime", () => { '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="Unauthorized: Invalid tunnel secret" connIndex=0', ), ).toBe(true); + // Seen in production on 2026-10-06 after the relay deleted an idle tunnel. + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-10-06T12:00:00Z ERR Register tunnel error from server side error="Unauthorized: Tunnel not found" connIndex=0 event=0 ip=198.41.200.23', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-10-06T12:00:00Z ERR Register tunnel error from server side error="Tunnel not found" connIndex=0', + ), + ).toBe(true); expect( ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="connection timed out" connIndex=0', ), ).toBe(false); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Failed to serve tunnel connection error="Unauthorized: Tunnel not found" connIndex=0', + ), + ).toBe(false); }); it.effect("keeps recovery requests sent before the server starts consuming them", () => @@ -318,6 +334,81 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("requests recovery while the connector never registers a connection", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 800, + onKill: () => {}, + output: Stream.fromQueue(output), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const requests = yield* Queue.unbounded(); + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((config) => Queue.offer(requests, config)), + Effect.forkChild, + ); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "silently-deleted", + }; + yield* runtime.applyConfig(config); + + yield* TestClock.adjust(Duration.minutes(2)); + expect(yield* Queue.size(requests)).toBe(0); + yield* TestClock.adjust(Duration.minutes(1)); + expect(yield* Queue.take(requests)).toEqual(config); + // Still unconnected, so it asks again. + yield* TestClock.adjust(Duration.minutes(3)); + expect(yield* Queue.take(requests)).toEqual(config); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not request recovery once the connector has connected", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 801, + onKill: () => {}, + output: Stream.fromQueue(output), + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const requests = yield* Queue.unbounded(); + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((config) => Queue.offer(requests, config)), + Effect.forkChild, + ); + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + yield* Queue.offer( + output, + new TextEncoder().encode( + "2026-10-06T00:00:00Z INF Registered tunnel connection connIndex=0\n", + ), + ); + yield* Stream.runHead(runtime.tunnelConnected); + + yield* TestClock.adjust(Duration.minutes(10)); + expect(yield* Queue.size(requests)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6b54b7ee3bdf..6c601fad80f3 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -59,6 +59,7 @@ interface ActiveConnector { readonly configKey: string; readonly config: RelayManagedEndpointRuntimeConfig; readonly startedAtMillis: number; + readonly connected: Ref.Ref; } // A connector that exits before running this long is treated as part of a @@ -71,6 +72,11 @@ const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; // Newly created tunnels can fail authorization briefly while Cloudflare propagates their token. const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; +// A connector that never registers a connection may hold a token for a tunnel +// that no longer exists, rejected in wording the output check does not know. +// Ask for recovery after this long, and again at this interval while it stays +// unconnected; the relay hands back the same tunnel when it is still live. +const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { @@ -84,14 +90,16 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" /** * Cloudflare's edge rejects a connector whose tunnel was deleted or whose - * token no longer matches. Current edge output is - * `error="Failed to get tunnel"` with no prefix; older edges prefixed the - * same messages with `Unauthorized:`. Match both so recovery fires on either. + * token no longer matches. The edge words this differently over time + * (`Failed to get tunnel`, `Tunnel not found`, ...), sometimes prefixed with + * `Unauthorized:`. Treat any `Unauthorized:` registration error as a rejection, + * plus the unprefixed messages seen so far. Transient rejections while a new + * tunnel's token propagates are absorbed by requiring several in a row. */ export function isRejectedRelayClientTunnelOutput(line: string): boolean { return ( /\bRegister tunnel error from server side\b/iu.test(line) && - /error="(?:Unauthorized:\s*)?(?:Failed to get tunnel|Record for tunnel not found|Invalid tunnel secret)"/iu.test( + /error="(?:Unauthorized:[^"]*|Failed to get tunnel|Tunnel not found|Record for tunnel not found|Invalid tunnel secret)"/iu.test( line, ) ); @@ -220,6 +228,24 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); + // Requests recovery while the connector has not registered a connection, + // once per timeout, until it connects or is replaced. + const watchConnectorRegistration = (connector: ActiveConnector) => + Effect.gen(function* () { + yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT); + if (yield* Ref.get(connector.connected)) return true; + yield* Effect.logWarning( + "Relay client has not registered a tunnel connection; requesting recovery", + { + pid: Number(connector.child.pid), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }, + ); + yield* Queue.offer(recoveryRequests, connector.config); + return false; + }).pipe(Effect.repeat({ until: (connected) => connected })); + const observeConnectorOutput = (connector: ActiveConnector) => { let rejectedRegistrations = 0; @@ -239,7 +265,10 @@ export const make = Effect.gen(function* () { switch (classifyRelayClientOutput(line)) { case "connected": rejectedRegistrations = 0; - return Effect.logInfo("Relay client tunnel connection registered", attributes).pipe( + return Ref.set(connector.connected, true).pipe( + Effect.andThen( + Effect.logInfo("Relay client tunnel connection registered", attributes), + ), Effect.andThen(Queue.offer(tunnelConnections, undefined)), Effect.asVoid, ); @@ -370,10 +399,12 @@ export const make = Effect.gen(function* () { configKey: nextConfigKey, config, startedAtMillis: yield* Clock.currentTimeMillis, + connected: yield* Ref.make(false), } satisfies ActiveConnector; yield* Ref.set(activeRef, connector); yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope); yield* Effect.forkIn(superviseConnector(connector), connectorScope); + yield* Effect.forkIn(watchConnectorRegistration(connector), connectorScope); return { status: "running", providerKind: "cloudflare_tunnel", From 2991331515ed5b7e4df0751e5119fa04954e803a Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:25:49 -0400 Subject: [PATCH 35/59] fix(web): iPhone Duo fold controls follow the phone's orientation (#16630) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/device/DeviceDuoControls.tsx | 112 ++++++++++++------ .../src/components/device/DeviceDuoGlyph.tsx | 30 +++-- .../src/device/duoControl.test.ts | 51 +++++++- .../client-runtime/src/device/duoControl.ts | 29 +++-- 4 files changed, 163 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/device/DeviceDuoControls.tsx b/apps/web/src/components/device/DeviceDuoControls.tsx index 1759ab07123b..57922a0243d3 100644 --- a/apps/web/src/components/device/DeviceDuoControls.tsx +++ b/apps/web/src/components/device/DeviceDuoControls.tsx @@ -1,5 +1,5 @@ import { - DUO_POSES, + duoFoldState, type DuoCommand, type DuoControlState, } from "@t3tools/client-runtime/device/duo-control"; @@ -8,51 +8,87 @@ import { DeviceDuoGlyph } from "./DeviceDuoGlyph"; import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -/** Physical presets live beside the device. Pinching supplies continuous hinge control. */ +const FOLDS = [ + { id: "closed", angle: 0 }, + { id: "half", angle: 90 }, + { id: "open", angle: 180 }, +] as const; +const STANDS = [ + { id: "laptop", label: "Laptop stand" }, + { id: "tent", label: "Tent stand" }, +] as const; + +/** + * Fold shapes move only the hinge, so the device opens around whichever edge it + * currently rests on: a vertical phone opens as a book into a landscape tablet, + * a horizontal one as a laptop into a portrait tablet. Stands are native presets + * that also place the device. Pinching supplies continuous hinge control. + */ export function DeviceDuoControls(props: { screen: DeviceScreenSize; state: DuoControlState; enabled: boolean; onCommand: (command: DuoCommand) => void; }) { - const angle = props.screen.hingeAngle; - const fold = angle == null ? null : angle === 0 ? "closed" : angle === 180 ? "open" : "book"; - const selected = (id: (typeof DUO_POSES)[number]["id"]) => - id === "laptop" || id === "tent" ? props.screen.hingePose === id : fold === id; + const { screen } = props; + const { fold, stand, phoneVertical } = duoFoldState(screen); + const foldLabels = { + closed: "Closed", + half: phoneVertical ? "Book" : "Laptop", + open: "Open", + }; + const button = ( + key: string, + label: string, + pressed: boolean, + onClick: () => void, + glyph: React.ReactNode, + ) => ( + + + } + > + {glyph} + + {label} + + ); + const group = + "pointer-events-auto flex shrink-0 flex-col items-center gap-1 rounded-full border border-border/50 bg-background/80 p-1 shadow-sm"; return (
    - {([DUO_POSES.slice(0, 3), DUO_POSES.slice(3)] as const).map((poses, index) => ( -
    - {poses.map((pose) => ( - - props.onCommand({ control: "pose", value: pose.id })} - /> - } - > - - - - {pose.label} - {pose.id === "book" ? " / bookshelf" : ""} - - - ))} -
    - ))} +
    + {FOLDS.map(({ id, angle: value }) => + button( + id, + foldLabels[id], + !stand && fold === id, + () => props.onCommand({ control: "angle", value }), + , + ), + )} +
    +
    + {STANDS.map(({ id, label }) => + button( + id, + label, + screen.hingePose === id, + () => props.onCommand({ control: "pose", value: id }), + , + ), + )} +
    {props.state.error ? ( {stance ? : null} {pose === "closed" ? ( <> - - - + + + ) : null} {pose === "book" ? ( - + ) : null} {pose === "open" ? ( <> - - + + ) : null} diff --git a/packages/client-runtime/src/device/duoControl.test.ts b/packages/client-runtime/src/device/duoControl.test.ts index 019c383a56dd..f325d3a25de7 100644 --- a/packages/client-runtime/src/device/duoControl.test.ts +++ b/packages/client-runtime/src/device/duoControl.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vite-plus/test"; -import { createDuoControl, createDuoPinch, type DuoCommand } from "./duoControl.ts"; +import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts"; afterEach(() => vi.useRealTimers()); it("keeps a failed send visible, including a disconnect while draining queued motion", () => { @@ -107,3 +107,52 @@ it("pinches only a hit device, accumulates independently of native readback, cla expect(change).toHaveBeenCalledTimes(count); expect(pinch.active).toBe(false); }); + +// Screen configs as an iPhone Duo simulator reports them for each way of holding the device. +it.each([ + [ + "vertical phone, closed", + { screenId: 1, orientation: "portrait", hingeAngle: 0 }, + "closed", + true, + ], + [ + "horizontal phone, closed", + { screenId: 1, orientation: "landscape_right", hingeAngle: 0 }, + "closed", + false, + ], + [ + "vertical phone opened as a book", + { screenId: 3, orientation: "landscape_left", hingeAngle: 180 }, + "open", + true, + ], + [ + "horizontal phone opened as a laptop", + { screenId: 3, orientation: "portrait_upside_down", hingeAngle: 180 }, + "open", + false, + ], + ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true], +] as const)("reads a %s", (_name, screen, fold, phoneVertical) => { + expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical }); +}); + +it("marks native stands so the fold group does not also claim them", () => { + expect( + duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }), + ).toEqual({ fold: "half", stand: true, phoneVertical: false }); +}); + +it("falls back like the 3D view when hinge fields are missing", () => { + expect(duoFoldState({ screenId: 1, orientation: "portrait" })).toMatchObject({ + fold: "closed", + phoneVertical: true, + }); + // Without a display ID the 3D view draws the open inner panel, so the controls do too. + expect(duoFoldState({ orientation: "landscape_left" })).toMatchObject({ + fold: "open", + phoneVertical: true, + }); +}); diff --git a/packages/client-runtime/src/device/duoControl.ts b/packages/client-runtime/src/device/duoControl.ts index b9f61ec997d9..e5be353ca625 100644 --- a/packages/client-runtime/src/device/duoControl.ts +++ b/packages/client-runtime/src/device/duoControl.ts @@ -1,12 +1,8 @@ // @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout. -export const DUO_POSES = [ - { id: "closed", label: "Closed", angle: 0 }, - { id: "book", label: "Book", angle: 90 }, - { id: "open", label: "Open", angle: 180 }, - { id: "laptop", label: "Laptop", angle: 90 }, - { id: "tent", label: "Tent", angle: 80 }, -] as const; -export type DuoPose = (typeof DUO_POSES)[number]["id"]; +import type { DeviceScreenSize } from "./stream.ts"; + +/** Native hinge presets. Each one also sets the device's physical orientation. */ +export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent"; export type DuoOrientation = | "portrait" | "landscape_left" @@ -24,6 +20,23 @@ export type DuoControlState = { error: string | null; }; +/** + * The fold the device is in and the way it is held. Missing hinge fields fall back the same way + * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a + * quarter turn from the cover, so its landscape orientation means a vertical phone. + */ +export function duoFoldState( + screen: Pick, +) { + const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180); + const landscape = screen.orientation.startsWith("landscape"); + return { + fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half", + stand: screen.hingePose === "laptop" || screen.hingePose === "tent", + phoneVertical: screen.screenId === 1 ? !landscape : landscape, + } as const; +} + /** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */ export function createDuoControl(options: { send: (request: { requestId: number; command: DuoCommand }) => boolean; From 847c53df0126a225fb1a64d4bda9acfffa2b68ed Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 22:32:14 -0300 Subject: [PATCH 36/59] fix(web): keep workspace options when expanding lineage (#16635) --- .../src/components/chat/ThreadDetailsCard.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index dfee1453eda9..74cbf60cc9ec 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -53,6 +53,7 @@ export function ThreadDetailsCard({ const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, + fullContentHeight: 0, }); const contentHeights = measurements.key === measurementKey ? measurements.heights : { full: 0, compact: 0 }; @@ -64,8 +65,8 @@ export function ThreadDetailsCard({ ? preferredPlacement.x + preferredPlacement.width : undefined; const cardBottom = - preferredPlacement && contentHeights.full > 0 - ? preferredPlacement.y + Math.min(contentHeights.full, preferredPlacement.height) + preferredPlacement && measurements.key === measurementKey && measurements.fullContentHeight > 0 + ? preferredPlacement.y + Math.min(measurements.fullContentHeight, preferredPlacement.height) : undefined; useLayoutEffect(() => { reportDetailsCard?.( @@ -88,11 +89,27 @@ export function ThreadDetailsCard({ const measure = () => { const frame = element.closest("[data-thread-details-card]"); const next = element.offsetHeight + (frame ? frame.offsetHeight - frame.clientHeight : 0); + // Lineage scrolls as it expands. Counting it toward density would hide + // the section and workspace controls when the user asks to see more rows. + const lineage = element.querySelector("[data-thread-relationships-panel]"); + const fittingHeight = next - (lineage?.offsetHeight ?? 0); setMeasurements((current) => { const heights = current.key === measurementKey ? current.heights : { full: 0, compact: 0 }; - return current.key === measurementKey && heights[density] === next + const fullContentHeight = + density === "full" + ? next + : current.key === measurementKey + ? current.fullContentHeight + : 0; + return current.key === measurementKey && + heights[density] === fittingHeight && + current.fullContentHeight === fullContentHeight ? current - : { key: measurementKey, heights: { ...heights, [density]: next } }; + : { + key: measurementKey, + heights: { ...heights, [density]: fittingHeight }, + fullContentHeight, + }; }); }; measure(); From 5886bd8f19c8d4b01d7a0b36ad62a9d35f02552d Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 6 Oct 2026 22:32:40 -0300 Subject: [PATCH 37/59] fix(web): preserve bare anchor placeholders in markdown (#16637) --- apps/web/src/components/ChatMarkdown.test.tsx | 78 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 50 ++++++++++++ 2 files changed, 128 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 5585dc3ddf40..7f655acb1cc3 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { EnvironmentId } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -73,6 +75,82 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown bare anchor placeholders", () => { + it.each(["", "", "", "", "", ""])( + "preserves unmatched %s without linking later blocks", + (token) => { + const text = `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`; + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + "text/html", + ); + + expect(document.querySelector("strong")?.textContent).toBe(`"From ${token}"`); + expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "the link", + ]); + expect(document.querySelectorAll("li")).toHaveLength(2); + expect( + [...document.querySelectorAll("p")].map((paragraph) => paragraph.textContent), + ).toContain("The deferred move continues on B."); + }, + ); + + it.each([" ", "
    more
    \n"])( + "preserves a paired anchor closing in the raw block %s", + (closing) => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + label\n\n${closing}\n\nfinish`} />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("See label"); + }, + ); + + it("preserves a paired anchor after comment-looking raw text", () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("See label -->"); + }); + + it.each(["", '
    more
    ', ''])( + "ignores apparent closing anchors inside %s", + (html) => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + .\n\n${html}\n\nAfter.`} />, + ), + "text/html", + ); + expect(document.querySelector("p")?.textContent).toBe("Before ."); + expect(document.querySelectorAll("a")).toHaveLength(0); + }, + ); + + it("preserves paired HTML anchors, details, markdown links, and inline code", () => { + const text = + 'Bare label, , ``, and [docs](https://example.com).\n\n
    MoreDetails
    '; + const document = new DOMParser().parseFromString( + renderToStaticMarkup(), + "text/html", + ); + + expect([...document.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ + "label", + "", + "docs", + ]); + expect(document.querySelector("code")?.textContent).toBe("
    "); + expect(document.querySelector("[data-markdown-details]")?.textContent).toContain("More"); + }); +}); + describe("ChatMarkdown context references", () => { it("renders text and image references through the chip renderer, with readable fallback", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a56db444d200..5389cae83d67 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -394,6 +394,7 @@ function orderedListGutterStyle( type MarkdownImageHastNode = { type?: string; + value?: string; tagName?: string; properties?: Record; children?: MarkdownImageHastNode[]; @@ -448,6 +449,54 @@ function markStandaloneImages(node: MarkdownImageHastNode) { }); } +/** Keep unmatched inline `` placeholders from opening an HTML link over later blocks. */ +function rehypePreserveBareAnchorPlaceholders() { + return (tree: MarkdownImageHastNode) => { + const anchors: Array = []; + let rawTextTag: string | undefined; + const visit = (node: MarkdownImageHastNode) => { + if (node.type === "raw" && typeof node.value === "string") { + // Raw blocks can contain several tags. Consume whole tags, quoted attributes, + // and comments so text resembling a closing anchor cannot pair a placeholder. + const tags = /|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g; + let offset = 0; + while (rawTextTag !== "plaintext") { + // Raw text ends at its closing tag even inside comment-looking text. + const matcher = rawTextTag ? new RegExp(``, "gi") : tags; + matcher.lastIndex = offset; + const match = matcher.exec(node.value); + if (!match) break; + const [tag] = match; + offset = matcher.lastIndex; + if (rawTextTag) { + rawTextTag = undefined; + continue; + } + if (tag.startsWith("