From 6266af3f1f688497ef9717b87b82eafc5240418c Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 10 Oct 2026 11:59:50 +0100 Subject: [PATCH 001/250] fix(server): t3_thread_launch refuses a new worktree whose base ref has no commit (#17791) --- .../server/src/git/GitWorkflowService.test.ts | 48 ++++++++ apps/server/src/git/GitWorkflowService.ts | 28 +++++ .../src/mcp/toolkits/project/handlers.test.ts | 46 ++++++++ .../src/mcp/toolkits/project/handlers.ts | 21 ++++ .../ThreadLaunchService.test.ts | 105 ++++++++++++++++++ .../orchestration-v2/ThreadLaunchService.ts | 62 ++++++++++- 6 files changed, 309 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 477bf2d39981..4617965803b9 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -6,6 +6,11 @@ import * as Option from "effect/Option"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; + +import * as ServerConfig from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -220,4 +225,47 @@ describe("GitWorkflowService", () => { ), ); }); + + it.effect("finds a name among the refs of every namespace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ref-named-" }); + const workflow = yield* GitWorkflowService.GitWorkflowService; + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const args of [ + ["init", "-b", "main"], + ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], + ["update-ref", "refs/remotes/upstream/feature/topic", "HEAD"], + ["symbolic-ref", "refs/remotes/origin/alias", "refs/heads/main"], + ["update-ref", "refs/mirror/origin/mirrored", "HEAD"], + ]) { + yield* driver.execute({ operation: "test.setupRepo", cwd, args }); + } + const found = yield* Effect.forEach( + ["main", "feature/topic", "alias", "mirrored", "t3/renamed", "feature/*"], + (refName) => workflow.hasRefNamed({ cwd, refName }), + ); + assert.deepEqual(found, [true, true, true, true, false, false]); + }).pipe( + Effect.scoped, + Effect.provide( + GitWorkflowService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle), + }), + ), + Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-ref-named-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index bc7f2d9fb2ea..f7eddd7f3c3f 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -40,6 +40,15 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly refName: string; }) => Effect.Effect; + /** + * Whether a ref in any namespace ends in this name: a local branch, a + * remote-tracking branch of any remote, a tag. Git falls back to these + * when a branch name is not a revision by itself. + */ + readonly hasRefNamed: (input: { + readonly cwd: string; + readonly refName: string; + }) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -300,6 +309,25 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.exitCode === 0), ), + hasRefNamed: (input) => + // No ref name holds a pattern character, and one here would widen the match. + /[*?[\\]/.test(input.refName) + ? Effect.succeed(false) + : ensureGitCommand("GitWorkflowService.hasRefNamed", input.cwd).pipe( + Effect.andThen( + git.execute({ + operation: "GitWorkflowService.hasRefNamed", + cwd: input.cwd, + args: [ + "for-each-ref", + "--count=1", + "--format=%(refname)", + `refs/**/${input.refName}`, + ], + }), + ), + Effect.map((result) => result.stdout.trim().length > 0), + ), status: (input) => detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index d657938ff206..b74df9dae1f9 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -301,6 +301,7 @@ const clientLaunchHarness = (input: { readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; readonly launched: Array; readonly workspaceRoot?: string; + readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; }) => { const projectId = ProjectId.make("project:client-target"); const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; @@ -320,6 +321,7 @@ const clientLaunchHarness = (input: { }), Layer.mock(ThreadManagement.ThreadManagementService)({}), Layer.mock(ThreadLaunch.ThreadLaunchService)({ + checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void), launch: (launch) => { input.launched.push(launch); return Effect.succeed({ @@ -454,3 +456,47 @@ it.effect("a launch binds only an existing checkout that is one of the project's }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("a launch into a new worktree creates nothing when its base ref has no commit", () => + Effect.gen(function* () { + const launched: Array = []; + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + checkWorktreeBase: ({ projectId, workspaceStrategy }) => + workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "t3/renamed" + ? Effect.fail( + new ThreadLaunch.ThreadLaunchBaseRefError({ + projectId, + projectTitle: "Client target", + workspaceRoot: "/projects/client-target", + baseRef: workspaceStrategy.baseRef, + }), + ) + : Effect.void, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launchFrom = (baseRef: string) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + workspaceStrategy: { type: "worktree", baseRef, startFromOrigin: false }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + const refused = (yield* launchFrom("t3/renamed")).at(-1)?.result; + expect(refused).toMatchObject({ code: "invalid_request" }); + expect((refused as { message: string }).message).toContain( + 'Base ref "t3/renamed" does not resolve to a commit in project "Client target" (/projects/client-target). No thread was created.', + ); + expect(launched).toHaveLength(0); + + expect((yield* launchFrom("main")).at(-1)?.result).toMatchObject({ projectId }); + expect(launched).toHaveLength(1); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 9a7d7024111e..edb732e400bc 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -2,6 +2,7 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; @@ -103,6 +104,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } + // A launch is accepted before its worktree is provisioned, so a base + // ref that cannot resolve is refused while the agent can still act on it. + const strategy = input.workspaceStrategy; + if (strategy?.type === "worktree") + yield* ThreadLaunch.ThreadLaunchService.pipe( + Effect.flatMap((launches) => + launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }), + ), + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ + code: "invalid_request", + message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${ + strategy.startFromOrigin === true + ? "; the project has no origin remote to fetch it from." + : ", or set startFromOrigin:true if it exists only on origin." + }`, + }), + ), + ); const modelSelection = input.modelSelection ?? caller?.modelSelection ?? diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index dfa02f4f514b..90248b0e1f6b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -107,6 +107,7 @@ interface HarnessOptions { readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"]; readonly serverSettings?: Parameters[0]; readonly providers?: ReadonlyArray; + readonly git?: Partial; } function makeHarness(options: HarnessOptions = {}) { @@ -175,6 +176,7 @@ function makeHarness(options: HarnessOptions = {}) { removeWorktree, resolveRemoteTrackingCommit: () => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), + ...options.git, }), Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { runForThread: runSetup, @@ -2024,6 +2026,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch }), ), ), + checkWorktreeBase: launches.checkWorktreeBase, retryPreparation: launches.retryPreparation, }), Effect.flip, @@ -2269,3 +2272,105 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit }).pipe(Effect.provide(harness.layer)); }), ); + +it.effect.each([ + { name: "is missing", baseRef: "t3/renamed", startFromOrigin: false, origin: true }, + { + name: "has no origin to come from", + baseRef: "t3/renamed", + startFromOrigin: true, + origin: false, + }, + { + name: "is a previous checkout there never was", + baseRef: "-", + startFromOrigin: false, + origin: true, + }, +])("refuses a new worktree whose base ref $name", (testCase) => + Effect.gen(function* () { + const harness = makeHarness({ + hasCommit: ({ refName }) => Effect.succeed(refName === "HEAD"), + git: { + remoteExists: () => Effect.succeed(testCase.origin), + hasRefNamed: () => Effect.succeed(false), + }, + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const error = yield* launches + .checkWorktreeBase({ + projectId, + workspaceStrategy: { + type: "worktree", + baseRef: testCase.baseRef, + startFromOrigin: testCase.startFromOrigin, + }, + }) + .pipe(Effect.flip); + assert.deepInclude(error, { + _tag: "ThreadLaunchBaseRefError", + projectId, + baseRef: testCase.baseRef, + }); + assert.equal( + error.message, + `Base ref "${testCase.baseRef}" does not resolve to a commit in project "Project" (/repo).`, + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + +it.effect.each([ + { name: "a base ref that resolves", baseRef: "main", commits: ["main", "HEAD"] }, + // Provisioning fetches it; the ref may exist nowhere else yet. + { + name: "a base ref only origin has", + baseRef: "remote-only", + startFromOrigin: true, + commits: ["HEAD"], + }, + // `git worktree add` starts from a remote-tracking branch of that name. + { + name: "a base ref a remote branch carries", + baseRef: "remote-only", + commits: ["HEAD"], + remoteBranch: true, + }, + // Provisioning runs such a repository without a worktree. + { name: "a repository with no commits yet", baseRef: "main", commits: [] }, + { name: "the previous checkout", baseRef: "-", commits: ["@{-1}", "HEAD"] }, + { name: "a commit search", baseRef: ":/fix", commits: ["HEAD"] }, + { name: "a folder that is not a repository", baseRef: "main", commits: null }, +])("leaves $name to the launch", (testCase) => + Effect.gen(function* () { + const { commits } = testCase; + const harness = makeHarness({ + git: { hasRefNamed: () => Effect.succeed(testCase.remoteBranch === true) }, + hasCommit: ({ refName }) => + commits === null + ? Effect.fail( + new GitCommandError({ + operation: "GitWorkflowService.hasCommit", + command: "git rev-parse", + cwd: "/repo", + detail: "Not a Git repository.", + }), + ) + : Effect.succeed(commits.includes(refName)), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + yield* launches.checkWorktreeBase({ + projectId, + workspaceStrategy: { + type: "worktree", + baseRef: testCase.baseRef, + ...(testCase.startFromOrigin === undefined + ? {} + : { startFromOrigin: testCase.startFromOrigin }), + }, + }); + }).pipe(Effect.provide(harness.layer)); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 10af1aef454d..d1a432bc9b52 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -147,12 +147,35 @@ export class ThreadLaunchError extends Schema.TaggedError()( } } +/** A new worktree was asked to start from a ref that gives it no commit. */ +export class ThreadLaunchBaseRefError extends Schema.TaggedError()( + "ThreadLaunchBaseRefError", + { + projectId: ProjectId, + projectTitle: Schema.String, + workspaceRoot: Schema.String, + baseRef: Schema.String, + }, +) { + override get message(): string { + return `Base ref "${this.baseRef}" does not resolve to a commit in project "${this.projectTitle}" (${this.workspaceRoot}).`; + } +} + export class ThreadLaunchService extends Context.Service< ThreadLaunchService, { readonly launch: ( input: ThreadLaunchInput, ) => Effect.Effect; + /** + * Fails when a new worktree's base ref cannot give provisioning a commit. + * `launch` accepts such a launch and reports the failure inside the + * thread; a caller that needs it before a thread exists checks first. + */ + readonly checkWorktreeBase: ( + input: Pick, + ) => Effect.Effect; /** Dispatches prepared-run.retry and prepares the run's workspace again. */ readonly retryPreparation: ( input: ThreadLaunchRetryInput, @@ -229,6 +252,43 @@ const make = Effect.gen(function* () { } }); + const checkWorktreeBase: ThreadLaunchService["Service"]["checkWorktreeBase"] = Effect.fn( + "ThreadLaunchService.checkWorktreeBase", + )(function* (input) { + const strategy = input.workspaceStrategy; + // `:/text` is a commit search, which cannot be asked for a commit type. + if (strategy.type !== "worktree" || strategy.baseRef.startsWith(":")) return; + const { baseRef } = strategy; + // Anything this cannot establish is left for the launch to report: a + // missing project, a folder that is not a repository, a failing Git. + const unresolved = yield* Effect.gen(function* () { + const project = Option.getOrUndefined(yield* projects.getById(input.projectId)); + if (project === undefined) return undefined; + const cwd = project.workspaceRoot; + // `git worktree add` reads `-` as the previous checkout. + if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef })) + return undefined; + // Provisioning fetches the base from origin when it can, and runs a + // repository with no commits yet without a worktree. + if ( + strategy.startFromOrigin === true && + (yield* git.remoteExists({ cwd, remoteName: "origin" })) + ) + return undefined; + // `git worktree add` also starts from a remote-tracking branch of that + // name; any ref carrying it is reason enough to let Git decide. + if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return undefined; + return (yield* git.hasCommit({ cwd, refName: "HEAD" })) ? project : undefined; + }).pipe(Effect.orElseSucceed(() => undefined)); + if (unresolved !== undefined) + return yield* new ThreadLaunchBaseRefError({ + projectId: unresolved.id, + projectTitle: unresolved.title, + workspaceRoot: unresolved.workspaceRoot, + baseRef, + }); + }); + const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( input: PreparationInput, threadId: ThreadId, @@ -977,7 +1037,7 @@ const make = Effect.gen(function* () { ); }; - return ThreadLaunchService.of({ launch, retryPreparation }); + return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation }); }); export const layer = Layer.effect(ThreadLaunchService, make); From d6f3af77192adfb2304769096eeec59d3464aa21 Mon Sep 17 00:00:00 2001 From: Saikrishna Ambeti Date: Sat, 10 Oct 2026 16:31:08 +0530 Subject: [PATCH 002/250] fix(web): omit underlines on markdown image links (#17728) --- apps/web/src/components/ChatMarkdown.tsx | 4 +++- apps/web/src/index.css | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4c712365475d..c0225713ec4c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2943,6 +2943,7 @@ const CHAT_MARKDOWN_COMPONENTS = { : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); + const hasText = hastHasText(node); const pullRequestAutolink = String( (props as Record)["data-pull-request-autolink"] ?? "", ); @@ -2974,6 +2975,7 @@ const CHAT_MARKDOWN_COMPONENTS = { {...props} className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")} data-markdown-copy={pullRequestCopy} + data-markdown-image-link={!hasText || undefined} href={href} target={isSameDocumentLink ? undefined : "_blank"} rel={isSameDocumentLink ? undefined : "noopener noreferrer"} @@ -3093,7 +3095,7 @@ const CHAT_MARKDOWN_COMPONENTS = { }); }} > - {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( + {faviconHost && hasText && !isPullRequestAutolink ? ( {linkChildren} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b59b8323f8a3..dfdf09af249d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1895,8 +1895,8 @@ code { color: var(--info-foreground); } -.chat-markdown a:not(.chat-markdown-file-link):hover, -.chat-markdown a:not(.chat-markdown-file-link):focus-visible { +.chat-markdown a:not(.chat-markdown-file-link, [data-markdown-image-link]):hover, +.chat-markdown a:not(.chat-markdown-file-link, [data-markdown-image-link]):focus-visible { background-image: radial-gradient(circle, currentcolor 0.75px, transparent 1px); background-position: left bottom; background-repeat: repeat-x; From 4606f9d6f213952124d93792854b0c4e48658e60 Mon Sep 17 00:00:00 2001 From: Zach Date: Sat, 10 Oct 2026 04:11:36 -0700 Subject: [PATCH 003/250] fix(server): restore auto resume for wrapped Claude gateway rate limits (#17778) --- .../Adapters/ClaudeAdapterV2.test.ts | 60 ++++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 32 +++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6777f0a2902a..4f89f58b3315 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3017,7 +3017,65 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([429, 401, 529])( + it.effect.each([ + { status: 503, cooldown: "4m 6s", seconds: 246 }, + { status: 503, cooldown: "1h 2m 3s", seconds: 3723 }, + { status: 503, cooldown: "7s", seconds: 7 }, + { status: 503, cooldown: "0s", seconds: 0 }, + { status: 503, cooldown: "unknown", seconds: 0 }, + { status: 503, cooldown: "999999999999999999999999h", seconds: 0 }, + { status: 503, cooldown: "4m 6s", seconds: 0, errorType: "overloaded_error" }, + { status: 401, cooldown: "4m 6s", seconds: 0 }, + ])("recovers wrapped gateway limits with status $status and cooldown $cooldown", (input) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-gateway-limit"), + text: "Continue.", + attachments: [], + }), + ); + yield* Queue.offerAll(harness.sdkMessages, [ + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000652", + error: "server_error", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000653", + result: `API Error: ${input.status} [claude/claude-opus-5-5] [429]: {"type":"error","error":{"type":"${input.errorType ?? "rate_limit_error"}","message":"Please try again later."}} (reset after ${input.cooldown}).`, + terminalReason: "api_error", + isError: true, + apiErrorStatus: input.status, + }), + ]); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.equal(terminal.failure.class, input.seconds > 0 ? "usage_limit" : "provider_error"); + if (input.seconds > 0) { + assert.equal(terminal.failure.retryable, true); + assert.equal( + terminal.failure.resetAt, + DateTime.formatIso( + DateTime.makeUnsafe(DateTime.toEpochMillis(now) + input.seconds * 1_000), + ), + ); + } else { + assert.isUndefined(terminal.failure.resetAt); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each([429, 401, 503, 529])( "classifies the current Claude API status %s after rate-limit evidence", (apiErrorStatus) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1451ec9ff62e..79e46c25dc8c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2553,9 +2553,37 @@ function isClaudeTaskNotificationOriginResult(message: SDKMessage): message is S function providerFailureFromResult( message: SDKResultMessage, + completedAt: DateTime.Utc, failureHint?: string, usageLimited = false, ): OrchestrationV2ProviderFailure | null { + // shortcut: accept 9router's wrapped 429 cooldown; prefer native reset metadata when available. + const gatewayLimit = + failureHint === undefined && + message.subtype === "success" && + message.is_error && + message.terminal_reason === "api_error" && + message.api_error_status === 503 + ? /^API Error: 503 \[[^\]\r\n]+\] \[429\]: \{"type":"error","error":\{"type":"rate_limit_error",[^\r\n]*\} \(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec( + message.result, + ) + : null; + if (gatewayLimit) { + const seconds = + Number(gatewayLimit[1] ?? 0) * 3_600 + + Number(gatewayLimit[2] ?? 0) * 60 + + Number(gatewayLimit[3] ?? 0); + const resetMs = DateTime.toEpochMillis(completedAt) + seconds * 1_000; + if (seconds > 0 && resetMs < 8.64e15) { + return makeProviderFailure({ + class: "usage_limit", + message: "Claude API rate limit reached. Try again later.", + code: "api_error_429", + retryable: true, + resetAt: DateTime.formatIso(DateTime.makeUnsafe(resetMs)), + }); + } + } const failureClass = message.terminal_reason === "blocking_limit" || (message.subtype === "success" && message.api_error_status === 429) || @@ -6707,10 +6735,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : null; const resultFailure = interrupted ? null - : providerFailureFromResult(message, failureHint, usageLimited); + : providerFailureFromResult(message, completedAt, failureHint, usageLimited); const terminalFailure = resultFailure?.class === "usage_limit" - ? { ...resultFailure, resetAt } + ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; yield* finalizeActiveTurn({ context, From e1ad595e0f10cc16d66fe54b4ee195435dd3cc0d Mon Sep 17 00:00:00 2001 From: Nikita Koynov <43469098+nkoynov@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:20:36 +0300 Subject: [PATCH 004/250] fix(opencode): name OpenCode 2 sessions after their thread (#17414) --- .../Adapters/OpenCode2AdapterV2.test.ts | 66 ++++++++++++++++++- ...penCode2OrchestratorV2.integration.test.ts | 30 +++++---- .../ProviderTurnStartService.ts | 2 + .../opencode2_fork/opencode_transcript.ndjson | 2 + .../opencode_transcript.ndjson | 2 + .../src/server/ProviderAdapter.ts | 2 + .../src/server/v2/adapter.ts | 31 ++++++++- 7 files changed, 121 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 205f54da7044..18041d9331aa 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -79,8 +79,11 @@ const mcpRules = [ { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" }, ]; const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules]; +/** The thread's title, which the recorded session already carries. */ +const TITLE = "Prime check"; const sessionInfo = (overrides: Record = {}) => ({ id: SESSION, + title: TITLE, permissions: t3Rules, projectID: "global", model: { id: "big-pickle", providerID: "opencode", variant: "default" }, @@ -226,7 +229,7 @@ const turnInput = ( modelSelection: ModelSelection = bigPickle, runtimeMode: "full-access" | "approval-required" = "full-access", ) => ({ - appThread: {} as OrchestrationV2AppThread, + appThread: { title: TITLE } as OrchestrationV2AppThread, threadId, runId: RunId.make("run:opencode2-adapter"), runOrdinal: 1, @@ -433,6 +436,66 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("creates the session with the thread's title, so OpenCode generates none", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ + ...opening, + out("session.create", { + location: { directory: WORK }, + model: { providerID: "opencode", id: "big-pickle" }, + permissions: t3Rules, + title: "Check whether 391 is prime", + }), + replyData("session.create", sessionInfo({ title: "Check whether 391 is prime" })), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const thread = yield* runtime.ensureThread({ + threadId, + title: " Check whether 391 is prime ", + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...turnInput(thread), + appThread: { title: "Check whether 391 is prime" } as OrchestrationV2AppThread, + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("renames the session before the first turn after the thread was renamed", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.update", { sessionID: SESSION, title: "Prime factors of 391" }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + // Renamed once: the next turn under the same title sends no update. + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const renamed = { + ...turnInput(thread), + appThread: { title: "Prime factors of 391" } as OrchestrationV2AppThread, + }; + const first = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(renamed); + assert.equal((yield* Fiber.join(first))?.status, "completed"); + const second = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...renamed, + runId: RunId.make("run:opencode2-adapter:2"), + attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), + }); + assert.equal((yield* Fiber.join(second))?.status, "completed"); + }).pipe(Effect.scoped), + ); + it.effect("ends a turn on the provider thread it started on", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -631,6 +694,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ...turnInput(thread), appThread: { id: threadId, + title: TITLE, lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2AppThread, }); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 3b9fa218d052..3f873bfb6ea3 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -151,9 +151,10 @@ const planRules = (name: string) => [ ...mcpRules(name), ]; -const sessionInfo = (directory: string, permissions: ReadonlyArray) => ({ +const sessionInfo = (directory: string, permissions: ReadonlyArray, title: string) => ({ data: { id: SESSION, + title, projectID: "global", model: { id: "big-pickle", providerID: "opencode", variant: "default" }, cost: 0, @@ -235,8 +236,9 @@ const createdSession = ( ], }), ...(narrows ? [out("agent.list", ""), reply("agent.list", agentList(directory))] : []), - out("session.create", { location: { directory }, model: "", permissions }), - reply("session.create", sessionInfo(directory, permissions)), + // The thread's title, which is its name here. + out("session.create", { location: { directory }, model: "", permissions, title: name }), + reply("session.create", sessionInfo(directory, permissions, name)), ]; /** A recording with its scrubbed `` directory replaced by `directory`. */ @@ -348,7 +350,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), // The next turn resumes the session at its new selection. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, t3Rules(name))), + reply("session.get", sessionInfo(cwd, t3Rules(name), name)), out("session.switchModel", { sessionID: SESSION, model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, @@ -401,7 +403,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), ...directoryModels(after), out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(before, t3Rules(name))), + reply("session.get", sessionInfo(before, t3Rules(name), name)), // The worktree change detached the thread, so its session is loaded afresh. ...noOpenRequests, out("session.move", { sessionID: SESSION, directory: after }), @@ -450,10 +452,14 @@ describe("OpenCode 2 through the orchestrator", () => { out("session.get", { sessionID: SESSION }), reply( "session.get", - sessionInfo(before, [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, - ]), + sessionInfo( + before, + [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + ], + name, + ), ), ...noOpenRequests, out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), @@ -531,7 +537,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), // A mode change detaches nothing: the same session is resumed with the new rules. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, t3Rules(name))), + reply("session.get", sessionInfo(cwd, t3Rules(name), name)), out("agent.list", ""), reply("agent.list", agentList(cwd)), out("session.update", { sessionID: SESSION, permissions: autoEditRules(name) }), @@ -539,7 +545,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("SECOND"), // Back to Full access: the narrowing rules go. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, autoEditRules(name))), + reply("session.get", sessionInfo(cwd, autoEditRules(name), name)), out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), ...answeredPrompt("THIRD"), @@ -577,7 +583,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...instructionsWritten, ...answeredPrompt("PLANNED"), out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, planRules(name))), + reply("session.get", sessionInfo(cwd, planRules(name), name)), out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), out("session.switchAgent", { sessionID: SESSION, agent: "build" }), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index e2c905b1e17a..684bde2c0669 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -651,6 +651,7 @@ export const layer: Layer.Layer< return yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, @@ -696,6 +697,7 @@ export const layer: Layer.Layer< const replacement = yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson index a327f5ffc9ed..57cbd1485346 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson @@ -50,6 +50,8 @@ {"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2-fork_target_*", "resource": "*", "effect": "allow"}]}}} {"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "emit_inbound", "label": "session.forked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b247d001fXzFroPg5P0bps", "created": 1790657111165, "type": "session.forked", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "parentID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "instructions": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}, "instructionEntries": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 0, "version": 2}}}} +{"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "title": ""}}} +{"type": "emit_inbound", "label": "session.update.title.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "key": "t3-code", "value": ""}}} {"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "id": "msg_0eb7b2493001nZTYCniIjU5Y65", "text": ""}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson index 11b79b6d31f1..ee2519419915 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson @@ -59,6 +59,8 @@ {"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}} {"type": "emit_inbound", "label": "session.agent.selected.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef80001lOHUbFIwlg7iap", "created": 1790657359744, "type": "session.agent.selected", "location": {"directory": ""}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build", "previous": "plan"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 16, "version": 1}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef830020JKd2UmFNPZ8vu", "created": 1790657359747, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7eef83001M1o8rpSkbKzlf5", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "synthetic", "payload": {"text": "\nYou are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.\n"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 17, "version": 1}}}} +{"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "title": ""}}} +{"type": "emit_inbound", "label": "session.update.title.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}} {"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}} {"type": "emit_inbound", "label": "session.model.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef8d001Yy5fmSDr6nQ8OS", "created": 1790657359757, "type": "session.model.selected", "location": {"directory": ""}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"id": "nemotron-3.5-lightning-free", "providerID": "opencode"}, "previous": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 18, "version": 1}}}} diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 086300e21878..ac5cd5fe8ab3 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -394,6 +394,8 @@ export interface ProviderAdapterV2OpenSessionInput { export interface ProviderAdapterV2EnsureThreadInput { readonly threadId: ThreadId; + /** The app thread's title, for providers that name their native sessions. */ + readonly title?: string; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; readonly providerSessionId?: ProviderSessionId; diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 306836614d55..86311cdafdd0 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -345,6 +345,8 @@ interface ThreadState { * rules stay in force, and a changed mode switches it before prompting. */ agent: string; + /** The native session's title as T3 last read or wrote it. */ + title: string | undefined; /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ rules: ReadonlyArray | undefined; policy: RulesPolicy; @@ -934,6 +936,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr unsettled: false, directory, agent: "build", + title: undefined, rules: undefined, policy: input.runtimePolicy, grants: [], @@ -3012,12 +3015,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.policy = policy; }); + /** Names the session after its thread when they differ. */ + const writeTitle = Effect.fnUntraced(function* (state: ThreadState, title: string) { + const next = title.trim(); + // An empty title asks OpenCode to generate one. + if (next === "" || next === state.title) return; + yield* client.session.update({ sessionID: Session.ID.make(state.sessionId), title: next }); + state.title = next; + }); + const register = ( providerThread: OrchestrationV2ProviderThread, native: { readonly id: string; readonly model?: ModelRef | undefined; readonly agent?: string | undefined; + readonly title?: string | undefined; readonly permissions?: ReadonlyArray | undefined; }, directory: string, @@ -3029,12 +3042,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr existing.model = native.model; existing.directory = directory; existing.agent = native.agent ?? existing.agent; + existing.title = native.title; existing.rules = native.permissions; return existing; } const state = newThreadState(native.id, providerThread, directory, undefined); state.model = native.model; state.agent = native.agent ?? state.agent; + state.title = native.title; state.rules = native.permissions; threads.set(native.id, state); return state; @@ -3615,10 +3630,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr policy, threadInput.threadId, ); + const title = threadInput.title?.trim() || undefined; + // A session created with a title is never titled by OpenCode's own model. const created = yield* client.session.create({ location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), model, permissions, + ...(title === undefined ? {} : { title }), }); const createdAt = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { @@ -3642,7 +3660,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; const state = register( providerThread, - { id: created.id, model: created.model, agent: created.agent, permissions }, + { + id: created.id, + model: created.model, + agent: created.agent, + title: title ?? created.title, + permissions, + }, directory, ); state.policy = policy; @@ -3801,6 +3825,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // and its subagents still running hold the rules they started with. // Those run on whether or not this turn starts, so theirs are best effort. yield* writeRules(state, turnInput.runtimePolicy); + // A thread renamed since the last turn, or titled after its first prompt. + yield* writeTitle(state, turnInput.appThread.title).pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ); for (const call of runningCalls(state)) { if (call.child === undefined) continue; yield* writeRules(call.child, turnInput.runtimePolicy).pipe( From 472ea7ec5f67fae660dc606b82698d15a2bc850f Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sat, 10 Oct 2026 07:27:33 -0400 Subject: [PATCH 005/250] fix(web): find update settings from the command palette (#17396) Co-authored-by: Claude --- .../src/components/settings/SettingsPanels.tsx | 6 ++++-- .../components/settings/settingsSearch.test.ts | 10 ++++++++++ .../src/components/settings/settingsSearch.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bee57d21b253..13c525a3d88e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -428,6 +428,7 @@ function AboutVersionSection() { return ( <> } description={description} control={ @@ -450,7 +451,7 @@ function AboutVersionSection() { /> {hasDesktopBridge ? ( ) : selectedHostedAppChannel ? ( } description="Current version of the application." /> diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 17bc6407d14b..2fd5707164b3 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -128,6 +128,16 @@ describe("searchSettings", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); expect(searchSettings("hold to quit")).toEqual([]); expect(searchSettings("wsl")).toEqual([]); + expect(searchSettings("update track")).toEqual([]); + }); + + it("finds the About update rows", () => { + expect(searchSettings("check for updates").map((item) => item.id)).toContain("app-version"); + expect(SETTINGS_SEARCH_ITEMS.find((item) => item.id === "update-track")).toMatchObject({ + title: "Update track", + to: "/settings/general", + releaseChannelOnly: true, + }); }); it("hides macOS-only settings on other platforms", () => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aff52adeb155..c23eeb8fdf06 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,4 @@ +import { HOSTED_APP_CHANNEL } from "~/branding"; import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; @@ -53,6 +54,9 @@ export interface SettingsSearchItem { // Its row only renders on Windows desktop, so other desktop platforms must // not expose a result that points to a missing anchor. readonly windowsOnly?: boolean; + // Its row renders only where the release channel can change: the desktop + // app, or a hosted web build deployed with a channel. + readonly releaseChannelOnly?: boolean; readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; @@ -520,6 +524,19 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, + { + id: "app-version", + title: "Version", + to: "/settings/general", + searchTerms: ["about check for updates download install upgrade release"], + }, + { + id: "update-track", + title: "Update track", + to: "/settings/general", + searchTerms: ["release channel stable latest nightly prerelease"], + releaseChannelOnly: true, + }, { id: "cli-command", title: "t3 command", @@ -1094,6 +1111,7 @@ export function searchSettings( return items .flatMap((item, index) => { if (!isElectron && item.desktopOnly === true) return []; + if (item.releaseChannelOnly && !isElectron && HOSTED_APP_CHANNEL === null) return []; if (item.macOnly && !isMacPlatform(platform)) return []; if (item.windowsOnly && !isWindowsPlatform(platform)) return []; From 7b09ac7e0022769425471fad1c0d152c03d5954f Mon Sep 17 00:00:00 2001 From: Karan Sharma Date: Sat, 10 Oct 2026 16:59:06 +0530 Subject: [PATCH 006/250] fix(provider-opencode): tell OpenCode Zen and Go models apart (#17424) --- apps/web/src/components/chat/ModelListRow.tsx | 5 +- .../components/chat/providerIconUtils.test.ts | 34 ++++ .../src/components/chat/providerIconUtils.ts | 21 +++ .../provider-opencode/src/server/driver.ts | 15 +- .../src/server/openCode2Catalog.test.ts | 149 ++++++++++++++++++ .../src/server/openCode2Catalog.ts | 74 +++++++++ .../src/server/status.test.ts | 41 +++++ .../provider-opencode/src/server/status.ts | 40 ++++- 8 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/providerIconUtils.test.ts create mode 100644 packages/provider-opencode/src/server/openCode2Catalog.test.ts create mode 100644 packages/provider-opencode/src/server/openCode2Catalog.ts diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a59ee5237fb8..7a15f173693b 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { CheckIcon, StarIcon } from "lucide-react"; import { getDisplayModelName, + getProviderRowLabel, getTriggerDisplayModelLabel, type ModelEsque, } from "./providerIconUtils"; @@ -43,9 +44,7 @@ export const ModelListRow = memo(function ModelListRow(props: { disabledReason?: string | null; onToggleFavorite: () => void; }) { - const providerLabel = props.model.subProvider - ? `${props.providerDisplayName} · ${props.model.subProvider}` - : props.providerDisplayName; + const providerLabel = getProviderRowLabel(props.providerDisplayName, props.model.subProvider); const row = ( { + it("shows a sub-provider named after its provider on its own", () => { + expect(getProviderRowLabel("OpenCode", "OpenCode Zen")).toBe("OpenCode Zen"); + expect(getProviderRowLabel("OpenCode", "OpenCode Go")).toBe("OpenCode Go"); + }); + + it("keeps a provider id that merely starts like the provider name", () => { + expect(getProviderRowLabel("OpenCode", "opencode-go")).toBe("OpenCode · opencode-go"); + }); + + it("joins a sub-provider that names something else", () => { + expect(getProviderRowLabel("OpenCode", "GitHub Copilot")).toBe("OpenCode · GitHub Copilot"); + }); + + it("keeps the provider name when a model has no sub-provider", () => { + expect(getProviderRowLabel("Claude", undefined)).toBe("Claude"); + }); +}); + +describe("getDisplayModelName", () => { + it("drops a leading sub-provider qualifier from the model name", () => { + expect( + getDisplayModelName({ + slug: "a/b", + name: "OpenCode Zen: Step 5", + subProvider: "OpenCode Zen", + }), + ).toBe("Step 5"); + }); +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 557d62d742db..54c11b494bef 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -32,6 +32,27 @@ export function getDisplayModelName( return stripLeadingQualifier(name, model.subProvider); } +/** + * The second line of a model row, naming where the model comes from. A + * sub-provider whose name reads as its provider plus a qualifier ("OpenCode + * Zen" under "OpenCode") stands alone: joining the two shows "OpenCode · + * OpenCode Zen", and OpenCode's own picker shows the sub-provider by itself. + */ +export function getProviderRowLabel( + providerDisplayName: string, + subProvider: string | undefined, +): string { + const provider = providerDisplayName.trim(); + const sub = subProvider?.trim(); + if (!sub) { + return providerDisplayName; + } + if (provider && new RegExp(`^${escapeRegExp(provider)}\\s+\\S`, "i").test(sub)) { + return sub; + } + return `${providerDisplayName} · ${sub}`; +} + export function getTriggerDisplayModelName(model: ModelEsque): string { return getDisplayModelName(model, { preferShortName: true }); } diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index f3340a62e9e0..1a4f235cd83a 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -36,6 +36,7 @@ import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textG import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; import { checkOpenCodeProviderStatus, loadOpenCode2Workspace, @@ -305,18 +306,8 @@ export const OpenCodeDriver: ProviderDriver< ), }); const loadOpenCode2Models = yield* makeOpenCode2ModelLoader( - openCode2Server.withConnection((connection) => - connection.client.model.list({ location: { directory: host.paths.cwd } }).pipe( - Effect.map((models) => models.data), - Effect.mapError( - (cause) => - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "model.list", - detail: "The OpenCode server could not list its models.", - cause, - }), - ), - ), + openCode2Server.withConnection(({ client }) => + loadOpenCode2Catalog(client, host.paths.cwd), ), ); // A 2.x server lists skills and commands per directory, so one server diff --git a/packages/provider-opencode/src/server/openCode2Catalog.test.ts b/packages/provider-opencode/src/server/openCode2Catalog.test.ts new file mode 100644 index 000000000000..4bddbd88e633 --- /dev/null +++ b/packages/provider-opencode/src/server/openCode2Catalog.test.ts @@ -0,0 +1,149 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { describe } from "vite-plus/test"; + +import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; +import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; + +/** + * `GET /api/model` from an `opencode serve` 2.0.26 with credentials for both + * gateways, cut to the one model each of them lists. Step 5 Preview Free is + * the whole point: both providers list it, with the same name. + */ +const MODELS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode-go","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}},{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1","provider":"opencode"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}}]}`; + +/** `GET /api/provider` from the same server. */ +const PROVIDERS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"opencode-go","integrationID":"opencode-go","name":"OpenCode Go","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"}},{"id":"opencode","integrationID":"opencode","name":"OpenCode Zen","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1"}}]}`; + +const layerServerReplying = ( + bodies: Readonly>, + hanging: ReadonlyArray = [], +) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.suspend(() => { + const path = new URL(request.url).pathname; + if (hanging.includes(path)) { + return Effect.never; + } + const body = bodies[path]; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(body ?? '{"_tag":"NotFoundError","message":"not recorded"}', { + status: body === undefined ? 404 : 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ), + ); + +/** + * Reads a catalog the way the driver does, through the client and a + * connection. `hanging` names paths the server accepts but never answers. + */ +const readCatalog = ( + bodies: Readonly>, + hanging: ReadonlyArray = [], +) => + Effect.gen(function* () { + const opencode = yield* OpenCode2Client.OpenCode2Client; + const { client } = yield* opencode.connect({ + baseUrl: "http://127.0.0.1:4096", + password: "secret", + }); + return yield* loadOpenCode2Catalog(client, "/project"); + }).pipe( + Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServerReplying(bodies, hanging)))), + ); + +describe("loadOpenCode2Catalog", () => { + it.effect("names the provider each listed model came from", () => + Effect.gen(function* () { + const models = yield* readCatalog({ + "/api/model": MODELS_BODY, + "/api/provider": PROVIDERS_BODY, + }); + + assert.deepStrictEqual( + models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]), + [ + ["opencode-go/step-5-preview-free", "OpenCode Go"], + ["opencode/step-5-preview-free", "OpenCode Zen"], + ], + ); + }), + ); + + it.effect("keeps the variants a model advertised", () => + Effect.gen(function* () { + const models = yield* readCatalog({ + "/api/model": MODELS_BODY, + "/api/provider": PROVIDERS_BODY, + }); + + assert.deepStrictEqual( + models.map((model) => model.variants), + [ + [{ id: "low" }, { id: "medium" }, { id: "high" }], + [{ id: "low" }, { id: "medium" }, { id: "high" }], + ], + ); + }), + ); + + // A server that cannot name its providers still lists its models, and the + // status check labels those with the provider id instead. + it.effect("leaves a model unnamed when the provider list fails", () => + Effect.gen(function* () { + const models = yield* readCatalog({ "/api/model": MODELS_BODY }); + + assert.deepStrictEqual( + models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]), + [ + ["opencode-go/step-5-preview-free", undefined], + ["opencode/step-5-preview-free", undefined], + ], + ); + }), + ); + + // Names are decoration: a provider read that never answers must not hold the + // models back, because the models are what the snapshot needs. + it.effect("keeps reading models while a provider list stays pending", () => + Effect.gen(function* () { + const reading = yield* readCatalog({ "/api/model": MODELS_BODY }, ["/api/provider"]).pipe( + Effect.forkChild, + ); + yield* TestClock.adjust("5 seconds"); + const models = yield* Fiber.join(reading); + + assert.deepStrictEqual( + models.map((model) => [`${model.providerID}/${model.id}`, model.providerName]), + [ + ["opencode-go/step-5-preview-free", undefined], + ["opencode/step-5-preview-free", undefined], + ], + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("fails the read when the model list fails", () => + Effect.gen(function* () { + const error = yield* readCatalog({ + "/api/model": '{"_tag":"InternalServerError","message":"catalog unavailable"}', + "/api/provider": PROVIDERS_BODY, + }).pipe(Effect.flip); + + assert.strictEqual(error.operation, "model.list"); + assert.match(error.detail, /could not list its models/i); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/openCode2Catalog.ts b/packages/provider-opencode/src/server/openCode2Catalog.ts new file mode 100644 index 000000000000..ddf78f3684fc --- /dev/null +++ b/packages/provider-opencode/src/server/openCode2Catalog.ts @@ -0,0 +1,74 @@ +import type { OpenCodeClient } from "@opencode/client/effect"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import type { OpenCode2Model } from "./status.ts"; + +/** + * How long a provider read may take before the models are listed without it. + * A server loads its provider list with the rest of its catalog, which takes a + * couple of seconds on a cold start, and the model loader retries while the + * catalog is empty anyway. Past that, the names are not worth waiting for. + */ +const PROVIDER_NAME_TIMEOUT = "3 seconds"; + +/** + * Reads the model catalog of an OpenCode 2 server, and names the provider each + * model belongs to. + * + * One instance fronts several providers: OpenCode Zen, OpenCode Go, and + * whatever the user added themselves. Their catalogues overlap, so a model's + * name alone cannot say which provider listed it and two rows for it read the + * same. Reading the server's provider list in the same connection as its + * models is what tells them apart. + * + * A server that cannot name its providers still lists its models, so the + * provider read is optional: a model whose provider went unnamed keeps only + * its id, which the status check then uses as the label. The models carry the + * payload and the names are decoration, so a provider read is bounded as well + * as optional: one that never answers must not hold the models back. Both + * reads are issued at once, and only the slower of the two is waited on. + */ +export const loadOpenCode2Catalog = ( + client: OpenCodeClient, + directory: string, +): Effect.Effect, OpenCodeRuntime.OpenCodeRuntimeError> => { + const location = { directory }; + return Effect.all( + { + models: client.model.list({ location }), + providers: client.provider + .list({ location }) + .pipe(Effect.timeout(PROVIDER_NAME_TIMEOUT), Effect.option), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map(({ models, providers }) => { + const providerNames = new Map( + (Option.getOrUndefined(providers)?.data ?? []).map((provider) => [ + provider.id, + provider.name, + ]), + ); + return models.data.map((model) => { + const providerName = providerNames.get(model.providerID); + return { + providerID: model.providerID, + id: model.id, + name: model.name, + ...(providerName ? { providerName } : {}), + variants: model.variants.map((variant) => ({ id: variant.id })), + }; + }); + }), + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + detail: "The OpenCode server could not list its models.", + cause, + }), + ), + ); +}; diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index c84dbdf3f894..0ed3d648641e 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -700,6 +700,47 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i }), ); + it.effect("labels each model with the sub-provider that listed it", () => + Effect.gen(function* () { + // Names come from the server's provider list; see openCode2Catalog.test.ts + // for reading that list itself. + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }), + process.cwd(), + undefined, + replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"), + Effect.succeed([ + { + providerID: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + providerName: "OpenAI", + variants: [], + }, + { + providerID: "custom", + id: "m1", + name: "Custom 1", + providerName: "OpenCode", + variants: [], + }, + { providerID: "anthropic", id: "claude", name: "Claude", variants: [] }, + ]), + ); + + const subProviderFor = (slug: string) => + snapshot.models.find((model) => model.slug === slug)?.subProvider; + NodeAssert.equal(subProviderFor("openai/gpt-5.4"), "OpenAI"); + // A provider called plain "OpenCode" would repeat the instance name. + NodeAssert.equal(subProviderFor("custom/m1"), undefined); + // The id stands in until the server names its providers. + NodeAssert.equal(subProviderFor("anthropic/claude"), "anthropic"); + }), + ); + it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () => Effect.gen(function* () { const snapshot = yield* checkProvider( diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index f89298af01ae..cb0892c5d9d6 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -383,9 +383,33 @@ export interface OpenCode2Model { readonly providerID: string; readonly id: string; readonly name: string; + /** + * Name of the provider the model belongs to, when the server names it. One + * OpenCode instance fronts several: OpenCode Zen, OpenCode Go, and whatever + * the user added themselves. + */ + readonly providerName?: string; readonly variants: ReadonlyArray<{ readonly id: string }>; } +/** + * One OpenCode instance fronts several providers, and their catalogues + * overlap: OpenCode Zen and OpenCode Go both list Step 5 Preview Free. Naming + * the sub-provider is what tells two such rows apart. The label only earns its + * place when it adds to the instance name the row already shows, which is why a + * provider called plain "OpenCode" keeps none, and the id stands in while the + * server has not named its providers yet. + */ +function subProviderLabelFor(input: { + readonly providerID: string; + readonly providerName?: string; +}): string | undefined { + const label = nonEmptyTrimmed(input.providerName) ?? input.providerID; + return label.toLowerCase() === OPENCODE_PRESENTATION.displayName.toLowerCase() + ? undefined + : label; +} + /** * A server loads its catalog lazily and lists nothing for its first few * hundred milliseconds, and a local instance starts a fresh server for each @@ -541,12 +565,16 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( } const models = providerModelsFromSettings( result.value - .map((model) => ({ - slug: `${model.providerID}/${model.id}`, - name: model.name, - isCustom: false, - capabilities: openCode2ModelCapabilities(model), - })) + .map((model) => { + const subProvider = subProviderLabelFor(model); + return { + slug: `${model.providerID}/${model.id}`, + name: model.name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCode2ModelCapabilities(model), + }; + }) .toSorted((left, right) => left.name.localeCompare(right.name)), settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, From adbecdac7b6f1a167cc389b3d5fe5be8ec70afdf Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Sat, 10 Oct 2026 07:31:01 -0400 Subject: [PATCH 007/250] fix(server): a pull that fast-forwards no longer fails on large Git output (#17376) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 85 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 4 + 2 files changed, 89 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fc0cdef01e5b..95124871a155 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -496,6 +496,91 @@ it.effect("coalesces concurrent ref pages into one repository snapshot", () => ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); +describe("pull output beyond the output cap", () => { + // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is + // padded past the cap instead. + const withPaddedPull = (stream: "stdout" | "stderr") => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const padding = new Uint8Array(1_000_001).fill(0x20); + const paddingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const handle = yield* delegate.spawn(command); + const isPull = + ChildProcess.isStandardCommand(command) && + command.args.includes("pull") && + command.args.includes("--ff-only"); + return isPull + ? ChildProcessSpawner.makeHandle({ + ...handle, + [stream]: Stream.concat(handle[stream], Stream.make(padding)), + }) + : handle; + }), + ); + return yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, paddingSpawner), + ); + }); + + const cloneBehindRemote = Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const updater = yield* makeTmpDir("git-vcs-driver-updater-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(updater, ["clone", remote, "."]); + yield* git(updater, ["config", "user.email", "test@test.com"]); + yield* git(updater, ["config", "user.name", "Test"]); + yield* writeTextFile(updater, "remote.txt", "remote\n"); + yield* git(updater, ["add", "remote.txt"]); + yield* git(updater, ["commit", "-m", "remote commit"]); + yield* git(updater, ["push", "origin", initialBranch]); + return { cwd, remoteHead: yield* git(updater, ["rev-parse", "HEAD"]) }; + }); + + it.effect.each(["stdout", "stderr"] as const)( + "reports a fast-forward whose %s passes the cap as pulled", + (stream) => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* withPaddedPull(stream); + yield* Effect.gen(function* () { + const { cwd, remoteHead } = yield* cloneBehindRemote; + + const result = yield* driver.pullCurrentBranch(cwd); + + assert.equal(result.status, "pulled"); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), remoteHead); + }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + }), + ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ); + + it.effect("still fails a pull that Git rejects, whatever its output size", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* withPaddedPull("stderr"); + yield* Effect.gen(function* () { + const { cwd } = yield* cloneBehindRemote; + // A local commit the remote lacks: `pull --ff-only` cannot fast-forward and exits 128. + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "local.txt"]); + yield* git(cwd, ["commit", "-m", "local commit"]); + + const error = yield* Effect.flip(driver.pullCurrentBranch(cwd)); + + assert.equal(error.operation, "GitVcsDriver.pullCurrentBranch.pull"); + assert.equal(error.exitCode, 128); + assert.notInclude(error.detail, "exceeded"); + }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + }), + ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), + ); +}); + it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 53e7f6418058..9d26aa1160b9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2518,9 +2518,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["rev-parse", "HEAD"], true, ).pipe(Effect.map((stdout) => stdout.trim())); + // After a successful pull, HEAD before and after decides the result, so its output is not + // needed. A large fast-forward's diffstat can exceed the output cap, which must not fail a + // pull Git applied. yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, fallbackErrorDetail: "git pull failed", + appendTruncationMarker: true, }); const afterSha = yield* runGitStdout( "GitVcsDriver.pullCurrentBranch.afterSha", From 91474e56f66888c5ab3856a782a72613e758c401 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Sun, 11 Oct 2026 00:32:05 +1300 Subject: [PATCH 008/250] fix(web): cancel question auto-advance after navigation (#17364) --- ...rPendingUserInputPanel.navigation.test.tsx | 144 ++++++++++++++++++ .../chat/ComposerPendingUserInputPanel.tsx | 16 +- 2 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx new file mode 100644 index 000000000000..c414934fa0da --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.test.tsx @@ -0,0 +1,144 @@ +import { RuntimeRequestId } from "@t3tools/contracts"; +import { act, useState, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { + togglePendingUserInputOptionSelection, + type PendingUserInputDraftAnswer, +} from "../../pendingUserInput"; + +vi.mock("../ui/collapsible", () => { + const Children = ({ children }: { children: ReactNode }) => <>{children}; + return { + Collapsible: Children, + CollapsiblePanel: Children, + CollapsibleTrigger: Children, + }; +}); + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +const prompt = { + requestId: RuntimeRequestId.make("question-navigation"), + createdAt: "2026-10-09T00:00:00.000Z", + dismissible: false, + responseCapability: "live" as const, + questions: [0, 1, 2].map((index) => ({ + id: `question-${index}`, + header: `Question ${index + 1}`, + question: `Choose for question ${index + 1}`, + options: [{ label: "Incremental", description: "One module at a time" }], + multiSelect: false, + })), +}; + +function Questions({ initialIndex = 0 }: { initialIndex?: number }) { + const [questionIndex, setQuestionIndex] = useState(initialIndex); + const [answers, setAnswers] = useState>({}); + const advance = () => setQuestionIndex(questionIndex + 1); + return ( + <> + {questionIndex} + + + { + const question = prompt.questions.find((entry) => entry.id === questionId)!; + setAnswers((existing) => ({ + ...existing, + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[questionId], + value, + ), + })); + }} + onDismiss={() => {}} + /> + + ); +} + +let renderer: ReactTestRenderer | undefined; +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); +}); +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function chooseOption() { + await act(async () => { + renderer!.root + .findAllByType("button") + .find((button) => + button.findAllByType("span").some((span) => span.children.includes("Incremental")), + )! + .props.onClick(); + }); +} + +async function navigate(label: string) { + await act(async () => { + renderer!.root + .findAllByType("button") + .find((button) => button.children.includes(label))! + .props.onClick(); + }); +} + +function activeIndex() { + return renderer!.root.findByType("output").children.join(""); +} + +it("does not skip the next unanswered question after manual advance", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await navigate("Next"); + expect(activeIndex()).toBe("1"); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); + + await chooseOption(); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("2"); +}); + +it("does not undo previous-question navigation with a stale timer", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await navigate("Previous"); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("0"); +}); + +it("still auto-advances once after the answer draft rerenders the same question", async () => { + await act(async () => { + renderer = create(); + }); + await chooseOption(); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); + await act(async () => vi.advanceTimersByTime(200)); + expect(activeIndex()).toBe("1"); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 687c92514178..1e8caefac2d4 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -93,13 +93,6 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onAdvanceRef.current = onAdvance; }, [onAdvance]); - useEffect(() => { - if (disabled && autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - autoAdvanceTimerRef.current = null; - } - }, [disabled]); - useEffect(() => { if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { return; @@ -121,14 +114,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( progress.selectedOptionValues, ]); - // Clear auto-advance timer on unmount + // A delayed answer only advances its own question, while permission remains granted. useEffect(() => { - return () => { + const clearAutoAdvance = () => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; } }; - }, []); + if (disabled || activeQuestion?.id === undefined) clearAutoAdvance(); + return clearAutoAdvance; + }, [activeQuestion?.id, disabled]); const handleOptionSelection = useCallback( (questionId: string, optionValue: string) => { From 33c1e36fd097fe4f8e4498db653ee97b17184791 Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Sat, 10 Oct 2026 07:34:21 -0400 Subject: [PATCH 009/250] fix(server): a bare repository name resolves to the signed-in account again (#17379) Co-authored-by: Claude Opus 5.5 --- .../GitHubSourceControlProvider.test.ts | 41 +++++++++++++++++++ .../src/server/GitHubSourceControlProvider.ts | 8 +++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 3f17db9bb302..7a902965b7aa 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -513,6 +513,47 @@ describe("GitHubSourceControlProvider writes", () => { assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); }).pipe(Effect.provide(layer)); }); + + it.effect("looks up a bare repository name under the signed-in account on that host", () => { + const requests: Array = []; + return Effect.gen(function* () { + const gh = yield* makeProvider({ + rest: (input) => + Effect.sync(() => { + requests.push(`${input.host} ${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "me/notes", + html_url: `https://${input.host}/me/notes`, + ssh_url: `git@${input.host}:me/notes.git`, + }); + }), + }); + + const urls = yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + context: githubContext("code.example.test"), + repository: " notes.git ", + }); + + assert.deepStrictEqual(urls, { + nameWithOwner: "me/notes", + url: "https://code.example.test/me/notes", + sshUrl: "git@code.example.test:me/notes.git", + }); + assert.deepStrictEqual(requests, [ + "code.example.test GET user", + "code.example.test GET repos/me/notes", + ]); + // Malformed names are still refused before any request. + for (const repository of ["me/", "/notes", "a/b/c/d", "", "two words"]) { + const error = yield* Effect.flip(gh.getRepositoryCloneUrls({ cwd: "/repo", repository })); + assert.include(error.message, "Repositories are named owner/name."); + } + assert.strictEqual(requests.length, 2); + }); + }); }); describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 0e54af539d9f..6cf169a1b754 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1025,7 +1025,13 @@ export const make = Effect.gen(function* () { Effect.map((locator) => locator.host), Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), )).toLowerCase(); - const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + // A bare name is the signed-in account's repository, the way `gh repo view` reads it. + const bareName = input.repository.trim().replace(/\.git$/i, ""); + const locator = + parseGitHubRepositorySelector(input.repository, fallbackHost) ?? + (/^[^/\s]+$/.test(bareName) + ? { host: fallbackHost, owner: yield* readViewerLogin(fallbackHost), name: bareName } + : null); if (locator === null) return yield* failure("Repositories are named owner/name."); return repositoryCloneUrls(yield* readRepository(locator)); }).pipe( From 71f6f1db89a50f0108663ebf1724cf3a1a4134ed Mon Sep 17 00:00:00 2001 From: James Villarrubia <8172873+jamesvillarrubia@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:37:00 -0400 Subject: [PATCH 010/250] fix(web): a maximized right panel stays maximized when you return to its thread (#17327) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 16 +++++---------- apps/web/src/rightPanelStore.test.ts | 29 ++++++++++++++++++++++++++++ apps/web/src/rightPanelStore.ts | 13 +++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2aeca85e6aa2..2620f363adc8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1914,9 +1914,6 @@ export default function ChatView(props: ChatViewProps) { const isRevertingCheckpoint = useComposerDraftStore((store) => store.rewindingThreadKeys.has(routeThreadKey), ); - const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( - null, - ); const userInputResponsesInFlight = useRef(new Set()); const [respondingRequestIds, setRespondingRequestIds] = useState([]); @@ -2392,8 +2389,7 @@ export default function ChatView(props: ChatViewProps) { renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; - const rightPanelMaximized = - canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPresentation, setThreadPanelPresentation] = useState("inline"); @@ -5866,7 +5862,7 @@ export default function ChatView(props: ChatViewProps) { .getState() .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); } - setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().setMaximized(activeThreadRef, false); useRightPanelStore.getState().close(activeThreadRef); } }, [activeRightPanelSurface, activeThreadRef]); @@ -6064,11 +6060,9 @@ export default function ChatView(props: ChatViewProps) { } }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]); const toggleRightPanelMaximized = useCallback(() => { - if (!canMaximizeRightPanel) return; - setMaximizedRightPanelThreadKey((threadKey) => - threadKey === routeThreadKey ? null : routeThreadKey, - ); - }, [canMaximizeRightPanel, routeThreadKey]); + if (!canMaximizeRightPanel || !activeThreadRef) return; + useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); + }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index ed27a64edd6f..961a4a178dac 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -322,6 +322,35 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); }); + it("keeps a maximized panel per thread without counting it as a manual choice", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "preview"); + store.open(refB, "diff"); + const revision = store.getUserActionRevision(refA); + store.setMaximized(refA, true); + store.setMaximized(refB, true); + store.setMaximized(refB, false); + + const { byThreadKey } = useRightPanelStore.getState(); + expect(selectThreadRightPanelState(byThreadKey, refA).maximized).toBe(true); + expect(selectThreadRightPanelState(byThreadKey, refB).maximized).toBeUndefined(); + expect(store.getUserActionRevision(refA)).toBe(revision); + }); + + it("restores a saved maximized panel during migration", () => { + const migrated = migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "browser:new", + surfaces: [{ id: "browser:new", kind: "preview", resourceId: null }], + maximized: true, + }, + }, + }); + expect(selectThreadRightPanelState(migrated.byThreadKey, refA).maximized).toBe(true); + }); + it("drops the legacy singleton terminal surface during migration", () => { expect( migratePersistedRightPanelState({ diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 8afdbedfcc87..fa417786a019 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -112,6 +112,8 @@ export interface ThreadRightPanelState { activeSurfaceId: string | null; surfaces: RightPanelSurface[]; dismissedDeviceSurfaceIds?: string[]; + /** Kept with the thread so the layout survives a ChatView remount or a reload. */ + maximized?: true; } export interface ThreadPanelVisibility { @@ -185,6 +187,7 @@ interface RightPanelStoreState { reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; + setMaximized: (ref: ScopedThreadRef, maximized: boolean) => void; toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, @@ -571,6 +574,7 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ), } : {}), + ...(validThreadState?.maximized === true ? { maximized: true as const } : {}), }, ]; }), @@ -969,6 +973,15 @@ export const useRightPanelStore = create()( current.isOpen ? { ...current, isOpen: false } : current, ), ), + // Layout only: it changes no surface, so proactive panels still apply. + setMaximized: (ref, maximized) => + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { + if ((current.maximized ?? false) === maximized) return current; + const { maximized: _maximized, ...rest } = current; + return maximized ? { ...rest, maximized: true } : rest; + }), + ), toggleVisibility: (ref) => set((state) => userAction(state, scopedThreadKey(ref), (current) => ({ From 02612c28564056ddb7ae6b06cedd31dafc235ffd Mon Sep 17 00:00:00 2001 From: Claudesaul <139932571+Claudesaul@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:37:26 -0400 Subject: [PATCH 011/250] fix(mobile): allow starting a task with only an image (#17409) --- .../features/threads/NewTaskDraftScreen.tsx | 4 ++-- .../threads/new-task-flow-provider.tsx | 3 ++- apps/mobile/src/state/thread-outbox-model.ts | 3 ++- apps/mobile/src/state/thread-outbox.test.ts | 20 +++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 181c257cbc17..cfb12bfd72a8 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1244,7 +1244,7 @@ export function NewTaskDraftScreen(props: { if ( attachmentBlockReason !== null || !modelSelection || - initialMessageText.length === 0 || + (initialMessageText.length === 0 && draft.attachments.length === 0) || flow.submitting || (workspaceMode === "worktree" && !selectedBranchName) ) { @@ -1397,7 +1397,7 @@ export function NewTaskDraftScreen(props: { !modelUnavailable && Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && - flow.prompt.trim().length > 0 && + (flow.prompt.trim().length > 0 || flow.attachments.length > 0) && isIncomingShareReady && !isImportingShare && !flow.submitting && diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9a523c6bb0b0..9b0699c0f740 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1105,7 +1105,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, draft.modelSelection ?? null, ) ?? selectedModel; - if (text.length === 0 || !draftModelSelection) { + // A shared image or file is a task on its own; text is optional. + if ((text.length === 0 && draft.attachments.length === 0) || !draftModelSelection) { return null; } // A saved choice from before the project went no-project must not diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 57a788cf5188..3c1527e04131 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -247,7 +247,8 @@ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): bo if (!message.creation) { return false; } - if (message.text.trim().length === 0 || message.modelSelection === undefined) { + const hasContent = message.text.trim().length > 0 || message.attachments.length > 0; + if (!hasContent || message.modelSelection === undefined) { return false; } return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index cbaffbbe5041..7cf10884f293 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1430,6 +1430,26 @@ describe("thread outbox", () => { false, ); expect(isQueuedThreadCreationSendable(base)).toBe(false); + expect(isQueuedThreadCreationSendable({ ...creationMessage, text: " " })).toBe(false); + expect( + isQueuedThreadCreationSendable({ + ...creationMessage, + text: "", + attachments: [ + { + id: "image-1", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + fileUri: "file:///documents/t3-composer-attachments/photo.png", + previewUri: "file:///documents/t3-composer-attachments/photo.png", + uploadedAttachmentId: "pending-photo-png", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }, + ], + }), + ).toBe(true); }); it("retries transport failures but drops deterministic command failures", () => { From a418075154d1d4f4263d5fa55804b9288f61d3dd Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Sat, 10 Oct 2026 07:38:26 -0400 Subject: [PATCH 012/250] fix(server): PR watch no longer reports passed while a second run of a check is still going (#17344) Co-authored-by: Claude Opus 5.5 --- .../src/server/gitHubPullRequestJson.test.ts | 40 +++++++++++++++++++ .../src/server/gitHubPullRequestJson.ts | 16 ++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 06e25c6e4709..145337a86dba 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -435,6 +435,46 @@ describe("pull request detail decoding", () => { expect(detail.checksState).toBe("pending"); }); + it("keeps a check pending while another run of it on the same commit is still going", () => { + // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The + // `pull_request` run finished after the `push` run started, and the `push` run is still going. + const finished = { + __typename: "CheckRun", + name: "check", + workflowName: "Checks", + status: "COMPLETED", + conclusion: "SUCCESS", + startedAt: "2026-10-08T04:06:10Z", + completedAt: "2026-10-08T04:18:58Z", + }; + const running = { + ...finished, + status: "IN_PROGRESS", + conclusion: "", + startedAt: "2026-10-08T04:06:13Z", + completedAt: "0001-01-01T00:00:00Z", + }; + // A run still queued has not started either, so it carries no time at all. + const queued = { ...running, status: "QUEUED", startedAt: "0001-01-01T00:00:00Z" }; + const raw = JSON.parse(detailJson) as Record; + const checksOf = (statusCheckRollup: ReadonlyArray) => { + const detail = expectSuccess( + decodePullRequestDetailJson(JSON.stringify({ ...raw, statusCheckRollup })), + ); + return [detail.checks.map((check) => [check.name, check.status]), detail.checksState]; + }; + + // Both listing orders, so the run still going wins on its own rather than by coming last. + for (const rollup of [ + [finished, running], + [running, finished], + [finished, queued], + [queued, finished], + ]) { + expect(checksOf(rollup)).toEqual([[["check", "pending"]], "pending"]); + } + }); + it("merges reviews with comments in time order and keeps a bodyless approval", () => { const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); // r2 approved without writing anything, which is still the event worth seeing. diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 956f1ef5f4ad..e3768b04d7bc 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -1576,6 +1576,9 @@ function toCheckStatus(raw: Schema.Schema.Type): PullRequ /** What GitHub writes where a run has not reached that moment yet, which is not a time. */ const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; +/** Sorts after every real timestamp, so the deduper keeps a run that has not finished. */ +const STILL_RUNNING = "9999-12-31T23:59:59Z"; + function realTimestamp(value: string | null | undefined): string | null { const at = trimmed(value); return at === null || at === UNSET_TIMESTAMP ? null : at; @@ -1588,8 +1591,9 @@ function isNamelessCheck(raw: Schema.Schema.Type): boolea /** * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last - * had something to say. A queued run reports a completion time it has not reached, so the start - * stands in for it rather than sorting the newest run to the bottom. + * had something to say. A run that has not finished outranks any finished run of the same check: + * a workflow triggered by both `push` and `pull_request` runs the check twice on one commit, and + * the run that finished first must not hide the one still going. */ function toCheckEntries( raw: ReadonlyArray> | null | undefined, @@ -1601,17 +1605,21 @@ function toCheckEntries( return (raw ?? []).flatMap((check) => { const name = trimmed(check.name) ?? trimmed(check.context); if (name === null) return []; + const status = toCheckStatus(check); return [ { check: { name, - status: toCheckStatus(check), + status, description: trimmed(check.description), url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), }, workflowName: trimmed(check.workflowName), - at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), + at: + status === "pending" + ? STILL_RUNNING + : (realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt)), }, ]; }); From a5b482452dc8ca87625d5a80f83be0a9b56674de Mon Sep 17 00:00:00 2001 From: Dylan Corso <96969209+DylanTX@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:43:39 -0500 Subject: [PATCH 013/250] fix(server): say why a thread can't be settled (#17258) Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 22 ++++++++++++++----- .../src/orchestration-v2/runtimeLayer.test.ts | 5 +++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3f17a6faa55d..94560f62f6f5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2605,10 +2605,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const automaticQueuedRuns = projection.runs.filter( (run) => run.status === "queued" && automaticMessageIds.has(run.userMessageId), ); - const activeRunExists = projection.runs.some( - (run) => - ["preparing", "queued", "starting", "running", "waiting"].includes(run.status) && - !automaticQueuedRuns.includes(run), + const runningRunExists = projection.runs.some((run) => + ["preparing", "starting", "running", "waiting"].includes(run.status), + ); + // Includes a queue held after a Stop, which otherwise shows no activity. + const queuedMessageExists = projection.runs.some( + (run) => run.status === "queued" && !automaticQueuedRuns.includes(run), ); const pendingRequests = projection.runtimeRequests.filter( (request) => request.status === "pending", @@ -2616,11 +2618,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const blockingRequestExists = pendingRequests.some( (request) => request.kind !== "user_input" || request.responseCapability.type !== "message", ); - if (activeRunExists || blockingRequestExists) { + // The reason is shown to the user, so it names what to clear first. + const blocker = blockingRequestExists + ? "is waiting on an approval or question. Answer it before settling." + : runningRunExists + ? "is still running. Stop it before settling." + : queuedMessageExists + ? "has a queued message. Send it or remove it from the queue before settling." + : null; + if (blocker !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: `Thread ${command.threadId} has active or blocked work and cannot be settled.`, + cause: `Thread ${command.threadId} ${blocker}`, }); } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 3d4bf4c2a64a..42c47bba51c0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3389,6 +3389,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { .pipe(Effect.flip); assert.equal(error._tag, "OrchestratorDispatchError"); + assert.equal(error.cause, `Thread ${threadId} is still running. Stop it before settling.`); const projection = yield* orchestrator.getThreadProjection(threadId); assert.equal(projection.runs[0]?.status, "starting"); assert.isNull(projection.thread.settledOverride); @@ -3587,6 +3588,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }) .pipe(Effect.flip); assert.equal(error._tag, "OrchestratorDispatchError"); + assert.equal( + error.cause, + `Thread ${threadId} has a queued message. Send it or remove it from the queue before settling.`, + ); }), ); From 03b210528160e68c8dbfc27f11f693085f92ab09 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:48:02 +0200 Subject: [PATCH 014/250] fix(server): prevent busy terminals from starving history persistence (#17181) --- .../shared/src/KeyedCoalescingWorker.test.ts | 95 ++++++++++++++++++- packages/shared/src/KeyedCoalescingWorker.ts | 39 +++----- 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 8bfc1a340a60..8ce44069d2d5 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -6,6 +6,98 @@ import * as Effect from "effect/Effect"; import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; describe("makeKeyedCoalescingWorker", () => { + it.live("lets a queued key drain before a continuously updated key finishes", () => + Effect.scoped( + Effect.gen(function* () { + const processed: string[] = []; + const busyStarted = yield* Deferred.make(); + const releaseBusy = yield* Deferred.make(); + + const worker = yield* makeKeyedCoalescingWorker({ + merge: (_current, next) => next, + process: (key, value): Effect.Effect => + Effect.gen(function* () { + processed.push(`${key}:${value}`); + if (key !== "busy") return; + + if (value === 1) { + yield* Deferred.succeed(busyStarted, undefined); + yield* Deferred.await(releaseBusy); + } + if (value < 4) yield* worker.enqueue(key, value + 1); + }), + }); + + yield* worker.enqueue("busy", 1); + yield* Deferred.await(busyStarted); + yield* worker.enqueue("quiet", 1); + yield* Deferred.succeed(releaseBusy, undefined); + yield* worker.drainKey("quiet"); + yield* worker.drainKey("busy"); + + expect(processed).toEqual(["busy:1", "quiet:1", "busy:2", "busy:3", "busy:4"]); + }), + ), + ); + + it.live("merges updates while waiting behind another key and drains the merged batch", () => + Effect.scoped( + Effect.gen(function* () { + const processed: string[] = []; + const busyStarted = yield* Deferred.make(); + const releaseBusy = yield* Deferred.make(); + const quietStarted = yield* Deferred.make(); + const releaseQuiet = yield* Deferred.make(); + const mergedStarted = yield* Deferred.make(); + const releaseMerged = yield* Deferred.make(); + + const worker = yield* makeKeyedCoalescingWorker({ + merge: (current, next) => `${current}+${next}`, + process: (key, value) => + Effect.gen(function* () { + processed.push(`${key}:${value}`); + if (value === "first") { + yield* Deferred.succeed(busyStarted, undefined); + yield* Deferred.await(releaseBusy); + } else if (key === "quiet") { + yield* Deferred.succeed(quietStarted, undefined); + yield* Deferred.await(releaseQuiet); + } else if (value === "a+b+c") { + yield* Deferred.succeed(mergedStarted, undefined); + yield* Deferred.await(releaseMerged); + } + }), + }); + + yield* worker.enqueue("busy", "first"); + yield* Deferred.await(busyStarted); + yield* worker.enqueue("quiet", "quiet"); + yield* worker.enqueue("busy", "a"); + yield* Deferred.succeed(releaseBusy, undefined); + yield* Deferred.await(quietStarted); + expect(processed).toEqual(["busy:first", "quiet:quiet"]); + yield* worker.enqueue("busy", "b"); + yield* worker.enqueue("busy", "c"); + + const drained = yield* Deferred.make(); + yield* Effect.forkChild( + worker.drainKey("busy").pipe(Effect.andThen(Deferred.succeed(drained, undefined))), + { startImmediately: true }, + ); + expect(yield* Deferred.isDone(drained)).toBe(false); + + yield* Deferred.succeed(releaseQuiet, undefined); + yield* Deferred.await(mergedStarted); + expect(yield* Deferred.isDone(drained)).toBe(false); + yield* Deferred.succeed(releaseMerged, undefined); + yield* Deferred.await(drained); + yield* worker.drainKey("quiet"); + + expect(processed).toEqual(["busy:first", "quiet:quiet", "busy:a+b+c"]); + }), + ), + ); + it.live("waits for latest work enqueued during active processing before draining the key", () => Effect.scoped( Effect.gen(function* () { @@ -85,12 +177,13 @@ describe("makeKeyedCoalescingWorker", () => { yield* worker.enqueue("terminal-1", "first"); yield* Deferred.await(firstStarted); + yield* worker.enqueue("terminal-2", "quiet"); yield* worker.enqueue("terminal-1", "second"); yield* Deferred.succeed(releaseFailure, undefined); yield* Deferred.await(secondProcessed); yield* worker.drainKey("terminal-1"); - expect(processed).toEqual(["terminal-1:first", "terminal-1:second"]); + expect(processed).toEqual(["terminal-1:first", "terminal-2:quiet", "terminal-1:second"]); }), ), ); diff --git a/packages/shared/src/KeyedCoalescingWorker.ts b/packages/shared/src/KeyedCoalescingWorker.ts index f4edebfafb30..ce1d89a41169 100644 --- a/packages/shared/src/KeyedCoalescingWorker.ts +++ b/packages/shared/src/KeyedCoalescingWorker.ts @@ -2,8 +2,9 @@ * KeyedCoalescingWorker - A keyed worker that keeps only the latest value per key. * * Enqueues for an active or already-queued key are merged atomically instead of - * creating duplicate queued items. `drainKey()` resolves only when that key has - * no queued, pending, or active work left. + * creating duplicate queued items. Each batch yields to other queued keys before + * processing newer updates. `drainKey()` resolves only when that key has no + * queued, pending, or active work left. * * @module KeyedCoalescingWorker */ @@ -35,28 +36,9 @@ export const makeKeyedCoalescingWorker = (options: { activeKeys: new Set(), }); - const processKey = (key: K, value: V): Effect.Effect => - options.process(key, value).pipe( - Effect.flatMap(() => - TxRef.modify(stateRef, (state) => { - const nextValue = state.latestByKey.get(key); - if (nextValue === undefined) { - const activeKeys = new Set(state.activeKeys); - activeKeys.delete(key); - return [null, { ...state, activeKeys }] as const; - } - - const latestByKey = new Map(state.latestByKey); - latestByKey.delete(key); - return [nextValue, { ...state, latestByKey }] as const; - }).pipe(Effect.tx), - ), - Effect.flatMap((nextValue) => - nextValue === null ? Effect.void : processKey(key, nextValue), - ), - ); - - const cleanupFailedKey = (key: K): Effect.Effect => + // Return dirty keys to the tail on success or failure. Keep pending values + // available for merging until the next batch starts. + const completeKey = (key: K): Effect.Effect => TxRef.modify(stateRef, (state) => { const activeKeys = new Set(state.activeKeys); activeKeys.delete(key); @@ -69,10 +51,11 @@ export const makeKeyedCoalescingWorker = (options: { return [false, { ...state, activeKeys }] as const; }).pipe( - Effect.tx, Effect.flatMap((shouldRequeue) => shouldRequeue ? TxQueue.offer(queue, key) : Effect.void, ), + Effect.tx, + Effect.asVoid, ); yield* TxQueue.take(queue).pipe( @@ -100,9 +83,9 @@ export const makeKeyedCoalescingWorker = (options: { Effect.flatMap((item) => item === null ? Effect.void - : processKey(item.key, item.value).pipe( - Effect.catchCause(() => cleanupFailedKey(item.key)), - ), + : options + .process(item.key, item.value) + .pipe(Effect.ignoreCause, Effect.ensuring(completeKey(item.key))), ), Effect.forever, Effect.forkScoped, From de49d637d4b72b3e3306533e3777cb37e6a3f738 Mon Sep 17 00:00:00 2001 From: Prathmesh Vhatkar Date: Sat, 10 Oct 2026 17:20:31 +0530 Subject: [PATCH 015/250] feat(server): use macOS .icns app icons as project icons (#17149) --- apps/desktop/src/ipc/methods/window.test.ts | 2 +- apps/desktop/src/ipc/methods/window.ts | 4 +- apps/server/src/assets/AssetAccess.test.ts | 32 ++++++++++ apps/server/src/assets/AssetAccess.ts | 36 +++++++++-- apps/server/src/assets/icns.test.ts | 62 +++++++++++++++++++ apps/server/src/assets/icns.ts | 30 +++++++++ .../src/project/ProjectFaviconResolver.ts | 4 +- .../src/workspace/WorkspaceSearchIndex.ts | 4 +- packages/contracts/src/project.test.ts | 4 ++ packages/contracts/src/project.ts | 2 +- packages/shared/src/projectFavicon.ts | 9 +++ 11 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/assets/icns.test.ts create mode 100644 apps/server/src/assets/icns.ts diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index a6913528653c..d21f3cd60505 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -287,7 +287,7 @@ describe("pickProjectFavicon", () => { filters: [ { name: "Images", - extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"], + extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"], }, ], }, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 47e6c02ffe52..33895ffffbd1 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -13,7 +13,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; -import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; +import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as FileSystem from "effect/FileSystem"; @@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ filters: [ { name: "Images", - extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)), + extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)), }, ], }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8a92b40aaea2..4e71c68f875c 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1226,6 +1226,38 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("serves a native macOS app icon as its embedded PNG", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-icns-", + }); + const icns = new Uint8Array(8 + 8 + screenshotPng.length); + icns.set(new TextEncoder().encode("icns")); + new DataView(icns.buffer).setUint32(4, icns.length); + icns.set(new TextEncoder().encode("ic07"), 8); + new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length); + icns.set(screenshotPng, 16); + yield* fileSystem.makeDirectory(path.join(root, "Resources")); + yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns")); + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + ); + expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png"); + expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("rejects a resolved project favicon with a non-image extension", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 43d7d75f7864..303137c149cd 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -31,7 +31,10 @@ import { type ImageDimensions, } from "@t3tools/shared/imageDimensions"; import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; -import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; +import { + isProjectFaviconPath, + PROJECT_FAVICON_FALLBACK_MARKER, +} from "@t3tools/shared/projectFavicon"; import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import { extractIcnsPng } from "./icns.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; const PROJECT_FAVICON_VERSION_PREFIX = "v"; +// Clients draw project icons from a 96px thumbnail at most. +const PROJECT_FAVICON_ICNS_MIN_SIZE = 96; const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; // Extensions a document viewer or audio player may request inline. The extension comes from // the attachment id the server assigned, never from the client's mime type. @@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") }; }); +/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */ +const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* ( + filePath: string, +) { + if (!filePath.toLowerCase().endsWith(".icns")) { + return { kind: "file", path: filePath } satisfies ResolvedAsset; + } + const fileSystem = yield* FileSystem.FileSystem; + const bytes = yield* fileSystem.readFile(filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to read project icon image.", { filePath, cause }), + ), + Effect.orElseSucceed(() => null), + ); + const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null; + return png + ? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset) + : null; +}); + const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* ( filePath: string, ) { @@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const relativePath = faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null; const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath; - if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) { + if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } sourcePath = sourceFaviconPath ?? undefined; @@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); - return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + return faviconPath ? yield* projectFaviconAsset(faviconPath) : null; } if (claims.kind === "project-favicon-external") { @@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( ), Effect.orElseSucceed(() => null), ); - return faviconPath === claims.filePath - ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) - : null; + return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null; } if (claims.kind === "github-media") { diff --git a/apps/server/src/assets/icns.test.ts b/apps/server/src/assets/icns.test.ts new file mode 100644 index 000000000000..ee129b30032e --- /dev/null +++ b/apps/server/src/assets/icns.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { extractIcnsPng } from "./icns.ts"; + +function pngHeader(size: number): Uint8Array { + const png = new Uint8Array(24); + png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + new DataView(png.buffer).setUint32(16, size); + new DataView(png.buffer).setUint32(20, size); + return png; +} + +function icns(...entries: ReadonlyArray): Uint8Array { + const length = 8 + entries.reduce((total, [, data]) => total + 8 + data.length, 0); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + bytes.set(new TextEncoder().encode("icns")); + view.setUint32(4, length); + let offset = 8; + for (const [type, data] of entries) { + bytes.set(new TextEncoder().encode(type), offset); + view.setUint32(offset + 4, 8 + data.length); + bytes.set(data, offset + 8); + offset += 8 + data.length; + } + return bytes; +} + +const widthOf = (png: Uint8Array | null) => + png ? new DataView(png.buffer, png.byteOffset).getUint32(16) : null; + +describe("extractIcnsPng", () => { + it("picks the smallest PNG that covers the requested size", () => { + const file = icns( + ["TOC ", new Uint8Array(16)], + ["ic09", pngHeader(512)], + ["icp5", pngHeader(32)], + ["ic07", pngHeader(128)], + ["ic08", pngHeader(256)], + ); + + expect(widthOf(extractIcnsPng(file, 96))).toBe(128); + }); + + it("falls back to the largest PNG when none covers the requested size", () => { + const file = icns(["icp4", pngHeader(16)], ["icp5", pngHeader(32)]); + + expect(widthOf(extractIcnsPng(file, 96))).toBe(32); + }); + + it("returns null for files without a PNG entry or with a foreign header", () => { + expect(extractIcnsPng(icns(["it32", new Uint8Array(64)]), 96)).toBeNull(); + expect(extractIcnsPng(pngHeader(128), 96)).toBeNull(); + }); + + it("stops at an entry whose length runs past the file", () => { + const file = icns(["ic07", pngHeader(128)], ["ic08", pngHeader(256)]); + new DataView(file.buffer).setUint32(8 + 8 + 24 + 4, 4096); + + expect(widthOf(extractIcnsPng(file, 200))).toBe(128); + }); +}); diff --git a/apps/server/src/assets/icns.ts b/apps/server/src/assets/icns.ts new file mode 100644 index 000000000000..429f4f7d3193 --- /dev/null +++ b/apps/server/src/assets/icns.ts @@ -0,0 +1,30 @@ +import { readImageDimensions } from "@t3tools/shared/imageDimensions"; + +const ICNS_HEADER_BYTES = 8; +const ICNS_MAGIC = 0x69636e73; // "icns" +const PNG_MAGIC = 0x89504e47; // "\x89PNG" + +/** + * Picks the smallest PNG at least `minSize` wide, else the largest; null when there is none. + * Returns a copy so the response does not pin the whole `.icns` buffer. + */ +export function extractIcnsPng(bytes: Uint8Array, minSize: number): Uint8Array | null { + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < ICNS_HEADER_BYTES || data.getUint32(0) !== ICNS_MAGIC) return null; + const end = Math.min(data.getUint32(4), bytes.length); + let best: { readonly png: Uint8Array; readonly width: number } | null = null; + for (let offset = ICNS_HEADER_BYTES; offset + ICNS_HEADER_BYTES <= end;) { + const length = data.getUint32(offset + 4); + if (length < ICNS_HEADER_BYTES || offset + length > end) break; + const entry = bytes.subarray(offset + ICNS_HEADER_BYTES, offset + length); + const isPng = entry.length >= 4 && data.getUint32(offset + ICNS_HEADER_BYTES) === PNG_MAGIC; + offset += length; + const width = isPng ? readImageDimensions(entry)?.width : undefined; + if (width === undefined) continue; + const isBetter = + best === null || + (best.width < minSize ? width > best.width : width >= minSize && width < best.width); + if (isBetter) best = { png: entry, width }; + } + return best?.png.slice() ?? null; +} diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 58a2de4f7a1a..8a0bb5796976 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -22,7 +22,7 @@ import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; -// Resolution probes 21 well-known paths plus 7 source files, so a miss +// Resolution probes 23 well-known paths plus 7 source files, so a miss // costs ~30 filesystem probes. AssetAccess resolves on every project-favicon // asset URL, and a project's icon does not move, so the answer is cached. const FAVICON_CACHE_CAPACITY = 512; @@ -69,6 +69,8 @@ const FAVICON_CANDIDATES = [ "assets/logo.svg", "assets/logo.png", ".idea/icon.svg", + "Resources/AppIcon.icns", + "AppIcon.icns", ] as const; // Files that may contain a or icon metadata declaration. diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 7cd96a65ebee..78a108cd18ac 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -25,7 +25,7 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; // fff-node stays external to the CLI bundle because it dlopens a native // library. A static `import` of an external package is a hard error inside a @@ -169,7 +169,7 @@ function mapFileSearchResult( ): ProjectSearchEntriesResult { const entries = result.items.flatMap((item) => { const entry = toFileEntry(item); - return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; + return entry && (!imageOnly || isProjectFaviconPath(entry.path)) ? [entry] : []; }); return { entries: entries.slice(0, limit), diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 81c260aa2725..0cf03ab1a65e 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath); effectIt.effect("project favicon paths accept only supported image files", () => Effect.gen(function* () { assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg"); + assert.strictEqual( + yield* decodeFaviconPath("Resources/AppIcon.icns"), + "Resources/AppIcon.icns", + ); assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure"); }), ); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d1d13e82ac37..baff3dc82705 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -97,7 +97,7 @@ const ProjectMonogramIcon = Schema.Struct({ /** A workspace-relative image a project may use as its favicon. */ export const ProjectFaviconPath = TrimmedNonEmptyString.check( Schema.isMaxLength(1024), - Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), + Schema.isPattern(/\.(?:avif|gif|icns|ico|jpe?g|png|svg|webp)$/i), ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index b6fd9e56f3a6..8dbceb5504bc 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,14 @@ +import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./filePreview.ts"; + export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export const PROJECT_FAVICON_EXTENSIONS = [...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".icns"] as const; + +export function isProjectFaviconPath(path: string): boolean { + const literalPath = path.toLowerCase(); + return PROJECT_FAVICON_EXTENSIONS.some((extension) => literalPath.endsWith(extension)); +} + export function getProjectFaviconResourceKey( environmentId: string, workspaceRoot: string, From a1dee8b2b34ad949935b703b85efb16b6fd1b987 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:59:18 +0100 Subject: [PATCH 016/250] fix(mobile): usage reset icon lines up with its row (#17175) --- .../mobile/src/features/usage/UsageLimitsPooled.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 713b9ffd160f..ae2938476559 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -178,9 +178,16 @@ function PoolWindowCard({ {resetsIn ? ( - - {resetsIn.replace("resets in ", "↻ ")} - + <> + + + {resetsIn.replace("resets in ", "")} + + ) : null} {credits ? ( <> From ee7924ff7fcabe24d5e8d940ac5e878d36477a0d Mon Sep 17 00:00:00 2001 From: Derek Trimm <275381468+derektrimm@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:04:54 -0500 Subject: [PATCH 017/250] fix(web): paths pasted after @ keep their underscores (#16619) Co-authored-by: Yash Singh --- apps/web/src/components/ChatView.tsx | 4 +- .../components/ComposerPromptEditorTiptap.tsx | 17 +++- apps/web/src/components/chat/ChatComposer.tsx | 3 + apps/web/src/composer-logic.ts | 16 ++++ apps/web/src/composer-rich-text-doc.test.ts | 90 ++++++++++++++++++- apps/web/src/composer-rich-text-doc.ts | 18 +++- 6 files changed, 141 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2620f363adc8..d2ec0af4ed20 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6056,9 +6056,9 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!canMaximizeRightPanel) return; if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { - setMaximizedRightPanelThreadKey(routeThreadKey); + useRightPanelStore.getState().setMaximized(routeThreadRef, true); } - }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]); + }, [canMaximizeRightPanel, routeThreadRef]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel || !activeThreadRef) return; useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 22002431097f..5d5c87f53881 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -45,6 +45,7 @@ import { collapseExpandedComposerCursor, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, + pastedPathQueryLength, } from "~/composer-logic"; import { collectComposerPromptInlineTokens, @@ -160,6 +161,8 @@ export interface ComposerPromptEditorProps { suggestionListId?: string | undefined; /** References the highlighted option only while its list is rendered. */ activeSuggestionId?: string | undefined; + /** Reads suggestion state at paste time, including Escape dismissal. */ + isPathQueryActive?: () => boolean; containerClassName?: string; className?: string; placeholderClassName?: string; @@ -820,6 +823,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ariaLabel, suggestionListId, activeSuggestionId, + isPathQueryActive, containerClassName, className, placeholderClassName, @@ -855,6 +859,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const onChangeRef = useRef(onChange); const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); const onCommandKeyDownRef = useRef(onCommandKeyDown); + const isPathQueryActiveRef = useRef(isPathQueryActive); const buildFragmentRef = useRef(buildContextClipboardFragment); const importFragmentRef = useRef(importContextFragment); const skillsRef = useRef(skills); @@ -872,6 +877,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { useEffect(() => { onCommandKeyDownRef.current = onCommandKeyDown; }, [onCommandKeyDown]); + useEffect(() => { + isPathQueryActiveRef.current = isPathQueryActive; + }, [isPathQueryActive]); useEffect(() => { buildFragmentRef.current = buildContextClipboardFragment; }, [buildContextClipboardFragment]); @@ -1412,6 +1420,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; } + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.()); const editorInstance = editorHolder.current; if (editorInstance) { // Inside a list item or quote, pasted block markup has nowhere to @@ -1424,7 +1437,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { insertMarkdownParagraphs( text, skillLabelFor, - { styling: richText, blocks: !nested, literalText }, + { styling: richText, blocks: !nested, literalText, literalLength }, (content) => { // Tagged on the same transaction insertContent builds, so the // paste is one undo step of its own. @@ -1815,7 +1828,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean; blocks?: boolean; literalText?: boolean }, + options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0d55c1c83e06..c9be1f46442d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7479,6 +7479,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) editorRef={composerEditorRef} richTextEnabled={settings.composerRichTextEnabled} literalText={isLiteralPendingAnswer} + isPathQueryActive={() => + resolveActiveComposerTrigger().trigger?.kind === "path" + } value={ isComposerApprovalState ? "" diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c26996f494e5..a44b999f2ba6 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -306,6 +306,22 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +/** + * Length of the leading part of `pasted` that continues an `@` path query + * typed at the end of `lineBefore`, the line's plain text up to the caret. A + * chip must read there as one non-space character, so it never passes for a + * typed `@path`. + */ +export function pastedPathQueryLength( + lineBefore: string, + pasted: string, + pathQueryActive = true, +): number { + if (!pathQueryActive) return 0; + if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0; + return /^\S*/.exec(pasted)![0].length; +} + /** Caret and trigger after replacing composer text and continuing at the end. */ export function composerStateAtPromptEnd( text: string, diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 1693cdbae9ac..19c6e0299f2e 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -6,7 +6,7 @@ import { EditorState, TextSelection } from "@tiptap/pm/state"; import { splitBlockKeepMarks } from "@tiptap/pm/commands"; import { describe, expect, it } from "vite-plus/test"; -import { collapseExpandedComposerCursor } from "./composer-logic"; +import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic"; import { buildDocJson, @@ -942,6 +942,94 @@ describe("pasting into a list item or quote", () => { }); }); +describe("pasting into a path query", () => { + const skillLabelFor = (name: string) => ({ label: name, description: null }); + + // Inserts `pasted` at the end of a one-line prompt the way the composer's + // paste handler does. `prompt` may be a document, to place the caret after a chip. + function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive = true) { + const promptDoc = + typeof prompt === "string" + ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor)) + : prompt; + const line = promptDoc.lastChild!; + const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC"); + const literalLength = pastedPathQueryLength(lineBefore, pasted, pathQueryActive); + const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength }); + const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block)); + const head = promptDoc.lastChild!.content.append(first!.content); + const doc = schema.node("doc", null, [first!.copy(head), ...rest]); + doc.check(); + return doc; + } + + function boldText(doc: ProseMirrorNode) { + const bold: string[] = []; + doc.descendants((node) => { + if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) { + bold.push(node.text!); + } + }); + return bold; + } + + it.each(["src/__test__.tsx", "src/__tests__/a.ts", "pkg/__init__.py", "a/**b**/c"])( + "keeps %s literal after @ so file search can match it", + (path) => { + const doc = paste("Open @", path); + expect(boldText(doc)).toEqual([]); + expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`); + }, + ); + + it("still styles the text after the path", () => { + const doc = paste("Open @", "src/__test__.tsx and **this**\n- [ ] then __that__"); + expect(boldText(doc)).toEqual(["this", "that"]); + expect(doc.lastChild!.type.name).toBe("taskList"); + expect(serializeEditorDoc(doc).value).toBe( + "Open @src/__test__.tsx and **this**\n- [ ] then **that**", + ); + }); + + it("parses markdown after Escape dismisses the path query", () => { + const doc = paste("Open @", "__hello__ world", false); + expect(boldText(doc)).toEqual(["hello"]); + expect(serializeEditorDoc(doc).value).toBe("Open @**hello** world"); + }); + + it("keeps the rest of the path's line on that line", () => { + const doc = paste("Open @", "src/a.ts - not a list item"); + expect(doc.childCount).toBe(1); + expect(serializeEditorDoc(doc).value).toBe("Open @src/a.ts - not a list item"); + }); + + it("parses markdown when the paste does not continue a path query", () => { + expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]); + expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]); + expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]); + }); + + it("keeps a path literal after a partly styled query", () => { + const doc = paste("Open @**src/**", "__test__.tsx"); + expect(boldText(doc)).toEqual(["src/"]); + expect(serializeEditorDoc(doc).value).toBe("Open @**src/**__test__.tsx"); + }); + + it("parses markdown pasted right after a mention chip", () => { + const withSpace = ProseMirrorNode.fromJSON( + schema, + buildDocJson("Open @README.md ", skillLabelFor), + ); + const paragraph = withSpace.firstChild!; + expect(paragraph.lastChild!.text).toBe(" "); + const chipLast = schema.node("doc", null, [ + paragraph.copy(paragraph.content.cut(0, paragraph.content.size - 1)), + ]); + expect(chipLast.firstChild!.lastChild!.type.name).toBe("composer-mention"); + expect(boldText(paste(chipLast, "__b__"))).toEqual(["b"]); + }); +}); + describe("caret stops at styled edges", () => { function stateAt(value: string, pmPos: number) { const doc = ProseMirrorNode.fromJSON( diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index dc3525e1249a..8c376fb5d93f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -534,10 +534,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean; blocks?: boolean; literalText?: boolean }, + options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, ): Record[] { // Editor answers are verbatim text, including Markdown and context-token sources. if (options?.literalText) { @@ -560,7 +565,8 @@ export function buildTiptapContent( // Code fences hold no chips, so their lines put the original source back in // place of the sentinel rather than building an atom for it. const atomSources: string[] = []; - const text = splitPromptIntoComposerSegments(value) + const literal = value.slice(0, options?.literalLength ?? 0); + const text = splitPromptIntoComposerSegments(value.slice(literal.length)) .map((segment) => { if (segment.type === "text") return segment.text; atoms.push(atomJsonForSegment(segment, skillLabelFor)); @@ -608,6 +614,14 @@ export function buildTiptapContent( for (let index = 0; index < sourceLines.length; index += 1) { const line = sourceLines[index]!; + // The rest of the literal's line continues the text before the caret, so + // it cannot open a block. + if (index === 0 && literal) { + entries.push({ + line: { list: null, inline: [textJsonForSpan(literal, []), ...buildInline(line)] }, + }); + continue; + } const opening = blockSyntax ? parseOpeningFence(line) : null; if (!opening) { // A thematic break outranks a list: `- - -` and `* * *` are rules, and From dd4549eede928f9dc514778aeae15d74656a87f8 Mon Sep 17 00:00:00 2001 From: Nikita Koynov <43469098+nkoynov@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:19:23 +0300 Subject: [PATCH 018/250] fix(server): settle every OpenCode subagent call one report answers (#17134) --- .../Adapters/OpenCode2AdapterV2.test.ts | 193 ++++++++++++++++++ .../src/server/v2/adapter.ts | 43 ++-- 2 files changed, 222 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 18041d9331aa..ef885e1b2ca6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -953,6 +953,199 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + /** A `subagent` call's result while its subagent runs on (`session.tool.success` is at version 2). */ + const returnedRunning = (id: string): ProviderReplayEntry => ({ + type: "emit_inbound", + frame: { + type: "sdk.event", + event: { + id: `evt_toolsuccess_${id}`, + created: 1, + type: "session.tool.success", + data: { + sessionID: SESSION, + assistantMessageID: "msg_assistant", + id, + content: [{ type: "text", text: "The subagent is working in the background." }], + metadata: { sessionID: CHILD, status: "running" }, + executed: true, + }, + durable: { aggregateID: SESSION, seq: 1, version: 2 }, + }, + }, + }); + /** The model calls the subagent again by its session; the tool returns while it runs on. */ + const callAgain = (input: Record): ReadonlyArray => { + const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" }; + return [ + event("session.tool.input.started", { ...tool, name: "subagent" }), + event("session.tool.called", { + ...tool, + name: "subagent", + input: { description: "Again", prompt: "also this", sessionID: CHILD, ...input }, + executed: false, + }), + event("session.tool.progress", { + ...tool, + metadata: { sessionID: CHILD, status: "running" }, + }), + // A session called again gets the thread's rules. + out("session.update", { sessionID: CHILD, permissions: "" }), + reply("session.update", null), + returnedRunning(tool.id), + ]; + }; + const childReport = (inboxID: string, text: string) => + event("session.inbox.enqueued", { + inboxID, + sessionID: SESSION, + item: { + type: "synthetic", + payload: { + text: `\n${text}\n`, + description: "Sleep", + metadata: { source: "subagent", childID: CHILD, agent: "General", state: "completed" }, + }, + delivery: "steer", + }, + }); + /** The subagent rows by title, the thread's background roster and the child's turns, as emitted. */ + const watchCalls = ( + runtime: ProviderAdapterV2SessionRuntime, + thread: OrchestrationV2ProviderThread, + ) => + Effect.gen(function* () { + const calls = new Map(); + const childTurns = new Map(); + const callsAtChildTurnStart = new Map< + string, + Record + >(); + const state: { roster: ReadonlyArray | undefined } = { roster: undefined }; + let wake = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.tap((event) => + Effect.gen(function* () { + if (event.type === "subagent.updated" && event.subagent.title !== null) { + calls.set(event.subagent.title, { + status: event.subagent.status, + result: event.subagent.result, + }); + } + if (event.type === "provider_thread.updated" && event.providerThread.id === thread.id) { + state.roster = event.providerThread.pendingBackgroundTasks; + } + const id = + event.type === "provider_turn.updated" + ? event.providerTurn.nativeTurnRef?.nativeId + : undefined; + if ( + event.type === "provider_turn.updated" && + id?.startsWith(`${CHILD}:turn:`) === true + ) { + if (!childTurns.has(id)) { + callsAtChildTurnStart.set(id, Object.fromEntries(calls)); + } + childTurns.set(id, event.providerTurn.status); + } + yield* Deferred.succeed(wake, undefined); + }), + ), + Stream.runDrain, + Effect.forkScoped, + ); + const until = (check: () => boolean): Effect.Effect => + Effect.suspend(() => { + if (check()) return Effect.void; + return Effect.gen(function* () { + wake = yield* Deferred.make(); + if (check()) return; + yield* Deferred.await(wake); + yield* until(check); + }); + }); + return { calls, callsAtChildTurnStart, childTurns, state, until }; + }); + const launchedAndRunning = [ + ...backgroundLaunch(CHILD), + returnedRunning("call-background"), + event("session.execution.started", { sessionID: CHILD }), + ]; + + it.effect("settles every call that joined a running subagent with its one report", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...launchedAndRunning, + // OpenCode steers the running subagent and joins its run: one report answers both calls. + ...callAgain({ background: true }), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD }), + childReport("msg_report", "CHILD_OK"), + ]); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => + watch.calls.get("Again")?.status === "completed" && + watch.calls.get("Sleep")?.status === "completed" && + watch.state.roster?.length === 0, + ); + assert.deepEqual(Object.fromEntries(watch.calls), { + Sleep: { status: "completed", result: "CHILD_OK" }, + Again: { status: "completed", result: "CHILD_OK" }, + }); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a foreground call that joined a background run for that run's report", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...launchedAndRunning, + // Without `background`, the call joins the run in the background all the same. + ...callAgain({}), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.succeeded", { sessionID: CHILD }), + childReport("msg_report", "CHILD_OK"), + ]); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => + watch.calls.get("Again")?.status === "completed" && + watch.calls.get("Sleep")?.status === "completed" && + watch.state.roster?.length === 0, + ); + // Not settled empty when the turn that made it ended. + assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "CHILD_OK" }); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a call to a finished subagent for its own run's report", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...launchedAndRunning, + event("session.execution.succeeded", { sessionID: CHILD }), + // Called again after it ended but before its report: the call starts a new run. + ...callAgain({ background: true }), + childReport("msg_report_1", "FIRST_OK"), + event("session.execution.started", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: CHILD }), + event("session.execution.succeeded", { sessionID: SESSION }), + childReport("msg_report_2", "SECOND_OK"), + ]); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + // The new run's turn starts after the first report was handled. + yield* watch.until(() => watch.childTurns.has(`${CHILD}:turn:2`)); + assert.deepEqual(watch.callsAtChildTurnStart.get(`${CHILD}:turn:2`), { + Sleep: { status: "completed", result: "FIRST_OK" }, + Again: { status: "running", result: null }, + }); + yield* watch.until(() => watch.calls.get("Again")?.status === "completed"); + assert.deepEqual(watch.calls.get("Again"), { status: "completed", result: "SECOND_OK" }); + }).pipe(Effect.scoped), + ); + it.effect("leaves no background work pending once a reconnect finds its subagent gone", () => Effect.gen(function* () { // The subagent ends while the stream is down: its end, its report and diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 86311cdafdd0..b23762fcc6e7 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -291,6 +291,8 @@ interface SubagentCall { model: string | null; /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ background: boolean; + /** The running call whose subagent run this call joined, so that run's one report settles both. */ + joined: SubagentCall | undefined; child: ThreadState | undefined; status: OrchestrationV2Subagent["status"]; result: string | null; @@ -1493,6 +1495,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // ran (each one's native id is its own), its grants and rules, and the // background work an earlier call left running, which Stop must reach. const previous = threads.get(childId); + // Called again while it runs, the subagent takes the prompt as a steer + // and OpenCode reports that run once, for every call that joined it. + if (previous?.active !== undefined) { + call.joined = [...call.state.calls.values()].findLast( + (candidate) => + candidate !== call && candidate.child === previous && candidate.joined === undefined, + ); + } const subagent = { call, appThread, @@ -2179,6 +2189,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr agent: undefined, model: null, background: false, + joined: undefined, child: undefined, status: "running", result: null, @@ -2244,8 +2255,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (call !== undefined) { const childId = stringField(event.data.metadata, "sessionID"); if (childId !== undefined) yield* attachChild(call, childId); - // A background call returns at launch; its report settles it. - if (event.data.metadata?.["status"] === "running") return; + // A background call returns at launch, and so does one that joined a + // background run; the report settles it. + if (event.data.metadata?.["status"] === "running") { + if (call.background) return; + call.background = true; + return yield* emitSubagent(call); + } return yield* settleCall(call, "completed", subagentOutput(textOf(event.data.content))); } const output = textOf(event.data.content); @@ -2383,6 +2399,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const call = [...state.calls.values()].find( (candidate) => candidate.child?.sessionId === childId, ); + const calls = [...state.calls.values()].filter( + (candidate) => candidate === call || (call !== undefined && candidate.joined === call), + ); const outcome = reportOutcome(stringField(payload.metadata, "state")); state.reports.set(inboxId, { inboxId, @@ -2395,18 +2414,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr outcome, }, }); - if (call === undefined) return; - yield* settleCall( - call, - state.stoppedChildren.has(childId) - ? "interrupted" - : outcome === "failed" - ? "failed" - : outcome === "cancelled" - ? "cancelled" - : "completed", - subagentOutput(payload.text), - ); + const status = state.stoppedChildren.has(childId) + ? "interrupted" + : outcome === "failed" + ? "failed" + : outcome === "cancelled" + ? "cancelled" + : "completed"; + for (const each of calls) yield* settleCall(each, status, subagentOutput(payload.text)); }); /** From 8bf0a38873aed486a585b2cebd8dd577ae5656b3 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:22:18 -0700 Subject: [PATCH 019/250] perf(web): switching project keeps Diagnostics and Providers mounted (#17122) --- .../settings/SettingsScopeSentence.tsx | 12 +- .../settings/settingsScopeRetention.test.tsx | 128 ++++++++++++++++++ apps/web/src/routes/settings.tsx | 11 +- 3 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/settings/settingsScopeRetention.test.tsx diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index 4223ebc90775..04312fd03142 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -152,7 +152,7 @@ function EnvironmentScopeMenu({ > {!singleEnvironment ? ( <> - + All environments @@ -163,7 +163,11 @@ function EnvironmentScopeMenu({ ) : null} {environments.map((environment) => ( - + - + All projects @@ -207,7 +211,7 @@ function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeMenuProps) { {groups.map((group) => ( - + {group.displayName} diff --git a/apps/web/src/components/settings/settingsScopeRetention.test.tsx b/apps/web/src/components/settings/settingsScopeRetention.test.tsx new file mode 100644 index 000000000000..a44d73e0fdae --- /dev/null +++ b/apps/web/src/components/settings/settingsScopeRetention.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { act, useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import type { SettingsScopeSearch } from "./settingsScope"; + +interface ScopeState { + readonly search: SettingsScopeSearch; + readonly environmentId: string | null; +} + +const state = vi.hoisted(() => ({ + pathname: "/settings/diagnostics", + scope: { search: {}, environmentId: null } as ScopeState, +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + createFileRoute: () => (options: { component: () => ReactNode }) => ({ + options, + useSearch: () => state.scope.search, + useNavigate: () => vi.fn(), + }), + useLocation: (options?: { select: (location: unknown) => unknown }) => { + const location = { pathname: state.pathname, hash: "", state: {} }; + return options ? options.select(location) : location; + }, + Outlet: () => , + redirect: vi.fn(), +})); +vi.mock("./SettingsScopeContext", async () => { + const { createContext, useContext } = await import("react"); + const Scope = createContext(state.scope); + return { + SettingsScopeProvider: ({ children }: { children: ReactNode }) => ( + {children} + ), + useSettingsScope: () => { + const { search, environmentId } = useContext(Scope); + return { + search, + scope: { kind: "all" }, + environment: environmentId ? { environmentId } : null, + connectedEnvironments: environmentId ? [{}] : [], + }; + }, + }; +}); +vi.mock("../../state/environments", () => ({ useEnvironments: () => ({ environments: [] }) })); +vi.mock("../ui/sidebar", () => ({ + SidebarInset: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../WorkspacePageHeader", () => ({ + WorkspacePageHeader: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("./settingsLayout", () => ({ + SettingsPageContainer: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("./SettingsBreadcrumb", () => ({ SettingsBreadcrumb: () => null })); +vi.mock("../sidebar/mainAppLocation", () => ({ useNavigateToMainApp: () => vi.fn() })); +vi.mock("../../hooks/useNavigateBack", () => ({ useEscapeToGoBack: () => undefined })); +vi.mock("./SettingsPanels", () => ({ useSettingsRestore: () => ({ changedSettingLabels: [] }) })); + +import { Route } from "../../routes/settings"; + +function StatefulContent() { + const [value, setValue] = useState(0); + return ( + + ); +} + +let root: Root; +let container: HTMLDivElement; +const Layout = Route.options.component!; +await Layout.preload?.(); + +async function render(scope: ScopeState) { + state.scope = scope; + await act(async () => root.render()); +} +function retainedValue() { + return container.querySelector('button[aria-label="Retained value"]')?.textContent ?? null; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +const first = { search: { machine: "first", project: "one" }, environmentId: "first" }; +const otherProject = { search: { machine: "first", project: "two" }, environmentId: "first" }; +const otherMachine = { search: { machine: "second", project: "one" }, environmentId: "second" }; +const implicitFirst = { search: { project: "one" }, environmentId: "first" }; +const implicitSecond = { search: { project: "one" }, environmentId: "second" }; + +const disconnected = { search: first.search, environmentId: null }; + +it.each([ + ["/settings/diagnostics", "another project", "1", first, otherProject], + ["/settings/providers", "another project", "1", first, otherProject], + ["/settings/diagnostics", "another environment", "0", first, otherMachine], + ["/settings/providers", "another environment", "0", first, otherMachine], + ["/settings/diagnostics", "an implicit environment change", "0", implicitFirst, implicitSecond], + ["/settings/providers", "an implicit environment change", "0", implicitFirst, implicitSecond], + ["/settings/diagnostics", "a disconnect", "0", first, disconnected], + ["/settings/keybindings", "another project", "0", first, otherProject], +])("%s after %s shows %s", async (pathname, _change, expected, before, after) => { + state.pathname = pathname; + await render(before); + await act(async () => + container.querySelector('button[aria-label="Retained value"]')?.click(), + ); + expect(retainedValue()).toBe("1"); + + await render(after); + + expect(retainedValue()).toBe(expected); +}); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 576307fbe7ad..a1e02c12d929 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -114,7 +114,11 @@ function SettingsContentLayout() { const location = useLocation(); const navigateToMainApp = useNavigateToMainApp(); useEscapeToGoBack(navigateToMainApp); - const { search } = useSettingsScope(); + const { search, environment } = useSettingsScope(); + const contentScopeKey = + location.pathname === "/settings/diagnostics" || location.pathname === "/settings/providers" + ? environment?.environmentId + : JSON.stringify(search); const [restoreSignal, setRestoreSignal] = useState(0); return ( @@ -133,10 +137,7 @@ function SettingsContentLayout() { -
+
From f7664fdf425555e0f85b74d904cfa4eaf57cf7af Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:23:40 -0700 Subject: [PATCH 020/250] perf(web): Open Source Licenses downloads its manifest once per session (#17119) --- .../settings/OpenSourceLicenses.tsx | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/settings/OpenSourceLicenses.tsx b/apps/web/src/components/settings/OpenSourceLicenses.tsx index 02a87caf7ac7..4005db89a35b 100644 --- a/apps/web/src/components/settings/OpenSourceLicenses.tsx +++ b/apps/web/src/components/settings/OpenSourceLicenses.tsx @@ -21,15 +21,35 @@ type LicenseManifestState = | { readonly status: "error"; readonly message: string } | { readonly status: "ready"; readonly manifest: ThirdPartyLicenseManifest }; -async function loadLicenseManifest(signal: AbortSignal): Promise { - const response = await fetch( - `${import.meta.env.BASE_URL.replace(/\/$/, "")}/third-party-licenses.json`, - { signal }, - ); - if (!response.ok) { - throw new Error(`The license manifest request failed with status ${String(response.status)}.`); +let licenseManifestPromise: Promise | undefined; + +async function fetchLicenseManifest(): Promise { + const signal = AbortSignal.timeout(30_000); + try { + const response = await fetch( + `${import.meta.env.BASE_URL.replace(/\/$/, "")}/third-party-licenses.json`, + { signal }, + ); + if (!response.ok) { + throw new Error( + `The license manifest request failed with status ${String(response.status)}.`, + ); + } + return decodeThirdPartyLicenseManifest((await response.json()) as unknown); + } catch (error) { + if (signal.aborted) { + throw new Error("The license manifest took too long to load.", { cause: error }); + } + throw error; } - return decodeThirdPartyLicenseManifest((await response.json()) as unknown); +} + +function loadLicenseManifest(): Promise { + licenseManifestPromise ??= fetchLicenseManifest().catch((error: unknown) => { + licenseManifestPromise = undefined; + throw error; + }); + return licenseManifestPromise; } function LicenseNoticeRow({ @@ -198,19 +218,23 @@ export function OpenSourceLicensesPanel() { const [requestVersion, setRequestVersion] = useState(0); useEffect(() => { - const controller = new AbortController(); + let cancelled = false; setState({ status: "loading" }); - void loadLicenseManifest(controller.signal).then( - (manifest) => setState({ status: "ready", manifest }), + void loadLicenseManifest().then( + (manifest) => { + if (!cancelled) setState({ status: "ready", manifest }); + }, (error: unknown) => { - if (controller.signal.aborted) return; + if (cancelled) return; setState({ status: "error", message: error instanceof Error ? error.message : "The license manifest could not load.", }); }, ); - return () => controller.abort(); + return () => { + cancelled = true; + }; }, [requestVersion]); const entries = state.status === "ready" ? state.manifest.entries : []; From 94d1a8e93f0434d34fa894523a04fe6a6f6172f6 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 07:51:50 -0500 Subject: [PATCH 021/250] fix(server): restore OpenCode adapter test typecheck (#17810) --- .../src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index ef885e1b2ca6..5c7161decd44 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1011,7 +1011,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }); /** The subagent rows by title, the thread's background roster and the child's turns, as emitted. */ const watchCalls = ( - runtime: ProviderAdapterV2SessionRuntime, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, thread: OrchestrationV2ProviderThread, ) => Effect.gen(function* () { From 98beed1a226c42b85c52ff5ee9d5dbb67d776a77 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:31:50 +0530 Subject: [PATCH 022/250] fix(server): Claude subagents show the reasoning effort they run at (#17496) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 373 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 105 ++++- .../ProviderEventIngestor.test.ts | 31 ++ .../orchestration-v2/ProviderEventIngestor.ts | 29 +- ...estratorReplayRecovery.integration.test.ts | 7 + .../output.ts | 3 +- .../claude_nested_subagent_model/output.ts | 11 +- 7 files changed, 530 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4f89f58b3315..10f6a8c7627e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -48,6 +48,7 @@ import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; @@ -73,6 +74,29 @@ const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ autoCompactWindow: "300000", }); +const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ + customModels: [ + // A bare custom slug that shadows the built-in "opus" alias. + "opus", + { + slug: "claude-custom-tuned", + name: "Tuned", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "gentle", label: "Gentle", isDefault: true }, + { id: "brutal", label: "Brutal" }, + ], + }, + ], + }, + }, + ], +}); const CLAUDE_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), model: "claude-sonnet-4-6", @@ -2107,6 +2131,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + readonly settings?: ClaudeSettings; // A CLI process opened after the first streams from its own queue, so the // first one can exit (Queue.shutdown) and a later turn can start another. readonly freshQueueOnReopen?: boolean; @@ -2141,7 +2166,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, + settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS, environment: options?.environment ?? {}, attachmentsDir, fileSystem, @@ -8139,18 +8164,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.take(harness.terminalReceipts); const subagents = harness.events.filter((event) => event.type === "subagent.updated"); + // Aliases and dated ids are recorded as the catalog slug. + const observedSlug = "claude-haiku-4-5"; const initialModel = source === "observed-before" - ? observedModel + ? observedSlug : source === "inherit" ? parentModel : source === "unknown" ? null - : "haiku"; + : "claude-haiku-5-5"; assert.equal(subagents[0]?.subagent.model, initialModel); assert.equal( subagents.at(-1)?.subagent.model, - source.startsWith("observed") ? observedModel : initialModel, + source.startsWith("observed") ? observedSlug : initialModel, ); const child = harness.events.find((event) => event.type === "app_thread.created"); assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); @@ -8162,6 +8189,344 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" }, + { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" }, + // claude-haiku-4-5 offers no effort, so none is sent or shown for it. + { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null }, + ])( + "records the effort a $agentModel subagent runs at (Agent effort $requested)", + ({ agentModel, slug, requested, expected }) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const toolUseId = "toolu-subagent-effort"; + const parentSelection = { + instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: false }, + ], + } satisfies ModelSelection; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-effort"), + text: "Spawn a subagent.", + attachments: [], + modelSelection: parentSelection, + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: parentSelection.model, + id: "msg_subagent_effort_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Agent", + input: { + description: "Research", + subagent_type: "general-purpose", + model: agentModel, + ...(requested === undefined ? {} : { effort: requested }), + prompt: "Research the topic.", + }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000211", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-subagent-effort", + tool_use_id: toolUseId, + description: "Research", + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000212", + session_id: WAKE_NATIVE_SESSION, + }), + // The reply names the model the alias resolved to. + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: toolUseId, + message: { + model: slug, + id: "msg_subagent_effort_observed", + type: "message", + role: "assistant", + content: [{ type: "text", text: "Researching." }], + }, + uuid: "00000000-0000-4000-8000-000000000213", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000214", + result: "Spawned the subagent.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + // The alias is stored as its slug, so the reply is no model change, + // and the parent's fast mode stays with the parent. + const subagentSelection = { + instanceId: parentSelection.instanceId, + model: slug, + ...(expected === null ? {} : { options: [{ id: "effort", value: expected }] }), + }; + const child = harness.events.find((event) => event.type === "app_thread.created"); + assert.deepEqual(child?.appThread.modelSelection, subagentSelection); + const subagents = harness.events.filter((event) => event.type === "subagent.updated"); + assert.deepEqual( + subagents.map((event) => event.subagent.modelSelection), + subagents.map(() => subagentSelection), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("resolves a subagent's model and effort against the instance's custom models", () => + Effect.scoped( + Effect.gen(function* () { + const TUNED_TOOL_USE_ID = "toolu-effort-custom-tuned"; + const SHADOW_TOOL_USE_ID = "toolu-effort-custom-shadow"; + const harness = yield* makeWakeHarnessWithOptions({ + settings: CUSTOM_MODEL_CLAUDE_SETTINGS, + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-custom-effort"), + text: "Spawn subagents on custom models.", + attachments: [], + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: CLAUDE_TEST_MODEL_SELECTION.model, + id: "msg_custom_effort_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: TUNED_TOOL_USE_ID, + name: "Agent", + input: { + description: "Tuned", + subagent_type: "general-purpose", + model: "claude-custom-tuned", + effort: "brutal", + prompt: "Check one thing.", + }, + }, + { + type: "tool_use", + id: SHADOW_TOOL_USE_ID, + name: "Agent", + input: { + description: "Shadow", + subagent_type: "general-purpose", + model: "opus", + prompt: "Check another thing.", + }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000231", + session_id: WAKE_NATIVE_SESSION, + }), + ...[TUNED_TOOL_USE_ID, SHADOW_TOOL_USE_ID].map((id, index) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: `task-${id}`, + tool_use_id: id, + description: id, + task_type: "local_agent", + uuid: `00000000-0000-4000-8000-00000000023${index + 2}`, + session_id: WAKE_NATIVE_SESSION, + }), + ), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000234", + result: "Spawned the subagents.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + const selections = Object.fromEntries( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent.modelSelection] as const] + : [], + ), + ); + const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId; + assert.deepEqual(selections, { + // The custom model's own effort levels apply. + [`task-${TUNED_TOOL_USE_ID}`]: { + instanceId, + model: "claude-custom-tuned", + options: [{ id: "effort", value: "brutal" }], + }, + // The custom slug is not resolved to the built-in model it shadows. + [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" }, + }); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TOOL_USE_ID = "toolu-effort-outer"; + const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; + const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const parentSelection = { + instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, + model: "claude-opus-4-6", + options: [{ id: "effort", value: "high" }], + } satisfies ModelSelection; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-nested-effort"), + text: "Spawn a subagent that spawns two more.", + attachments: [], + modelSelection: parentSelection, + }), + ); + const agentCall = (id: string, effort?: string) => ({ + type: "tool_use" as const, + id, + name: "Agent", + input: { + description: id, + subagent_type: "general-purpose", + ...(effort === undefined ? {} : { effort }), + prompt: "Check one thing.", + }, + }); + const taskStarted = (id: string, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: `task-${id}`, + tool_use_id: id, + description: id, + task_type: "local_agent", + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: parentSelection.model, + id: "msg_nested_effort_outer_launch", + type: "message", + role: "assistant", + content: [agentCall(OUTER_TOOL_USE_ID, "low")], + }, + uuid: "00000000-0000-4000-8000-000000000221", + session_id: WAKE_NATIVE_SESSION, + }), + taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"), + // The outer subagent's own Agent calls arrive in its snapshot. + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: OUTER_TOOL_USE_ID, + message: { + model: parentSelection.model, + id: "msg_nested_effort_inner_launch", + type: "message", + role: "assistant", + content: [ + agentCall(INHERITING_TOOL_USE_ID), + agentCall(OVERRIDING_TOOL_USE_ID, "max"), + ], + }, + uuid: "00000000-0000-4000-8000-000000000223", + session_id: WAKE_NATIVE_SESSION, + }), + taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000226", + result: "Spawned the subagents.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + const efforts = new Map( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [ + [ + event.subagent.nativeTaskRef?.nativeId, + getModelSelectionStringOptionValue(event.subagent.modelSelection, "effort"), + ] as const, + ] + : [], + ), + ); + assert.deepEqual(Object.fromEntries(efforts), { + [`task-${OUTER_TOOL_USE_ID}`]: "low", + [`task-${INHERITING_TOOL_USE_ID}`]: "low", + [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", + }); + const childEfforts = harness.events.flatMap((event) => + event.type === "app_thread.created" && event.appThread.id !== harness.threadId + ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] + : [], + ); + assert.deepEqual(childEfforts, ["low", "low", "max"]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("extracts text from direct content-block subagent results", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 79e46c25dc8c..caf63f2811b5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -99,6 +99,10 @@ import { import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindowTokens, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, } from "../../provider/ClaudeModelCatalog.ts"; import { boundProviderEventForLogging, @@ -2841,6 +2845,9 @@ interface ActiveClaudeSubagent { // The tool call that started the current run: the Agent launch, then each // SendMessage that resumes the subagent. A new one means a new prompt. readonly runToolUseId: string | null; + // The API effort the subagent runs at: its Agent call's, else its owner's, + // else the session's. Undefined when none is sent and the model default applies. + readonly effort: string | undefined; nextChildItemOrdinal: number; resultItemOrdinal: number | null; // The subagent's latest assistant message routed into its child thread, @@ -2893,10 +2900,42 @@ interface ActiveClaudeToolCall { // keyed by the tool_use_id that launches it. interface PendingClaudeSubagentLaunch { readonly model?: string; + readonly effort?: string; // parent_tool_use_id of the subagent whose own Agent call launches this one. readonly ownerToolUseId?: string; } +/** + * Agent calls name aliases ("opus") and replies name dated ids; both resolve + * to the catalog slug. A subagent's thread then keeps one model name across + * its launch, its replies and a resume after a restart, so a later report of + * the same model never reads as a model change that drops its effort. The + * catalog is the instance's, so a custom model keeps its own name. + */ +function canonicalClaudeSubagentModel(catalog: ClaudeModelCatalog, model: string): string { + return resolveClaudeModelSlug(catalog, model); +} + +/** + * The selection a subagent's thread shows. An effort its model does not offer + * is not sent for it, so it is left out (a nested subagent still inherits it). + */ +function claudeSubagentModelSelection( + catalog: ClaudeModelCatalog, + instanceId: ModelSelection["instanceId"], + model: string, + effort: string | undefined, +): ModelSelection { + const descriptor = getClaudeCatalogModelCapabilities(catalog, model).optionDescriptors?.find( + (candidate) => candidate.id === "effort", + ); + const offered = + effort !== undefined && + descriptor?.type === "select" && + descriptor.options.some((option) => option.id === effort); + return { instanceId, model, ...(offered ? { options: [{ id: "effort", value: effort }] } : {}) }; +} + const PENDING_CLAUDE_SUBAGENT_CAP = 64; // Per-subagent bound on frames held while waiting for task_started. const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; @@ -2917,11 +2956,13 @@ function rememberPendingClaudeSubagentLaunch( } /** - * Agent calls carry model overrides even when the SDK omits child assistant - * snapshots. A subagent's own Agent call arrives only in its snapshot, so the - * owner recorded here is all that links the subagent it starts back to it. + * Agent calls carry model and effort overrides even when the SDK omits child + * assistant snapshots. A subagent's own Agent call arrives only in its + * snapshot, so the owner recorded here is all that links the subagent it + * starts back to it. */ function rememberClaudeSubagentLaunch( + catalog: ClaudeModelCatalog, context: ActiveClaudeTurnContext, pending: Map, toolUseId: string, @@ -2934,17 +2975,23 @@ function rememberClaudeSubagentLaunch( // task_started resolves it. const model = requested !== "inherit" - ? requested + ? requested && canonicalClaudeSubagentModel(catalog, requested) : ownerToolUseId === null ? context.input.modelSelection.model : undefined; + const effort = firstStringInputField(input, ["effort"]); // A model already known (a snapshot's, or an earlier sighting of this // call) wins over the requested one. const launch: PendingClaudeSubagentLaunch = { ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }), + ...(effort === undefined ? {} : { effort }), ...(ownerToolUseId === null ? {} : { ownerToolUseId }), }; - if (launch.model !== undefined || launch.ownerToolUseId !== undefined) { + if ( + launch.model !== undefined || + launch.effort !== undefined || + launch.ownerToolUseId !== undefined + ) { rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch); } } @@ -3088,6 +3135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; + // Subagent model names and effort levels, including the instance's custom models. + const subagentModelCatalog = scopeClaudeModelCatalog( + BUNDLED_CLAUDE_MODEL_CATALOG, + adapterOptions.settings.customModels, + ); const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; @@ -4165,6 +4217,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( `task:${resume.taskId}:subagent`, ), runToolUseId: launchToolUseId, + // The launch's effort went with the old process. Without a task + // modelSelection the child thread keeps the one it was given. + effort: undefined, nextChildItemOrdinal: 100, resultItemOrdinal: null, lastAssistantText: null, @@ -4185,8 +4240,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly prompt?: string; readonly title?: string; readonly model?: string; - // The subagent whose own Agent call started this one; read only - // when this call registers the subagent. + // The Agent call's effort and the subagent whose own Agent call + // started this one; read only when this call registers the subagent. + readonly effort?: string; readonly owner?: ActiveClaudeSubagent; readonly progress?: string; readonly result?: string; @@ -4285,6 +4341,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task, ) : existingSubagent.task; + const effort = + existingSubagent === undefined + ? (input.effort ?? + input.owner?.effort ?? + compileClaudeModelSelection(input.context.input.modelSelection).effort) + : existingSubagent.effort; + const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { id: nodeId, @@ -4323,7 +4386,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : {}), ...(input.prompt === undefined ? {} : { prompt: input.prompt }), ...(input.title === undefined ? {} : { title: input.title }), - ...(input.model === undefined ? {} : { model: input.model }), + model, + // What the subagent runs with, unless it was recovered after a + // restart. The parent's other options belong to the parent's session. + ...(existingSubagent === undefined || existingSubagent.task.modelSelection !== undefined + ? { + modelSelection: claudeSubagentModelSelection( + subagentModelCatalog, + input.context.input.modelSelection.instanceId, + model || input.context.input.modelSelection.model, + effort, + ), + } + : {}), ...(input.progress === undefined ? {} : { progress: input.progress }), ...(input.result === undefined ? {} : { result: input.result }), ...(isReopen ? { startedAt: now } : {}), @@ -4341,6 +4416,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent === undefined ? (input.toolUseId ?? null) : (resumeToolUseId ?? existingSubagent.runToolUseId), + effort, nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, // Every task_started begins a new run of the subagent (including a @@ -4388,10 +4464,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( parentNodeId: nodeId, activeProviderThreadId: null, providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: - task.model && task.model !== input.context.input.modelSelection.model - ? { instanceId: input.context.input.modelSelection.instanceId, model: task.model } - : input.context.input.modelSelection, + modelSelection: task.modelSelection ?? input.context.input.modelSelection, title: subagentThreadTitle({ parentTitle: input.context.input.appThread.title, prompt: task.prompt, @@ -6194,7 +6267,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const parentToolUseId = message.parent_tool_use_id; const snapshotModel = typeof message.message.model === "string" ? message.message.model.trim() : ""; - const model = snapshotModel.length === 0 ? undefined : snapshotModel; + const model = + snapshotModel.length === 0 + ? undefined + : canonicalClaudeSubagentModel(subagentModelCatalog, snapshotModel); if (parentToolUseId !== null && model !== undefined) { const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); if (subagent === undefined) { @@ -6301,6 +6377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), ...(message.prompt === undefined ? {} : { prompt: message.prompt }), ...(model === undefined ? {} : { model }), + ...(launch?.effort === undefined ? {} : { effort: launch.effort }), ...(owner === undefined ? {} : { owner }), title: message.description, status: "running", @@ -6402,6 +6479,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); if (toolUse.name === "Agent") { rememberClaudeSubagentLaunch( + subagentModelCatalog, context, pendingSubagentLaunchesByToolUseId, toolUse.id, @@ -7000,6 +7078,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const heldForEcho = context.heldRootFrames.length > 0; if (toolName === "Agent") { rememberClaudeSubagentLaunch( + subagentModelCatalog, context, pendingSubagentLaunchesByToolUseId, nativeRequestId, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index d1b978e3862f..a6b7cc23d087 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1681,6 +1681,37 @@ layer("ProviderEventIngestorV2", (it) => { instanceId: modelSelection.instanceId, model: "gpt-6.1-sol", }); + + // A selection the provider reports, effort included, replaces the thread's. + const reportedSelection = { + instanceId: modelSelection.instanceId, + model: "gpt-6.1-sol", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const reported = yield* ingest({ + ...subagentUpdated, + subagent: { ...subagentUpdated.subagent, modelSelection: reportedSelection }, + }); + assert.deepEqual( + reported.map((stored) => stored.event.type), + ["subagent.updated", "thread.model-selection-updated"], + ); + assert.deepEqual( + (yield* projectionStore.getThread(childThreadId)).modelSelection, + reportedSelection, + ); + + // A subagent recovered after a restart reports its model but no + // selection; the same model keeps the effort the thread holds. + const recovered = yield* ingest(subagentUpdated); + assert.deepEqual( + recovered.map((stored) => stored.event.type), + ["subagent.updated"], + ); + assert.deepEqual( + (yield* projectionStore.getThread(childThreadId)).modelSelection, + reportedSelection, + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 0b4d66394e1c..308b8b210ceb 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { AgentScope } from "@t3tools/shared/AgentScope"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -402,18 +402,32 @@ export const layer: Layer.Layer< /** * A native subagent's thread starts on the parent's model when the * provider names the real one later (a Claude agent file's model arrives - * with the subagent's first reply). Clients read the thread's model, so - * move the thread to the reported one. Thread commands rewrite the whole - * thread row under the thread's lock, so this read and write take it too. + * with the subagent's first reply). Clients read the thread's model and + * effort, so move the thread to the reported selection, or to the reported + * model alone when the provider reports no selection. Thread commands + * rewrite the whole thread row under the thread's lock, so this read and + * write take it too. */ const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { const { childThreadId, model } = subagent; - if (subagent.origin !== "provider_native" || childThreadId === null || model === null) { + if ( + subagent.origin !== "provider_native" || + childThreadId === null || + (model === null && subagent.modelSelection === undefined) + ) { return []; } + const reportedSelection = ({ modelSelection }: { modelSelection: ModelSelection }) => + subagent.modelSelection ?? + (model === null || model === modelSelection.model + ? modelSelection + : // The parent's options belong to the parent's model. + { instanceId: modelSelection.instanceId, model }); const staleThread = projections.getThread(childThreadId).pipe( - Effect.map((thread) => (thread.modelSelection.model === model ? null : thread)), + Effect.map((thread) => + modelSelectionsEqual(thread.modelSelection, reportedSelection(thread)) ? null : thread, + ), Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), ); // Nearly every update already matches; only a mismatch takes the lock. @@ -427,10 +441,9 @@ export const layer: Layer.Layer< const event = yield* makeDomainEvent(input, { type: "thread.model-selection-updated", threadId: thread.id, - // The parent's options belong to the parent's model. payload: { ...thread, - modelSelection: { instanceId: thread.modelSelection.instanceId, model }, + modelSelection: reportedSelection(thread), updatedAt: now, }, occurredAt: now, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index b3c979715b85..6c1a6757058e 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -384,6 +384,13 @@ describe("orchestrator replay recovery", () => { assert.lengthOf(childThreads, 1); const child = childThreads[0]; assert.equal(child?.thread.id, subagent?.childThreadId); + // The recovered subagent's replies name the model its thread already + // holds, so the effort recorded at launch survives the restart. + assert.deepEqual(child?.thread.modelSelection, { + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + model: "claude-sonnet-5", + options: [{ id: "effort", value: "high" }], + }); const conversation = (child?.turnItems ?? []) .toSorted((left, right) => left.ordinal - right.ordinal) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index 4c91460bed69..fe993a3437f4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -22,7 +22,8 @@ import { const AGENT_A_TASK_ID = "a1a715b7d0bdfefea"; const AGENT_B_TASK_ID = "af44d5c14aa3ce867"; -const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5-20251001"; +// Its snapshots report claude-haiku-4-5-20251001, recorded as the catalog slug. +const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5"; function assistantTexts( projection: OrchestrationV2ThreadProjection, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts index bce49262e204..2826ce9bbf2d 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts @@ -12,9 +12,10 @@ import { const OUTER_TASK_ID = "a457ca677bc1ecd82"; const NESTED_TASK_ID = "aac1d7a2af5371c7d"; -// The outer subagent's snapshots report this id; the nested one never streams -// a snapshot of its own (the CLI's session storage shows it ran on the same). -const SUBAGENT_MODEL = "claude-haiku-4-5-20251001"; +// The outer subagent's snapshots report claude-haiku-4-5-20251001, recorded as +// the catalog slug; the nested one never streams a snapshot of its own (the +// CLI's session storage shows it ran on the same). +const SUBAGENT_MODEL = "claude-haiku-4-5"; export function assertClaudeNestedSubagentModelOutput( result: OrchestratorV2ScenarioResult, @@ -45,6 +46,10 @@ export function assertClaudeNestedSubagentModelOutput( // only that snapshot can say who launched it and on which model. assert.equal(nested.parentNodeId, outer.id, "a nested subagent hangs off its owner"); assert.equal(nested.model, SUBAGENT_MODEL, "a nested subagent runs on its owner's model"); + // The session's effort is not sent for claude-haiku-4-5, which offers none. + const subagentSelection = { instanceId: outer.providerInstanceId, model: SUBAGENT_MODEL }; + assert.deepEqual(outer.modelSelection, subagentSelection); + assert.deepEqual(nested.modelSelection, subagentSelection); const nestedNode = projection.nodes.find((node) => node.id === nested.id); assert.isDefined(nestedNode); assert.equal(nestedNode.parentNodeId, outer.id); From 8c777fbfade340f2092c011483c7fb0cde44c675 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 09:50:11 -0500 Subject: [PATCH 023/250] feat(server): stop Claude subagents without stopping their owner (#17826) --- .../Adapters/ClaudeAdapterV2.test.ts | 109 ++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 34 +++++ .../src/orchestration-v2/EffectOutbox.ts | 2 + .../src/orchestration-v2/EffectWorker.test.ts | 30 +++++ .../src/orchestration-v2/EffectWorker.ts | 19 +-- .../src/orchestration-v2/Orchestrator.ts | 90 ++++++++++++- .../ProviderTurnControlService.ts | 17 +++ .../src/orchestration-v2/ThreadStop.test.ts | 119 ++++++++++++++++++ .../testkit/OrchestratorScenario.ts | 1 + ...ThreadRelationshipsControl.agents.test.tsx | 27 +++- .../chat/ThreadRelationshipsControl.tsx | 31 ++++- .../src/operations/commands.test.ts | 55 ++++++++ .../client-runtime/src/operations/commands.ts | 14 +++ packages/contracts/src/orchestrationV2.ts | 8 ++ .../src/server/ProviderAdapter.ts | 5 + 15 files changed, 542 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 10f6a8c7627e..83d5b11aef6a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2130,6 +2130,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const makeWakeHarnessWithOptions = (options?: { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; + readonly stopTask?: (taskId: string) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; readonly settings?: ClaudeSettings; // A CLI process opened after the first streams from its own queue, so the @@ -2155,6 +2156,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { const permissionModeChanges: Array = []; const continuationRequests: Array = []; + const subagentReceipts = + yield* Queue.unbounded< + Extract + >(); const terminalReceipts = yield* Queue.unbounded< Extract @@ -2227,6 +2232,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.sync(() => { permissionModeChanges.push(mode); }), + ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; @@ -2253,6 +2259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.gen(function* () { events.push(event); + if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event); if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } @@ -2292,6 +2299,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { continuationRequests, events, terminalReceipts, + subagentReceipts, systemNoticeReceipts, getOpenedOptions: () => openedOptions, terminalEvents, @@ -8076,6 +8084,107 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([false, true])( + "stops one native subagent while its sibling keeps running, owner settled %s", + (ownerSettled) => + Effect.scoped( + Effect.gen(function* () { + const stopped: string[] = []; + let closes = 0; + const harness = yield* makeWakeHarnessWithOptions({ + stopTask: (taskId) => + Effect.sync(() => { + stopped.push(taskId); + }), + interrupt: Effect.die("A child stop must not interrupt its owner"), + close: () => + Effect.sync(() => { + closes++; + }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-native-child-stop"), + text: "Start two agents.", + attachments: [], + }), + ); + for (const [taskId, toolUseId, uuid] of [ + ["agent-stop", "toolu-stop", "00000000-0000-4000-8000-000000000801"], + ["agent-keep", "toolu-keep", "00000000-0000-4000-8000-000000000802"], + ]) { + yield* harness.offerAndWait( + makeSubagentTaskStartedFrame({ taskId: taskId!, toolUseId: toolUseId!, uuid: uuid! }), + ); + yield* Queue.take(harness.subagentReceipts); + } + if (ownerSettled) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000803", + result: "Agents are working.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + } + const stop = harness.runtime.stopSubagent; + assert.isDefined(stop); + yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" }); + assert.deepEqual(stopped, ["agent-stop"]); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeSubagentNotificationFrame({ + taskId: "agent-stop", + toolUseId: "toolu-stop", + summary: "Stopped by user", + uuid: "00000000-0000-4000-8000-000000000804", + }), + status: "stopped", + }), + ); + if (ownerSettled) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-native-child-stop-wake"), + text: "Background task stopped.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + const stoppedEvent = yield* Queue.take(harness.subagentReceipts); + assert.equal(stoppedEvent.subagent.status, "cancelled"); + const tasks = harness.events.filter((event) => event.type === "subagent.updated"); + assert.equal( + tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-stop") + ?.subagent.status, + "cancelled", + ); + assert.equal( + tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-keep") + ?.subagent.status, + "running", + ); + assert.lengthOf(harness.terminalEvents(), ownerSettled ? 1 : 0); + yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" }); + assert.deepEqual(stopped, ["agent-stop"]); + assert.equal(closes, 0); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect.each(["requested", "observed-before", "observed-after", "inherit", "unknown"] as const)( "records the subagent model from %s without inheriting the parent override", (source) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index caf63f2811b5..6251e698b2ad 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -342,6 +342,7 @@ export interface ClaudeAgentSdkQuerySession { readonly setPermissionMode: ( mode: PermissionMode, ) => Effect.Effect; + readonly stopTask?: (taskId: string) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -734,6 +735,11 @@ export const layerQueryRunner: Layer.Layer< }), ), ), + stopTask: (taskId) => + Effect.tryPromise({ + try: () => queryRuntime.stopTask(taskId), + catch: (cause) => queryRunnerError(cause, "stopTask"), + }), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -8092,6 +8098,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), steerTurn, interruptTurn, + stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { + const existing = yield* Ref.get(queryContext); + const nativeThreadId = input.providerThread.nativeThreadRef?.nativeId; + const subagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.nativeTaskId); + if (subagent === undefined || subagent.task.status !== "running") return; + if ( + existing === null || + existing.nativeThreadId !== nativeThreadId || + subagent.task.threadId !== input.providerThread.appThreadId || + existing.subagentsFromEarlierProcesses.has(subagent) || + existing.query.stopTask === undefined + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude subagent has no live owning query with task stopping support.", + }); + } + yield* existing.query.stopTask(input.nativeTaskId).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude native subagent stop failed.", + cause, + }), + ), + ); + }), respondToRuntimeRequest: Effect.fn("ClaudeAdapterV2.respondToRuntimeRequest")( function* (requestInput) { const pending = (yield* Ref.get(pendingRuntimeRequests)).get( diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index d9b21038cf71..0cb3de2e0012 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -3,6 +3,7 @@ import { CheckpointScopeId, CommandId, MessageId, + NodeId, ProviderSessionId, RunAttemptId, ProviderApprovalDecision, @@ -48,6 +49,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ providerSessionId: ProviderSessionId, providerThreadId: ProviderThreadId, providerTurnId: ProviderTurnId, + subagent: Schema.optional(Schema.Struct({ id: NodeId, nativeTaskId: Schema.String })), }), Schema.Struct({ type: Schema.Literal("provider-turn.steer"), diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index d9bd8689f092..230f4d4b7775 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import { CommandId, + NodeId, ProviderSessionId, ProviderThreadId, ProviderTurnId, @@ -837,3 +838,32 @@ it.effect("settles a delegated child once its restart continuation fails for goo }).pipe(Effect.provide(layer)); }), ); + +it.effect("a native subagent stop does not settle its owner's background work", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const layer = layerExecutorFor({ + events, + interrupt: (input) => { + assert.equal(input.subagent?.nativeTaskId, "native-task"); + return Ref.update(events, (current) => [...current, "stop-child"]); + }, + threads: { dispatch: () => Effect.die("The owner's background work must stay running") }, + }); + yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + yield* executor.execute({ + ...restartEffect(now, { type: "detach" }), + request: { + type: "provider-turn.interrupt", + providerSessionId: oldSessionId, + providerThreadId, + providerTurnId, + subagent: { id: NodeId.make("subagent"), nativeTaskId: "native-task" }, + }, + }); + }).pipe(Effect.provide(layer)); + assert.deepEqual(yield* Ref.get(events), ["stop-child"]); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 9ae94910b7c8..ddfc54b53320 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -169,6 +169,9 @@ export const layerExecutor: Layer.Layer< providerSessionId: effect.request.providerSessionId, providerThreadId: effect.request.providerThreadId, providerTurnId: effect.request.providerTurnId, + ...(effect.request.subagent === undefined + ? {} + : { subagent: effect.request.subagent }), }) .pipe( Effect.catch((cause) => @@ -185,13 +188,15 @@ export const layerExecutor: Layer.Layer< // One Stop can interrupt several provider threads, so the // settle is keyed by effect, not by the Stop command. Effect.andThen( - threads.dispatch({ - type: "thread.background-work.settle", - commandId: CommandId.make(`${effect.id}:background-work-settled`), - threadId: effect.threadId, - providerThreadId: effect.request.providerThreadId, - providerTurnId: effect.request.providerTurnId, - }), + effect.request.subagent !== undefined + ? Effect.void + : threads.dispatch({ + type: "thread.background-work.settle", + commandId: CommandId.make(`${effect.id}:background-work-settled`), + threadId: effect.threadId, + providerThreadId: effect.request.providerThreadId, + providerTurnId: effect.request.providerTurnId, + }), ), Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 94560f62f6f5..d1e1dd1e7a27 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -463,6 +463,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "prepared-run.progress": case "prepared-run.fail": case "prepared-run.retry": + case "subagent.stop": case "run.interrupt": case "queued-message.promote-to-steer": case "queue.resume": @@ -8821,6 +8822,89 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + const dispatchSubagentStop = ( + command: Extract, + events: Ref.Ref>, + effects: Ref.Ref>, + ) => + Effect.gen(function* () { + const projection = yield* loadProjectionForCommand(command, [ + "runs", + "subagents", + "providerThreads", + "providerTurns", + ]); + const run = projection.runs.find((candidate) => candidate.id === command.runId); + const subagent = projection.subagents.find( + (candidate) => candidate.id === command.subagentId, + ); + if ( + subagent === undefined || + subagent.runId !== command.runId || + subagent.origin !== "provider_native" || + subagent.driver !== "claudeAgent" || + subagent.nativeTaskRef?.strength !== "strong" || + subagent.nativeTaskRef.nativeId === null + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The requested native subagent cannot be stopped on this run.", + }); + } + if (!["pending", "running", "waiting"].includes(subagent.status)) return; + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run?.providerThreadId, + ); + const providerTurn = projection.providerTurns.findLast( + (candidate) => candidate.providerThreadId === providerThread?.id, + ); + if ( + providerThread?.providerSessionId == null || + providerTurn === undefined || + providerThread.driver !== subagent.driver || + providerThread.providerInstanceId !== subagent.providerInstanceId + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The requested native subagent has no owning provider session.", + }); + } + const providerSessionId = providerThread.providerSessionId; + const nativeTaskId = subagent.nativeTaskRef.nativeId; + const now = yield* DateTime.now; + // Record the request with its effect; the provider notification supplies the outcome. + yield* emit( + events, + command, + )({ + type: "subagent.updated", + threadId: command.threadId, + runId: command.runId, + nodeId: subagent.id, + providerInstanceId: subagent.providerInstanceId, + occurredAt: now, + payload: { ...subagent, updatedAt: now }, + }); + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:subagent.stop:${subagent.id}`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId, + providerThreadId: providerThread.id, + providerTurnId: providerTurn.id, + subagent: { id: subagent.id, nativeTaskId }, + }, + } satisfies PendingOrchestrationEffectV2, + ]); + return; + }); + const dispatchRunInterrupt = ( command: Extract, events: Ref.Ref>, @@ -10526,6 +10610,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.user-input.dismiss": yield* dispatchThreadUserInputDismiss(command, events, effects); break; + case "subagent.stop": + yield* dispatchSubagentStop(command, events, effects); + break; case "run.interrupt": cancelUnsettledEffects = yield* dispatchRunInterrupt(command, events, effects); // Stop also stops every delegated task under the thread once it commits. @@ -10739,7 +10826,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // its expected outcome, not a failure. planned.events.length > 0 || command.type === "thread.background-work.settle" || - command.type === "thread.stop" + command.type === "thread.stop" || + command.type === "subagent.stop" ? Effect.succeed(planned) : Effect.fail( new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 4421da12da6f..eaf1a9f940d0 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,6 +1,7 @@ import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { MessageId, + type NodeId, ProviderSessionId, ProviderThreadId, ProviderTurnId, @@ -43,6 +44,7 @@ const isProviderTurnControlError = Schema.is(ProviderTurnControlError); export interface ProviderTurnControlServiceV2Shape { readonly interrupt: (input: { + readonly subagent?: { readonly id: NodeId; readonly nativeTaskId: string }; readonly threadId: ThreadId; readonly providerSessionId: ProviderSessionId; readonly providerThreadId: ProviderThreadId; @@ -177,6 +179,21 @@ export const layer: Layer.Layer< ? loaded.session : yield* sessions.get(input.providerSessionId); if (Option.isNone(session)) return; + if (input.subagent !== undefined) { + if (session.value.stopSubagent === undefined) { + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "interrupt", + providerTurnId: input.providerTurnId, + cause: "The provider does not support stopping a native subagent.", + }); + } + yield* session.value.stopSubagent({ + providerThread: loaded.providerThread, + nativeTaskId: input.subagent.nativeTaskId, + }); + return; + } // A settled turn reaches its adapter too: only the adapter knows // whether it still runs work for the thread, and each one either // stops it or reports there is nothing left to stop. Background work diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 6c5874cb75f2..6b058f25a7e8 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -7,6 +7,7 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, ProviderTurnId, ThreadId, TurnItemId, @@ -20,6 +21,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts"; import * as Orchestrator from "./Orchestrator.ts"; @@ -582,3 +584,120 @@ it.effect("thread.stop marks a turn it cannot interrupt so a late agent watch is assert.deepEqual(yield* threadState(threadId), { runs: ["running"], watched: [] }); }).pipe(Effect.provide(layerTest)), ); + +it.effect( + "a native child stop persists only its targeted effect and leaves its owner's queue and watch alone", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:native-child-stop"); + yield* createWatchingThread(threadId, 20); + yield* send(threadId, "owner", "start_immediately"); + yield* delegate(threadId, "native-child-fixture"); + const before = yield* orchestrator.getThreadProjection(threadId); + const run = before.runs[0]!; + const now = yield* DateTime.now; + const subagent = { + ...before.subagents[0]!, + origin: "provider_native" as const, + driver: CLAUDE_PROVIDER, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + nativeTaskRef: { + driver: CLAUDE_PROVIDER, + nativeId: "claude-task", + strength: "strong" as const, + }, + status: "running" as const, + startedAt: now, + }; + yield* projections.apply({ + id: EventId.make("event:native-child-stop:agent"), + type: "subagent.updated", + threadId, + runId: run.id, + nodeId: subagent.id, + occurredAt: now, + payload: subagent, + }); + const providerThread = before.providerThreads.find( + (thread) => thread.id === run.providerThreadId, + )!; + yield* projections.apply({ + id: EventId.make("event:native-child-stop:provider-thread"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...providerThread, + driver: CLAUDE_PROVIDER, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: ProviderSessionId.make("session:native-child-stop"), + }, + }); + const providerTurnId = ProviderTurnId.make("turn:native-child-stop"); + yield* projections.apply({ + id: EventId.make("event:native-child-stop:provider-turn"), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId: providerThread.id, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + }); + // The owner has finished but its child and queued prompt remain. + yield* projections.apply({ + id: EventId.make("event:native-child-stop:owner-completed"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "completed", startedAt: now, completedAt: now }, + }); + yield* send(threadId, "queued", "queue_after_active"); + const stateBefore = yield* threadState(threadId); + const commandId = CommandId.make("stop-native-child"); + yield* orchestrator.dispatch({ + type: "subagent.stop", + commandId, + threadId, + runId: run.id, + subagentId: subagent.id, + }); + assert.deepEqual(yield* threadState(threadId), stateBefore); + const effects = yield* sql<{ payload_json: string }>` + SELECT payload_json FROM orchestration_v2_effect_outbox WHERE command_id = ${commandId} + `; + assert.equal(effects.length, 1); + assert.deepEqual(JSON.parse(effects[0]!.payload_json), { + type: "provider-turn.interrupt", + providerSessionId: "session:native-child-stop", + providerThreadId: providerThread.id, + providerTurnId, + subagent: { id: subagent.id, nativeTaskId: "claude-task" }, + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).subagents[0]?.status, + "running", + ); + const unrelated = yield* Effect.exit( + orchestrator.dispatch({ + type: "subagent.stop", + commandId: CommandId.make("stop-unknown-child"), + threadId, + runId: run.id, + subagentId: NodeId.make("unknown-child"), + }), + ); + assert.isTrue(Exit.isFailure(unrelated)); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 3d8ef589050e..c43c17e01294 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -166,6 +166,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray { state.projection = null; }); -it.each(["codex", "claudeAgent"])( - "stops only active app-owned %s subagents without opening their thread", - async (driver) => { +it.each([ + { driver: "codex", origin: "app_owned" }, + { driver: "claudeAgent", origin: "app_owned" }, + { driver: "claudeAgent", origin: "provider_native" }, +])( + "stops active $origin $driver subagents without opening their thread", + async ({ driver, origin }) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const parent = { id: "parent", @@ -70,7 +74,9 @@ it.each(["codex", "claudeAgent"])( const agent = { id: "agent", childThreadId: "child", - origin: "app_owned", + origin, + threadId: "parent", + nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" }, driver, providerInstanceId: "codex", title: "Worker", @@ -106,7 +112,10 @@ it.each(["codex", "claudeAgent"])( await act(async () => stopButton().props.onClick()); expect(state.command).toHaveBeenCalledWith({ environmentId: "test", - input: { threadId: "child" }, + input: + origin === "provider_native" + ? { threadId: "parent", subagentId: "agent" } + : { threadId: "child" }, }); expect(state.navigate).not.toHaveBeenCalled(); @@ -132,6 +141,12 @@ it.each(["codex", "claudeAgent"])( ); state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] }; await act(async () => renderer.update(cloneElement(panel))); + if (origin === "provider_native") { + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + continue; + } await act(async () => stopButton().props.onClick()); expect(state.command).toHaveBeenCalledTimes(1); expect(state.command).toHaveBeenLastCalledWith({ @@ -152,7 +167,7 @@ it.each(["codex", "claudeAgent"])( expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); state.projection = { ...projection, - subagents: [{ ...agent, origin: "provider_native", driver: "claudeAgent" }], + subagents: [{ ...agent, origin: "provider_native", driver: "codex" }], }; await act(async () => renderer.update(cloneElement(panel))); expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 4c637d13c05d..d7f6471eea3c 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -24,7 +24,12 @@ import { canDetachThreadProviderSession, resolveLatestMergeBackRun, } from "@t3tools/client-runtime/state/thread-workflows"; -import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationV2Subagent, + OrchestrationV2ThreadShell, + ThreadId, +} from "@t3tools/contracts"; import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; import { groupBy } from "effect/Array"; import * as DateTime from "effect/DateTime"; @@ -221,6 +226,10 @@ export function ThreadRelationshipsPanel(props: { subagent.childThreadId, { ...projectedSubagentsToRuntime([subagent])[0]!, + id: subagent.id, + threadId: subagent.threadId, + nativeTaskRef: subagent.nativeTaskRef, + nativeStatus: subagent.status, driver: subagent.driver, providerInstanceId: subagent.providerInstanceId, origin: subagent.origin, @@ -328,12 +337,18 @@ export function ThreadRelationshipsPanel(props: { setBusyAction(null); }; - const stopSubagent = async (childThreadId: ThreadId) => { + const stopSubagent = async ( + childThreadId: ThreadId, + agent: Pick, + ) => { if (stoppingThreadId !== null) return; setStoppingThreadId(childThreadId); const result = await interruptTurn({ environmentId: props.environmentId, - input: { threadId: childThreadId }, + input: + agent.origin === "provider_native" + ? { threadId: agent.threadId, subagentId: agent.id } + : { threadId: childThreadId }, }); setStoppingThreadId(null); if (result._tag === "Failure") { @@ -398,7 +413,13 @@ export function ThreadRelationshipsPanel(props: { ); const failed = status === "failed" || status === "error"; const canStop = - agent?.origin === "app_owned" && + agent && + (agent.origin === "app_owned" || + (agent.origin === "provider_native" && + agent.driver === "claudeAgent" && + agent.nativeTaskRef?.strength === "strong" && + agent.nativeTaskRef.nativeId !== null && + ["pending", "running", "waiting"].includes(agent.nativeStatus))) && agent.startedAt && ["pending", "running", "waiting"].includes(agent.status); const trailingVisibilityClass = canStop @@ -566,7 +587,7 @@ export function ThreadRelationshipsPanel(props: { tone="destructive" aria-label={`Stop subagent ${threadTitle}`} disabled={stoppingThreadId !== null} - onClick={() => void stopSubagent(threadId)} + onClick={() => void stopSubagent(threadId, agent)} /> } > diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 52e08fe6e74e..2c38e18f3e5c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -10,6 +10,7 @@ import { PlanId, ProjectId, ProviderInstanceId, + ProviderDriverKind, RunId, RuntimeRequestId, ThreadId, @@ -995,6 +996,60 @@ describe("V2 environment commands", () => { }).pipe(Effect.provide(layerTestCrypto)), ); + it.effect( + "a native subagent stop targets its owning run rather than holding the parent queue", + () => + Effect.gen(function* () { + const subagentId = NodeId.make("native-subagent"); + const runId = RunId.make("settled-owner-run"); + const commands: OrchestrationV2Command[] = []; + const supervisor = yield* makeSupervisor({ + commands, + projects: [], + projection: { + ...v2Projection, + subagents: [ + { + id: subagentId, + threadId: v2ThreadId, + runId, + parentNodeId: NodeId.make("owner-node"), + origin: "provider_native", + createdBy: "agent", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerThreadId: null, + childThreadId: ThreadId.make("native-child-thread"), + nativeTaskRef: null, + prompt: "Work", + title: null, + model: null, + result: null, + status: "running", + startedAt: v2Now, + completedAt: null, + updatedAt: v2Now, + }, + ], + }, + }); + yield* interruptThreadTurn({ + commandId: CommandId.make("native-stop"), + threadId: v2ThreadId, + subagentId, + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(commands).toEqual([ + { + type: "subagent.stop", + commandId: "native-stop", + threadId: v2ThreadId, + runId, + subagentId, + }, + ]); + }).pipe(Effect.provide(layerTestCrypto)), + ); + it.effect("interrupts a known run without fetching the full projection", () => Effect.gen(function* () { const commands: OrchestrationV2Command[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 84f4a2ad3cff..aec86014071e 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -10,6 +10,7 @@ import { type ChatAttachment, type MessageId, type ModelSelection, + type NodeId, type OrchestrationV2Command, type OrchestrationV2CreationSource, type PlanId, @@ -183,6 +184,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput { export interface InterruptThreadTurnInput extends ThreadCommandInput { readonly runId?: RunId; + readonly subagentId?: NodeId; /** Temporary caller compatibility while UI naming moves from turns to runs. */ readonly turnId?: string; } @@ -780,6 +782,18 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( input: InterruptThreadTurnInput, ) { + if (input.subagentId !== undefined) { + const projection = yield* getProjection(input.threadId); + const subagent = projection.subagents.find((agent) => agent.id === input.subagentId); + if (subagent?.runId == null) return { sequence: 0 }; + return yield* dispatch({ + type: "subagent.stop", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + runId: subagent.runId, + subagentId: subagent.id, + }); + } let runId = input.runId ?? (input.turnId as RunId | undefined); if (runId === undefined) { const projection = yield* getProjection(input.threadId); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 7aa8c74fd80a..865077ef9d2b 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2915,6 +2915,14 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, runId: RunId, }), + /** Stops one provider-native child while its owning run keeps working. */ + Schema.Struct({ + type: Schema.Literal("subagent.stop"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + subagentId: NodeId, + }), Schema.Struct({ type: Schema.Literal("run.interrupt"), commandId: CommandId, diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index ac5cd5fe8ab3..9094e8989fa4 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -598,6 +598,11 @@ export interface ProviderAdapterV2SessionRuntime { readonly interruptTurn: ( input: ProviderAdapterV2InterruptInput, ) => Effect.Effect; + /** Stops one native child through its owning runtime; the owner keeps running. */ + readonly stopSubagent?: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly nativeTaskId: string; + }) => Effect.Effect; /** * Lets a runtime shared by several app threads unload one provider thread's * native state (and its MCP servers) when that app thread detaches, while From afd98d7d1840ed76f57d3ece556257aebc00f1bc Mon Sep 17 00:00:00 2001 From: Krishna Vijay <228381532+im-kvijay@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:32:35 -0700 Subject: [PATCH 024/250] fix(server): stopped native subagents no longer read as Running (#17223) Co-authored-by: Claude Opus 5.5 Co-authored-by: Yash Singh --- ...skCancelNativeSubagent.integration.test.ts | 357 ++++++++++++++++++ .../BackgroundWorkStop.integration.test.ts | 144 ++++++- .../FoundationPersistence.test.ts | 12 +- .../src/orchestration-v2/Orchestrator.ts | 65 +++- .../src/orchestration-v2/ProjectionStore.ts | 18 +- .../ProviderRuntimeRecoveryService.test.ts | 2 + .../ProviderRuntimeRecoveryService.ts | 143 ++----- .../RestartContinuation.test.ts | 1 + .../RunExecutionService.test.ts | 131 +++++++ .../orchestration-v2/RunExecutionService.ts | 9 +- .../SubagentProjection.test.ts | 64 ++++ .../src/server/subagentProjection.ts | 151 ++++++++ 12 files changed, 973 insertions(+), 124 deletions(-) create mode 100644 apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts new file mode 100644 index 000000000000..04c862940ac8 --- /dev/null +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -0,0 +1,357 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EnvironmentId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as SecretRequests from "../secrets/SecretRequests.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; + +const driver = ProviderDriverKind.make("claudeAgent"); +const instanceId = ProviderInstanceId.make("claudeAgent"); +const modelSelection = { instanceId, model: "claude-test" }; + +// A delegated Claude child ends its turn while a background native subagent it +// started keeps running (#17154). Cancelling the task stops the child, which +// ends that subagent's process, so the task must end instead of waiting on it. +it.effect("task_cancel ends a delegated child's background native subagent", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("task-cancel-native-subagent"); + const turns: Array<{ + readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly events: Queue.Queue; + }> = []; + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: ClaudeProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: `native:${threadId}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + turns.push({ turn, events }); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: null, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.void, + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "task-cancel-native-subagent" }, + layerAdapters, + { runEffectWorker: false }, + ); + const layerOrchestration = Layer.merge( + layerOrchestrator, + ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)), + ); + const layerTest = OrchestratorMcpService.layer.pipe( + Layer.provideMerge(layerOrchestration), + Layer.provide( + Layer.mergeAll( + layerAdapters, + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + NodeServices.layer, + ), + ), + ); + + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const parentThreadId = ThreadId.make("thread:task-cancel-parent"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turnRunning = () => + watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:task-cancel"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const parentRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent"), + threadId: parentThreadId, + messageId: MessageId.make("message:start-parent"), + text: "Delegate a review", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(parentRunning); + + const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + const childRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the change", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + createdBy: "agent", + creationSource: "mcp", + }); + yield* worker.drain(); + yield* Fiber.join(childRunning); + const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; + const child = turns.find((entry) => entry.turn.threadId === task.childThreadId)!; + const childTurn = (yield* orchestrator.getThreadProjection(task.childThreadId!)) + .providerTurns[0]!; + + // The child starts a background native subagent, then ends its turn. + const now = yield* DateTime.now; + const subagentId = NodeId.make("node:native-subagent"); + const native = { + threadId: task.childThreadId!, + runId: child.turn.runId, + origin: "provider_native", + driver, + providerInstanceId: instanceId, + providerThreadId: childTurn.providerThreadId, + childThreadId: null, + prompt: "Check the tests", + title: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + } as const; + const waiting = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === child.turn.runId && + event.payload.status === "waiting", + ); + for (const event of [ + { + type: "node.updated", + driver, + node: { + id: subagentId, + threadId: native.threadId, + runId: native.runId, + parentNodeId: child.turn.rootNodeId, + rootNodeId: child.turn.rootNodeId, + kind: "subagent", + status: "running", + countsForRun: false, + providerThreadId: childTurn.providerThreadId, + providerTurnId: childTurn.id, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + type: "subagent.updated", + driver, + subagent: { + ...native, + id: subagentId, + parentNodeId: child.turn.rootNodeId, + createdBy: "agent", + nativeTaskRef: null, + model: null, + }, + }, + { + type: "turn_item.updated", + driver, + turnItem: { + ...native, + id: TurnItemId.make("turn-item:native-subagent"), + nodeId: subagentId, + providerTurnId: childTurn.id, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "subagent", + subagentId, + }, + }, + { + type: "provider_turn.updated", + driver, + providerTurn: { ...childTurn, status: "completed", completedAt: now }, + }, + { + type: "turn.terminal", + driver, + providerThreadId: childTurn.providerThreadId, + providerTurnId: childTurn.id, + runOrdinal: child.turn.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }, + ] satisfies ReadonlyArray) { + yield* Queue.offer(child.events, event); + } + yield* Fiber.join(waiting); + yield* worker.drain(); + + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:task-cancel"), + requestNamespace: "provider-session:task-cancel", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:task-cancel", + providerInstanceId: instanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const before = yield* service.taskStatus(scope, task.id); + assert.equal(before.status, "running"); + assert.equal(before.workState, "waiting_for_children"); + + const cancelled = yield* service.cancelTask(scope, { + taskId: task.id, + clientRequestId: "cancel-task", + }); + assert.equal(cancelled.status, "cancel_requested"); + yield* worker.drain(); + + const childAfter = yield* orchestrator.getThreadProjection(task.childThreadId!); + assert.equal( + childAfter.subagents.find((entry) => entry.id === subagentId)?.status, + "interrupted", + ); + assert.equal( + childAfter.nodes.find((entry) => entry.id === subagentId)?.status, + "interrupted", + ); + assert.equal( + childAfter.turnItems.find((item) => item.type === "subagent")?.status, + "interrupted", + ); + // The child's own turn had completed, so the task keeps that result. + const after = yield* service.taskStatus(scope, task.id); + assert.equal(after.status, "completed"); + assert.notEqual(after.workState, "waiting_for_children"); + }).pipe(Effect.provide(layerTest)); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index 6d5e0b92d87f..d1e62aad91bb 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -414,6 +414,113 @@ const stopEarlierBackgroundWork = ({ const codexProviderThread = (yield* orchestrator.getThreadProjection(threadId)) .providerThreads[0]!; + // A native subagent's own thread, still showing its runless root turn as working. + const subagentThread = (name: string) => + Effect.gen(function* () { + const subagentThreadId = ThreadId.make(`thread:${name}`); + const rootId = NodeId.make(`node:${name}-root`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${name}`), + threadId: subagentThreadId, + projectId: ProjectId.make("project:background-work-stop"), + title: name, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "agent", + creationSource: "provider", + }); + yield* sink.write({ + events: [ + { + id: EventId.make(`${name}-root`), + type: "node.updated", + threadId: subagentThreadId, + occurredAt: now, + payload: { + id: rootId, + threadId: subagentThreadId, + runId: null, + parentNodeId: null, + rootNodeId: rootId, + kind: "root_turn", + status: "running", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + ], + }); + return { threadId: subagentThreadId, rootId }; + }); + const { threadId: reviewerThreadId, rootId: reviewerRootId } = + yield* subagentThread("reviewer"); + // The reviewer starts a tester, which runs on a thread of its own. + const tester = yield* subagentThread("tester"); + const nestedSubagentId = NodeId.make("subagent:nested"); + const nestedItemId = TurnItemId.make("turn-item:nested-subagent"); + const nested = { + threadId: reviewerThreadId, + runId: null, + origin: "provider_native", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: tester.threadId, + prompt: "Check the tests", + title: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + } as const; + yield* sink.write({ + events: [ + // A subagent the reviewer started, which the provider records on the + // reviewer's own thread. + { + id: EventId.make("nested-subagent-item"), + type: "turn-item.updated", + threadId: reviewerThreadId, + occurredAt: now, + payload: { + ...nested, + id: nestedItemId, + nodeId: nestedSubagentId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "subagent", + subagentId: nestedSubagentId, + }, + }, + { + id: EventId.make("nested-subagent"), + type: "subagent.updated", + threadId: reviewerThreadId, + occurredAt: now, + payload: { + ...nested, + id: nestedSubagentId, + parentNodeId: reviewerRootId, + createdBy: "agent", + nativeTaskRef: null, + model: null, + }, + }, + ], + }); // A settled run with its root node, attempt, provider turn and, // optionally, a command or native subagent it left running. const settledRun = (input: { @@ -550,11 +657,35 @@ const stopEarlierBackgroundWork = ({ origin: "provider_native", driver, providerInstanceId: instanceId, - childThreadId: null, + childThreadId: reviewerThreadId, prompt: "Review the change", result: null, }, }); + if (item.kind === "subagent") { + events.push({ + id: EventId.make(`subagent:${input.ordinal}`), + type: "subagent.updated", + threadId, + runId, + occurredAt: now, + payload: { + ...base, + id: NodeId.make(`subagent:${input.ordinal}`), + parentNodeId: nodeId, + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: reviewerThreadId, + nativeTaskRef: null, + prompt: "Review the change", + model: null, + result: null, + }, + }); + } } return { runId, providerTurnId, events }; }; @@ -747,6 +878,17 @@ const stopEarlierBackgroundWork = ({ ? ["interrupted", "interrupted"] : ["interrupted", "interrupted", "interrupted"], ); + // The reviewer died with its provider process, so it no longer reads as running. + assert.equal(after.subagents[0]?.status, "interrupted"); + const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId); + assert.equal(reviewerThread.nodes[0]?.status, "interrupted"); + assert.equal(reviewerThread.subagents[0]?.status, "interrupted"); + assert.equal( + reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, + "interrupted", + ); + const testerThread = yield* orchestrator.getThreadProjection(tester.threadId); + assert.equal(testerThread.nodes[0]?.status, "interrupted"); }).pipe( Effect.provide( ProviderReplayHarness.layerWithRegistry( diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index 9a1e43a8b44b..f791977235c9 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -3052,9 +3052,9 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { completedAt: null, }); // The parent run settled while its background subagent kept working, - // then the server died. Recovery already cancels the parent's subagent - // item, entity, and node; the child's runless root turn lives on another - // thread and must be settled too. + // Stop ended the subagent's item, then the server died. Recovery cancels + // the parent's subagent entity and node; the child's runless root turn + // lives on another thread and must be settled too. yield* eventSink.commitCommand({ commandId: CommandId.make("command:foundation-native-subagent"), threadId: parentId, @@ -3186,7 +3186,7 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { parentItemId: null, ordinal: 1, type: "subagent", - status: "running", + status: "interrupted", title: null, startedAt: now, completedAt: null, @@ -3222,6 +3222,10 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { const parentProjection = yield* projectionStore.getThreadProjection(parentId); assert.equal(parentProjection.subagents[0]?.status, "cancelled"); + assert.equal( + parentProjection.nodes.find((node) => node.id === subagentId)?.status, + "cancelled", + ); const childProjection = yield* projectionStore.getThreadProjection(childId); const childRoot = childProjection.nodes.find((candidate) => candidate.id === childRootId); assert.equal(childRoot?.status, "cancelled"); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d1e1dd1e7a27..908fe0946774 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -117,6 +117,8 @@ import { ProviderSwitchServiceV2 } from "./ProviderSwitchService.ts"; import { isAutomaticCompletionRun, queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; import { + endOrphanedNativeSubagent, + endRunlessRootTurns, makeSubagentChildThread, subagentResultForRun, delegatedTaskProgress, @@ -8308,7 +8310,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly events: Ref.Ref>; readonly projection: Pick< OrchestrationV2ThreadProjection, - "runs" | "turnItems" | "providerThreads" + "runs" | "nodes" | "subagents" | "turnItems" | "providerThreads" >; readonly stoppedProviderThreadId: OrchestrationV2ProviderThread["id"] | null; readonly throughRunOrdinal: number; @@ -8317,6 +8319,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.gen(function* () { const emitEvent = emit(input.events, input.command); const runOrdinals = new Map(input.projection.runs.map((run) => [run.id, run.ordinal])); + const allocateEventId = () => + idAllocator.allocate.event({ + threadId: input.command.threadId, + commandId: input.command.commandId, + }); + const childThreadIds = new Set(); const liveness = new Map(); const hasLiveSession = (providerThreadId: OrchestrationV2ProviderThread["id"]) => Effect.gen(function* () { @@ -8366,6 +8374,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: input.now, }, }); + if (item.type !== "subagent") continue; + // A native subagent died with the process too: end its record, its own + // thread's runless root turn, and the subagents it started there, which + // a provider can record on that thread rather than this one. + const dying: Array<{ + readonly projection: Pick< + OrchestrationV2ThreadProjection, + "nodes" | "subagents" | "turnItems" + >; + readonly subagent: Extract; + }> = [{ projection: input.projection, subagent: item }]; + for (const { projection, subagent } of dying) { + const ended = yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.subagentId, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...ended]); + const childThreadId = subagent.childThreadId; + if ( + subagent.origin !== "provider_native" || + childThreadId === null || + childThreadIds.has(childThreadId) + ) { + continue; + } + childThreadIds.add(childThreadId); + const child = yield* projectionStore + .getThreadRecords(childThreadId, ["nodes", "subagents", "turnItems"], { + // Runless items are the subagent's; any run there is the user's own. + turnItemRunIds: [null], + turnItemStatuses: ["pending", "running", "waiting"], + }) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + mapDispatchError(input.command), + ); + if (child === null) continue; + const childEnded = yield* endRunlessRootTurns({ + threadId: childThreadId, + providerInstanceId: subagent.providerInstanceId, + projection: child, + status: "interrupted", + now: input.now, + emitted: yield* Ref.get(input.events), + allocateEventId, + }).pipe(mapDispatchError(input.command)); + yield* Ref.update(input.events, (events) => [...events, ...childEnded]); + for (const nested of child.turnItems) { + if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); + } + } } for (const providerThread of input.projection.providerThreads) { // A live process owns its roster and reports clearing it. diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 51343fd8af91..200f1548d700 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -589,6 +589,11 @@ function needsRecovery( (run.status === "queued" && run.queueHeld !== true), ) || projection.runtimeRequests.some((request) => request.status === "pending") || + projection.subagents.some( + (subagent) => + subagent.origin === "provider_native" && + ["pending", "running", "waiting"].includes(subagent.status), + ) || projection.providerSessions.some( (session) => session.status !== "stopped" && session.status !== "error", ) || @@ -3664,6 +3669,11 @@ export const layer: Layer.Layer = AND node.status IN ('pending', 'running', 'waiting') ) UNION + -- No native subagent outlives the provider process that ran it. + SELECT thread_id FROM orchestration_v2_projection_subagents + WHERE origin = 'provider_native' + AND status IN ('pending', 'running', 'waiting') + UNION SELECT item.thread_id FROM orchestration_v2_projection_turn_items AS item WHERE NOT EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs AS run @@ -4067,6 +4077,11 @@ export const layer: Layer.Layer = WHERE item.thread_id = ${threadId} AND item.type = 'subagent' AND item.status IN ('pending', 'running', 'waiting') ) + OR node.node_id IN ( + SELECT subagent_id FROM orchestration_v2_projection_subagents + WHERE thread_id = ${threadId} AND origin = 'provider_native' + AND status IN ('pending', 'running', 'waiting') + ) ) ORDER BY COALESCE(node.started_at, ''), node.node_id ASC `, @@ -4075,7 +4090,8 @@ export const layer: Layer.Layer = WHERE subagent.thread_id = ${threadId} AND subagent.status IN ('pending', 'starting', 'running', 'waiting') AND ( - subagent.run_id IN ( + subagent.origin = 'provider_native' + OR subagent.run_id IN ( SELECT run_id FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND status IN ('queued', 'preparing', 'starting', 'running', 'waiting') diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index da764d41d730..c8802a30f4d4 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -1227,6 +1227,7 @@ it.effect( { id: staleSubagentNodeId, runId: settledRunId, + origin: "provider_native", driver: ProviderDriverKind.make("claude"), providerInstanceId: claudeInstanceId, status: "running", @@ -1235,6 +1236,7 @@ it.effect( // Already finished with a real result: must never be overwritten. id: doneSubagentNodeId, runId: settledRunId, + origin: "provider_native", driver: ProviderDriverKind.make("claude"), providerInstanceId: claudeInstanceId, status: "completed", diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 7a063bec065b..b3c74621302e 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -22,6 +22,10 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import { restartContinuationRun } from "./RestartContinuation.ts"; +import { + endOrphanedNativeSubagent, + endRunlessRootTurns, +} from "@t3tools/provider-core/server/subagentProjection"; import { cancelledRosterTaskWork, cancelledTurnItemWork, @@ -88,10 +92,6 @@ function isNonterminalTurnItemStatus(status: string): boolean { return status === "pending" || status === "running" || status === "waiting"; } -function isNonterminalSubagentStatus(status: string): boolean { - return status === "pending" || status === "running" || status === "waiting"; -} - function isNonterminalNodeStatus(status: string): boolean { return status === "pending" || status === "running" || status === "waiting"; } @@ -362,26 +362,6 @@ export const make = Effect.gen(function* () { payload: { ...node, status: "cancelled", completedAt: now }, }); } - for (const subagent of projection.subagents.filter( - (candidate) => - candidate.runId === run.id && - !isAppOwnedDelegation(candidate) && - (candidate.status === "pending" || - candidate.status === "running" || - candidate.status === "waiting"), - )) { - events.push({ - id: yield* allocateEventId(), - type: "subagent.updated", - threadId: projection.thread.id, - runId: run.id, - nodeId: subagent.id, - driver: subagent.driver, - providerInstanceId: subagent.providerInstanceId, - occurredAt: now, - payload: { ...subagent, status: "cancelled", completedAt: now, updatedAt: now }, - }); - } for (const providerTurn of projection.providerTurns.filter( (candidate) => candidate.runAttemptId !== null && @@ -486,103 +466,34 @@ export const make = Effect.gen(function* () { }); } } - if (item.type !== "subagent") { - continue; - } - // Cancelling only the turn item would leave the linked subagent entity - // non-terminal forever, since the dead provider process can no longer - // emit its terminal event. Match the exact linked id so a subagent - // that already finished is never overwritten. - const staleSubagent = projection.subagents.find( - (candidate) => - candidate.id === item.subagentId && isNonterminalSubagentStatus(candidate.status), - ); - if (staleSubagent !== undefined) { - events.push({ - id: yield* allocateEventId(), - type: "subagent.updated", - threadId: projection.thread.id, - ...(item.runId === null ? {} : { runId: item.runId }), - nodeId: staleSubagent.id, - driver: staleSubagent.driver, - providerInstanceId: staleSubagent.providerInstanceId, - occurredAt: now, - payload: { ...staleSubagent, status: "cancelled", completedAt: now, updatedAt: now }, - }); - } - const staleSubagentNode = projection.nodes.find( - (candidate) => - candidate.id === item.subagentId && isNonterminalNodeStatus(candidate.status), + } + // No provider process outlives the server, so a native subagent still open + // here never reports its end, whatever state its run and item are in. + for (const subagent of projection.subagents ?? []) { + events.push( + ...(yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.id, + status: "cancelled", + now, + emitted: events, + allocateEventId, + })), ); - if (staleSubagentNode !== undefined && !cancelledStaleNodeIds.has(staleSubagentNode.id)) { - cancelledStaleNodeIds.add(staleSubagentNode.id); - events.push({ - id: yield* allocateEventId(), - type: "node.updated", - threadId: projection.thread.id, - ...(item.runId === null ? {} : { runId: item.runId }), - nodeId: staleSubagentNode.id, - providerInstanceId, - occurredAt: now, - payload: { ...staleSubagentNode, status: "cancelled", completedAt: now }, - }); - } } - // A provider-native subagent thread has no runs: its work is a runless - // root turn, plus items under it (Claude's live progress item), that - // only the dead provider process could settle. Left running, the child + // A provider-native subagent thread has no runs; left running, the child // would show as working forever. - const cancelledStaleItemIds = new Set( - events.flatMap((event) => (event.type === "turn-item.updated" ? [event.payload.id] : [])), - ); - for (const node of projection.nodes) { - if ( - node.kind !== "root_turn" || - node.runId !== null || - !isNonterminalNodeStatus(node.status) || - cancelledStaleNodeIds.has(node.id) - ) { - continue; - } - cancelledStaleNodeIds.add(node.id); - events.push({ - id: yield* allocateEventId(), - type: "node.updated", + events.push( + ...(yield* endRunlessRootTurns({ threadId: projection.thread.id, - nodeId: node.id, providerInstanceId: projection.thread.providerInstanceId, - occurredAt: now, - payload: { ...node, status: "cancelled", completedAt: now }, - }); - for (const item of projection.turnItems) { - if ( - item.nodeId !== node.id || - item.runId !== null || - !isNonterminalTurnItemStatus(item.status) || - cancelledStaleItemIds.has(item.id) - ) { - continue; - } - cancelledStaleItemIds.add(item.id); - events.push({ - id: yield* allocateEventId(), - type: "turn-item.updated", - threadId: projection.thread.id, - nodeId: node.id, - providerInstanceId: projection.thread.providerInstanceId, - occurredAt: now, - payload: { - ...item, - status: "cancelled", - completedAt: now, - updatedAt: now, - ...(item.type === "reasoning" || item.type === "assistant_message" - ? { streaming: false } - : {}), - }, - }); - } - } + projection, + status: "cancelled", + now, + emitted: events, + allocateEventId, + })), + ); // All provider processes are gone on startup/shutdown: clear any // persisted Waiting roster (including idle threads from settled roots) // and idle active threads without resurrecting active status. diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 34b3d8352c68..34b608f961e5 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -481,6 +481,7 @@ it.effect.each([ { id: "subagent:background", runId, + origin: "provider_native", driver, providerInstanceId: instanceId, status: "running", diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 708205663787..c42c402003d4 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1432,6 +1432,137 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", }), ); +it.effect("ends a nested native subagent and its thread when the root run is interrupted", () => + Effect.gen(function* () { + const key = "run-execution-nested-native"; + const threadId = ThreadId.make(`thread:${key}`); + const childThreadId = ThreadId.make(`thread:${key}:child`); + const grandchildThreadId = ThreadId.make(`thread:${key}:grandchild`); + const runId = RunId.make(`run:${key}`); + const attemptId = RunAttemptId.make(`attempt:${key}`); + const providerInstanceId = ProviderInstanceId.make("codex"); + const providerThreadId = ProviderThreadId.make(`provider-thread:${key}`); + const rootProviderTurnId = ProviderTurnId.make(`provider-turn:${key}`); + const nestedId = NodeId.make(`node:${key}:nested`); + const grandchildRootId = NodeId.make(`node:${key}:grandchild-root`); + const finalized = yield* Deferred.make>(); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), + Layer.mock(EventSink.EventSinkV2)({ + write: () => Effect.succeed([]), + writeWithEffects: (input) => + Effect.as( + input.events.some((event) => event.type === "run.updated") + ? Deferred.succeed(finalized, input.events) + : Effect.void, + [], + ), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + const subagentThread = (id: ThreadId, parentThreadId: ThreadId) => + ({ + type: "app_thread.created", + driver, + appThread: { + id, + lineage: { parentThreadId, relationshipToParent: "subagent", rootThreadId: threadId }, + }, + }) as ProviderAdapter.ProviderAdapterV2Event; + const events: ReadonlyArray = [ + subagentThread(childThreadId, threadId), + subagentThread(grandchildThreadId, childThreadId), + { + type: "subagent.updated", + driver, + subagent: { + id: nestedId, + threadId: childThreadId, + runId: null, + origin: "provider_native", + childThreadId: grandchildThreadId, + status: "running", + }, + } as ProviderAdapter.ProviderAdapterV2Event, + { + type: "node.updated", + driver, + node: { + id: grandchildRootId, + threadId: grandchildThreadId, + runId: null, + kind: "root_turn", + status: "running", + }, + } as ProviderAdapter.ProviderAdapterV2Event, + { + type: "turn.terminal", + driver, + providerThreadId, + providerTurnId: rootProviderTurnId, + runOrdinal: 1, + status: "interrupted", + failure: null, + threadDisposition: "reusable", + }, + ]; + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make(`command:${key}`), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make(`session:${key}`), + session: { + events: Stream.fromIterable(events), + startTurn: () => Effect.void, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run: { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run, + rootNode: { id: NodeId.make(`node:${key}`) } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make(`checkpoint-scope:${key}`), + } as OrchestrationV2CheckpointScope, + providerThread: { id: providerThreadId, driver } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: rootProviderTurnId } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make(`message:${key}:user`), + text: "Spawn a nested subagent.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, + }, + }); + }).pipe(Effect.provide(layerTest)); + + const ended = (yield* Deferred.await(finalized)).flatMap((event) => + event.type === "subagent.updated" || event.type === "node.updated" + ? [[event.type, event.payload.id, event.payload.status]] + : [], + ); + assert.deepInclude(ended, ["subagent.updated", nestedId, "interrupted"]); + assert.deepInclude(ended, ["node.updated", grandchildRootId, "interrupted"]); + }), +); + it.effect("keeps ingesting a late background command item completion after root terminal", () => Effect.gen(function* () { const observed = yield* runBackgroundItemScenario("bg-command", (ids) => [ diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a316ce0ccd62..fa6d0fbd7f12 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -1044,7 +1044,14 @@ export const layer: Layer.Layer< // Preserve childThreadId linkage for the root-run lifetime even // after the subagent row terminalizes, so open child-thread // nodes can still be proven linked on a later root interrupt. - if (belongsToRootRun) { + // A provider records nested native subagents on the child + // thread, runless; they die with this run too. + if ( + belongsToRootRun || + (belongsToOwnedChildThread && + event.subagent.runId === null && + event.subagent.origin === "provider_native") + ) { yield* Ref.update(openRunOwnedSubagents, (current) => { const withLink = withLinkedChildThreadId(current, event.subagent.childThreadId); const subagents = new Map(withLink.subagents); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 59eca69cf35a..65b64000b470 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -15,12 +15,15 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; import { makeSubagentChildThread, delegatedTaskProgress, subagentResultForRun, makeSubagentConversationArtifacts, + endOrphanedNativeSubagent, + endRunlessRootTurns, } from "@t3tools/provider-core/server/subagentProjection"; import { emptyProjection } from "./ProjectionStore.ts"; @@ -314,3 +317,64 @@ it("exposes the provider failure rather than a progress message from the failed assert.equal(result.turnItemId, artifacts.turnItem.id); assert.isNull(result.messageId); }); + +it.effect("ends an orphaned native subagent's node when only its record already ended", () => + Effect.gen(function* () { + const id = NodeId.make("node:orphaned-native-subagent"); + const subagent = { id, origin: "provider_native", status: "running", runId: null }; + const events = yield* endOrphanedNativeSubagent({ + projection: { + subagents: [subagent], + nodes: [{ id, status: "running", runId: null }], + turnItems: [], + } as never, + subagentId: id, + status: "cancelled", + now: yield* DateTime.now, + emitted: [ + { type: "subagent.updated", payload: { ...subagent, status: "cancelled" } }, + ] as never, + allocateEventId: () => Effect.succeed(EventId.make("event:orphaned-native-subagent")), + }); + assert.deepEqual( + events.map((event) => [event.type, event.type === "node.updated" && event.payload.status]), + [["node.updated", "cancelled"]], + ); + }), +); + +it.effect("ends a runless root turn's items when its node already ended", () => + Effect.gen(function* () { + const root = { + id: NodeId.make("node:runless-root"), + kind: "root_turn", + status: "running", + runId: null, + }; + const item = (id: string) => ({ id, nodeId: root.id, runId: null, status: "running" }); + const events = yield* endRunlessRootTurns({ + threadId: childThreadId, + providerInstanceId: parentProviderInstanceId, + projection: { + nodes: [root], + turnItems: [item("turn-item:command"), item("turn-item:reasoning")], + } as never, + status: "cancelled", + now: yield* DateTime.now, + emitted: [ + { + type: "turn-item.updated", + payload: { ...item("turn-item:command"), status: "cancelled" }, + }, + { type: "node.updated", payload: { ...root, status: "cancelled" } }, + ] as never, + allocateEventId: () => Effect.succeed(EventId.make("event:runless-root")), + }); + assert.deepEqual( + events.map( + (event) => `${event.type} ${event.type === "turn-item.updated" && event.payload.id}`, + ), + ["turn-item.updated turn-item:reasoning"], + ); + }), +); diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index 7ee8d7c1aa8d..1a6b31a886e5 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -1,4 +1,5 @@ import type { + EventId, MessageId, ModelSelection, NodeId, @@ -6,6 +7,7 @@ import type { OrchestrationV2Actor, OrchestrationV2ConversationMessage, OrchestrationV2CreationSource, + OrchestrationV2DomainEvent, OrchestrationV2ProviderRef, OrchestrationV2Run, OrchestrationV2ThreadProjection, @@ -18,6 +20,7 @@ import type { } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; function trimmed(value: string | null | undefined): string | undefined { @@ -257,3 +260,151 @@ export function delegatedTaskProgress(projection: { resultRun, }; } + +/** Ids that `emitted` already wrote with an event of `type`. */ +function writtenIds( + emitted: ReadonlyArray, + type: "subagent.updated" | "node.updated" | "turn-item.updated", +): ReadonlySet { + return new Set(emitted.flatMap((event) => (event.type === type ? [event.payload.id] : []))); +} + +/** + * Ends a provider-native subagent, its node and its turn item once the provider + * process that ran it is gone, since no terminal event will come from it. Each + * record is skipped if already settled or already ended by `emitted`. + */ +export const endOrphanedNativeSubagent = (input: { + readonly projection: Pick; + readonly subagentId: NodeId; + readonly status: "interrupted" | "cancelled"; + readonly now: DateTime.Utc; + readonly emitted: ReadonlyArray; + readonly allocateEventId: () => Effect.Effect; +}) => + Effect.gen(function* () { + const { now, status, subagentId } = input; + const subagent = input.projection.subagents.find((entry) => entry.id === subagentId); + if (subagent?.origin !== "provider_native") return []; + const node = input.projection.nodes.find((entry) => entry.id === subagentId); + const item = input.projection.turnItems.find( + (entry) => entry.type === "subagent" && entry.subagentId === subagentId, + ); + const scope = { + threadId: subagent.threadId, + ...(subagent.runId === null ? {} : { runId: subagent.runId }), + nodeId: subagentId, + providerInstanceId: subagent.providerInstanceId, + occurredAt: now, + }; + const events: Array = []; + if ( + isOrchestrationV2WorkActive(subagent.status) && + !writtenIds(input.emitted, "subagent.updated").has(subagentId) + ) { + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "subagent.updated", + driver: subagent.driver, + payload: { ...subagent, status, completedAt: now, updatedAt: now }, + }); + } + if ( + node !== undefined && + isOrchestrationV2WorkActive(node.status) && + !writtenIds(input.emitted, "node.updated").has(subagentId) + ) { + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "node.updated", + payload: { ...node, status, completedAt: now }, + }); + } + if ( + item !== undefined && + isOrchestrationV2WorkActive(item.status) && + !writtenIds(input.emitted, "turn-item.updated").has(item.id) + ) { + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "turn-item.updated", + payload: { ...item, status, completedAt: now, updatedAt: now }, + }); + } + return events; + }); + +/** + * Ends a provider-native subagent thread's work, a runless root turn plus the + * items under it (Claude's live progress), which only the provider process that + * ran it could settle. Skips records `emitted` already ended. + */ +export const endRunlessRootTurns = (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly projection: Pick; + readonly status: "interrupted" | "cancelled"; + readonly now: DateTime.Utc; + readonly emitted: ReadonlyArray; + readonly allocateEventId: () => Effect.Effect; +}) => + Effect.gen(function* () { + const { now, status } = input; + const endedNodeIds = writtenIds(input.emitted, "node.updated"); + const endedItemIds = writtenIds(input.emitted, "turn-item.updated"); + const scope = { + threadId: input.threadId, + providerInstanceId: input.providerInstanceId, + occurredAt: now, + }; + const events: Array = []; + for (const node of input.projection.nodes) { + if ( + node.kind !== "root_turn" || + node.runId !== null || + !isOrchestrationV2WorkActive(node.status) + ) { + continue; + } + // Recovery may have ended this node through one of its items; its other + // items still need ending. + if (!endedNodeIds.has(node.id)) { + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "node.updated", + nodeId: node.id, + payload: { ...node, status, completedAt: now }, + }); + } + for (const item of input.projection.turnItems) { + if ( + item.nodeId !== node.id || + item.runId !== null || + !isOrchestrationV2WorkActive(item.status) || + endedItemIds.has(item.id) + ) { + continue; + } + events.push({ + ...scope, + id: yield* input.allocateEventId(), + type: "turn-item.updated", + nodeId: node.id, + payload: { + ...item, + status, + completedAt: now, + updatedAt: now, + ...(item.type === "reasoning" || item.type === "assistant_message" + ? { streaming: false } + : {}), + }, + }); + } + } + return events; + }); From a11f464133291122e0f6381b35e1b245d0aa9c73 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 10 Oct 2026 10:37:40 -0500 Subject: [PATCH 025/250] perf(server): t3_thread_list reads only the listed project's threads (#17843) Co-authored-by: Claude Opus 5.5 (1M context) --- .../orchestration-v2/ProjectionStore.test.ts | 180 ++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 23 ++- .../ThreadManagementService.ts | 3 +- 3 files changed, 197 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index e8a3c1bcfc08..1008c4a2f58f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -349,6 +349,157 @@ it.effect("memory recovery selection includes unfinished items from missing runs }).pipe(Effect.provide(ProjectionStore.layerMemory)), ); +// Project A holds an active thread, an archived thread and a fork of a run in +// an archived project B thread, so a project-scoped snapshot still has to load +// a fork source that is outside the project and archived. +const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(function* ( + suffix: string, +) { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = ProjectId.make(`project:${suffix}:a`); + const otherProjectId = ProjectId.make(`project:${suffix}:b`); + const activeThreadId = ThreadId.make(`thread:${suffix}:a-active`); + const archivedThreadId = ThreadId.make(`thread:${suffix}:a-archived`); + const forkThreadId = ThreadId.make(`thread:${suffix}:a-fork`); + const sourceThreadId = ThreadId.make(`thread:${suffix}:b-source`); + const otherThreadId = ThreadId.make(`thread:${suffix}:b-other`); + const sourceRunId = RunId.make(`run:${suffix}:b-source`); + const sourceNodeId = NodeId.make(`node:${suffix}:b-source`); + const createThread = (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly archived?: boolean; + readonly forkedFromRunOf?: ThreadId; + }) => + projectionStore.apply({ + id: EventId.make(`event:${input.threadId}:created`), + type: "thread.created", + threadId: input.threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: input.threadId, + projectId: input.projectId, + title: input.threadId, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: + input.forkedFromRunOf === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: input.threadId } + : { + parentThreadId: input.forkedFromRunOf, + relationshipToParent: "fork", + rootThreadId: input.forkedFromRunOf, + }, + forkedFrom: + input.forkedFromRunOf === undefined + ? null + : { type: "run", threadId: input.forkedFromRunOf, runId: sourceRunId }, + createdAt: now, + updatedAt: now, + archivedAt: input.archived === true ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + + yield* createThread({ threadId: sourceThreadId, projectId: otherProjectId, archived: true }); + yield* createThread({ threadId: otherThreadId, projectId: otherProjectId }); + yield* createThread({ threadId: activeThreadId, projectId }); + yield* createThread({ threadId: archivedThreadId, projectId, archived: true }); + yield* createThread({ threadId: forkThreadId, projectId, forkedFromRunOf: sourceThreadId }); + yield* projectionStore.apply({ + id: EventId.make(`event:${suffix}:source-run`), + type: "run.created", + threadId: sourceThreadId, + runId: sourceRunId, + nodeId: sourceNodeId, + driver, + occurredAt: now, + payload: { + id: sourceRunId, + threadId: sourceThreadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${suffix}:source-user`), + rootNodeId: sourceNodeId, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:${suffix}:source-item`), + type: "turn-item.updated", + threadId: sourceThreadId, + runId: sourceRunId, + occurredAt: now, + payload: { + id: TurnItemId.make(`turn-item:${suffix}:source`), + threadId: sourceThreadId, + runId: sourceRunId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "assistant_message", + messageId: MessageId.make(`message:${suffix}:source-assistant`), + text: "Source answer", + streaming: false, + }, + }); + + const unscoped = yield* projectionStore.getShellSnapshot(); + const inProject = (thread: { readonly projectId: ProjectId }) => thread.projectId === projectId; + const active = yield* projectionStore.getShellSnapshot({ projectId, location: "active" }); + assert.deepEqual(active.threads, unscoped.threads.filter(inProject)); + assert.deepEqual(active.archivedThreads, []); + assert.sameMembers( + active.threads.map((thread) => thread.id), + [activeThreadId, forkThreadId], + ); + const fork = active.threads.find((thread) => thread.id === forkThreadId); + assert.isAbove(fork?.visibleItemCount ?? 0, 0); + + const all = yield* projectionStore.getShellSnapshot({ projectId }); + assert.deepEqual(all.threads, active.threads); + assert.deepEqual(all.archivedThreads, unscoped.archivedThreads.filter(inProject)); + assert.sameMembers( + all.archivedThreads.map((thread) => thread.id), + [archivedThreadId], + ); + return { projectId, activeThreadId, forkThreadId, otherThreadId }; +}); + +it.effect("memory shell snapshots scope to one project", () => + assertProjectScopedShells("memory-project-shell").pipe( + Effect.asVoid, + Effect.provide(ProjectionStore.layerMemory), + ), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { it.effect( "keeps restart-cancelled work through a stale run.updated", @@ -1884,6 +2035,35 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect("reads only the requested project's threads into a scoped shell snapshot", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const { projectId, activeThreadId, forkThreadId, otherThreadId } = + yield* assertProjectScopedShells("sql-project-shell"); + + // A thread in another project that cannot decode fails the unscoped + // snapshot, so a scoped snapshot that succeeds never read it. + const [stored] = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_threads + WHERE thread_id = ${otherThreadId} + `; + const setPayload = (payload: string) => + sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${payload} + WHERE thread_id = ${otherThreadId}`; + yield* setPayload("{}"); + const [unscoped, scoped] = yield* Effect.all([ + projectionStore.getShellSnapshot().pipe(Effect.flip), + projectionStore.getShellSnapshot({ projectId, location: "active" }), + ]).pipe(Effect.ensuring(Effect.orDie(setPayload(stored!.payload_json)))); + assert.strictEqual(unscoped._tag, "ProjectionStoreReadError"); + assert.sameMembers( + scoped.threads.map((thread) => thread.id), + [activeThreadId, forkThreadId], + ); + }), + ); + it.effect("does not treat visited or marked-unread state as thread activity", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 200f1548d700..a374cc55ac93 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -31,6 +31,7 @@ import type { OrchestrationV2ThreadShell, OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, + ProjectId, ProviderInstanceId, ProviderSessionId, ProviderThreadId, @@ -325,6 +326,11 @@ export interface ProjectionTimelinePage { export interface ShellSnapshotOptions { readonly location?: "active" | "archive"; + /** + * Reads only this project's threads. Fork sources in other projects still + * load, so visible item counts match the unscoped snapshot. + */ + readonly projectId?: ProjectId; /** * For background sweeps, not clients: skips settled threads before any of * their run, item or session rows are read. @@ -5148,8 +5154,7 @@ export const layer: Layer.Layer = const selectShellThreadRows = ( threadId?: ThreadId, - location?: "active" | "archive", - unsettledOnly = false, + { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, ) => sql` SELECT @@ -5325,6 +5330,8 @@ export const layer: Layer.Layer = LIMIT 1 ) AND blocked.status = 'failed' WHERE t.deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND t.thread_id = ${threadId}`}${ + projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}` + }${ location === "active" ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` : location === "archive" @@ -5748,11 +5755,7 @@ export const layer: Layer.Layer = const readShellSnapshotRows = (options: ShellSnapshotOptions | undefined) => Effect.gen(function* () { - const targetThreadRows = yield* selectShellThreadRows( - undefined, - options?.location, - options?.unsettledOnly ?? false, - ); + const targetThreadRows = yield* selectShellThreadRows(undefined, options); const targetThreadIds = new Set( targetThreadRows.map((row) => ThreadId.make(row.thread_id)), ); @@ -6008,6 +6011,12 @@ export const layerMemory: Layer.Layer = Layer.effect( const existing = (yield* Ref.get(replayState)).projections; const selectedThreadIds = [...existing.entries()] .filter(([, projection]) => { + if ( + options?.projectId !== undefined && + projection.thread.projectId !== options.projectId + ) { + return false; + } if ( options?.unsettledOnly && (projection.thread.settledAt !== null || diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a12e6680ca7e..3da3ef12438d 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -563,7 +563,7 @@ const make = Effect.gen(function* () { ); const listProjectThreads: ThreadManagementServiceShape["listProjectThreads"] = (input) => - orchestrator.getShellSnapshot().pipe( + orchestrator.getShellSnapshot({ projectId: input.projectId, location: "active" }).pipe( Effect.mapError( (cause) => new ThreadManagementProjectThreadsListError({ @@ -573,7 +573,6 @@ const make = Effect.gen(function* () { ), Effect.map((snapshot) => snapshot.threads - .filter((thread) => thread.projectId === input.projectId) .filter( (thread) => input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", From cfbd1457ad093b1cd7f6c53851009a62b676b203 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 13:25:00 -0300 Subject: [PATCH 026/250] fix(server): show diffs for projects outside the server cwd (#17724) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/git/GitManager.ts | 5 +- apps/server/src/review/ReviewService.test.ts | 56 ++++++++++++++++++++ apps/server/src/review/ReviewService.ts | 15 ++++++ apps/server/src/server.ts | 1 + apps/web/src/components/DiffPanel.tsx | 22 +------- 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c5d372f8fb2b..0eee415f19f9 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -765,7 +765,10 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); - if (!instructionPath.startsWith(`${root}${path.sep}`)) { + // A drive root such as `D:\` already ends with a separator, so compare + // with path.relative instead of a `${root}${sep}` prefix. + const relative = path.relative(root, instructionPath); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { return ""; } const info = yield* fileSystem.stat(instructionPath); diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 0802bac2d73a..384b8ad40d12 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -4,8 +4,10 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import { ProjectId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -17,8 +19,30 @@ function layer(input: { readonly detectCalls?: Array<{ readonly cwd: string }>; readonly worktreesDirectory?: string; readonly previousWorktreesDirectories?: ReadonlyArray; + readonly projectRoots?: ReadonlyArray | "unavailable"; }) { + const projectRoots = input.projectRoots ?? []; return ReviewService.layer.pipe( + Layer.provide( + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => + projectRoots === "unavailable" + ? Effect.fail( + new ProjectStore.ProjectStoreV2Error({ operation: "list", cause: "offline" }), + ) + : Effect.succeed( + projectRoots.map((workspaceRoot, index) => ({ + id: ProjectId.make(`project-${index}`), + title: `Project ${index}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + })), + ), + }), + ), Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ get: () => Effect.die("unexpected VCS registry get"), @@ -134,6 +158,38 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("allows registered project roots outside the server cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + // Stands in for a project on another drive than the server's home cwd. + const projectRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-project-" }); + const projectChild = `${projectRoot}/packages/app`; + yield* fs.makeDirectory(projectChild, { recursive: true }); + const sibling = `${projectRoot}-sibling`; + yield* fs.makeDirectory(sibling); + yield* Effect.addFinalizer(() => fs.remove(sibling, { recursive: true }).pipe(Effect.ignore)); + + const review = (cwd: string, projectRoots: ReadonlyArray | "unavailable") => + Effect.gen(function* () { + const service = yield* ReviewService.ReviewService; + return yield* service.getDiffPreview({ cwd }); + }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, projectRoots }))); + + assert.strictEqual((yield* review(projectRoot, [projectRoot])).cwd, projectRoot); + assert.strictEqual((yield* review(projectChild, [projectRoot])).cwd, projectChild); + for (const [cwd, projectRoots] of [ + [sibling, [projectRoot]], + [projectRoot, []], + [projectRoot, "unavailable"], + ] as const) { + const error = yield* review(cwd, projectRoots).pipe(Effect.flip); + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("allows diff preview cwd inside the configured workspace root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index d8e27c9d6dd7..1bde38b589b4 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -42,6 +43,7 @@ export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const settings = yield* ServerSettings.ServerSettingsService; + const projectStore = yield* ProjectStore.ProjectStoreV2; const canonicalizePath = (value: string) => { const resolvedPath = path.resolve(value); @@ -97,6 +99,19 @@ export const make = Effect.gen(function* () { return; } + // Registered projects can live outside the server cwd, which is the home + // directory in packaged desktop builds, e.g. a repository on another + // Windows drive. Unreadable or unresolvable project roots grant nothing. + const projects = yield* projectStore.listShells().pipe(Effect.orElseSucceed(() => [])); + for (const project of projects) { + const root = yield* canonicalizePath(project.workspaceRoot).pipe( + Effect.orElseSucceed(() => null), + ); + if (root !== null && isWithinRoot(candidate, root)) { + return; + } + } + return yield* new VcsRepositoryDetectionError({ operation, cwd, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3760500e4056..f1daa720016f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -374,6 +374,7 @@ const layerProjectCloneTracker = ProjectCloneTracker.layer.pipe( ); const layerReview = ReviewService.layer.pipe( + Layer.provide(ProjectStore.layer), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(layerVcsDriverRegistry), ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 633094738e9e..0c7a4a28988d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -339,7 +339,7 @@ export default function DiffPanel({ }, { enabled: isGitRepo && selectedTurn !== undefined }, ); - const primaryBranchDiffPreview = useEnvironmentQuery( + const branchDiffPreview = useEnvironmentQuery( canReadFiles && selectedRunId === null && activeThread && activeCwd ? reviewEnvironment.diffPreview({ environmentId: activeThread.environmentId, @@ -351,26 +351,6 @@ export default function DiffPanel({ }) : null, ); - const shouldRetryBranchDiffAtEnvironmentCwd = - selectedRunId === null && - primaryBranchDiffPreview.error?.includes("configured workspace root") === true && - serverConfig?.cwd !== undefined && - serverConfig.cwd !== activeCwd; - const fallbackBranchDiffPreview = useEnvironmentQuery( - canReadFiles && shouldRetryBranchDiffAtEnvironmentCwd && activeThread && serverConfig - ? reviewEnvironment.diffPreview({ - environmentId: activeThread.environmentId, - input: { - cwd: serverConfig.cwd, - ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), - ignoreWhitespace: diffIgnoreWhitespace, - }, - }) - : null, - ); - const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd - ? fallbackBranchDiffPreview - : primaryBranchDiffPreview; const canRefreshGitDiff = isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null; const activeThreadRefreshKey = routeThreadRef From 50647de0143e68fde051eb1a0ccf4fe63f9947b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 09:36:20 -0700 Subject: [PATCH 027/250] fix(server): check the specific scope for scripts, preview input, and full-access MCP grants (#17772) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/EnvironmentAuth.ts | 16 +++++++-- apps/server/src/auth/McpOAuth.test.ts | 9 +++++ apps/server/src/auth/RpcAuthorization.ts | 7 ++++ apps/server/src/preview/ServerBrowser.test.ts | 29 ++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 34 ++++++++++++++++--- .../src/preview/ServerBrowserStream.test.ts | 8 +++-- .../server/src/preview/ServerBrowserStream.ts | 8 ++--- apps/server/src/project/http.ts | 6 ++-- packages/contracts/src/settings.test.ts | 21 +++++++++++- packages/contracts/src/settings.ts | 20 ++++++++++- 10 files changed, 140 insertions(+), 18 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index ac90e67b6f26..d66cdbb62c36 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -4,6 +4,8 @@ import { AuthAdministrativeScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthSettingsWriteScope, + AuthSourceControlWriteScope, AuthStandardClientScopes, type AuthAccessTokenResult, type AuthBrowserSessionResult, @@ -74,7 +76,10 @@ export interface IssuedBearerSession { * 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. + * holds `orchestration:operate` and carries its runtime-mode ceiling. A + * full-access client may also change environment preferences and clone + * repositories, so it holds the scopes those need over RPC. Approving a grant + * requires every scope it holds. */ const MCP_CLIENT_SUBJECT = "mcp-client"; const MCP_CLIENT_SESSION_TTL = Duration.days(30); @@ -84,7 +89,14 @@ export const mcpClientScopes = ( ): ReadonlyArray => access === "read-only" ? [AuthOrchestrationReadScope] - : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; + : access === "full-access" + ? [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthSourceControlWriteScope, + ] + : [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]; export interface McpClientSession { readonly sessionId: AuthSessionId; diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 97e4a4d824ce..cb58294fb2bf 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -451,6 +451,15 @@ it.live( const readOnlyResponse = yield* approveWith(readOnly.credential); expect(readOnlyResponse.status).toBe(400); expect(readOnlyResponse.message).toContain("cannot grant this access"); + const operator = yield* auth.issuePairingCredential({ + scopes: ["orchestration:read", "orchestration:operate"], + }); + const operatorFull = yield* decide(handler, params, { + _tag: "pairing-code", + access: "full-access", + code: operator.credential, + }); + expect(operatorFull.status).toBe(400); // A read-only code can approve read-only access. const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 11756026f6d1..9c6243fde230 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -8,6 +8,8 @@ import { ServerSettingsPatch, ProviderInstanceMutation, requiredScopesForServerSettingsPatch, + requiredScopesForProjectMutation, + ProjectMutation, AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, @@ -242,6 +244,8 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { : [...new Set([...scopes, AuthProvidersManageScope])]; }; +const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); + const requiredScopesForRpcCall = ( method: string, payload: unknown, @@ -260,6 +264,9 @@ const requiredScopesForRpcCall = ( ]; } if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); + if (method === WS_METHODS.projectsMutate) { + return requiredScopesForProjectMutation(decodeProjectMutation(payload)); + } const guarded = clientRpcRequiredScopes(method, payload); if (guarded.length > 0) return guarded; return [requiredScopeForRpcMethod(method)]; diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index f66e255c51b4..385223a42d39 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1126,6 +1126,35 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); +it.live("only the current controller can answer a file picker", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const first = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "takeControl" }); + const setFiles = vi.fn(async (_files: unknown) => {}); + contexts[0]!.page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ getAttribute: async () => "" }), + setFiles, + }); + let offered = yield* Queue.take(first.output); + while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output); + const second = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* first.input({ type: "releaseControl" }); + yield* second.input({ type: "takeControl" }); + const answered = yield* browser.answerFileChooser({ + threadId: scope.thread.threadId, + tabId, + chooserId: offered.id, + files: [{ name: "a.txt", mimeType: "text/plain", buffer: Buffer.from("a") }], + }); + expect(answered).toBe(false); + expect(setFiles).not.toHaveBeenCalled(); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 072a022f90c0..939d112c4908 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -310,11 +310,15 @@ interface ServerTab { readonly openerTabId: string | undefined; /** Finished downloads, newest last; files live until the tab closes. */ readonly downloads: Array; - /** A page's open file picker, waiting for the controlling viewer's files. */ + /** + * A page's open file picker, waiting for the controlling viewer's files. Its + * id is reissued for each viewer it is offered to, so only that viewer can answer. + */ fileChooser: { readonly id: string; readonly chooser: FileChooser; readonly accept: string; + readonly offeredTo: string | null; } | null; dialog: Dialog | null; setting: PreviewViewportSetting; @@ -1027,14 +1031,27 @@ const make = Effect.gen(function* () { .catch(() => null)) ?? ""; // A newer picker replaces an unanswered one, as a real browser allows only one. if (previous) closeFileChooser(tab); - tab.fileChooser = { id: NodeCrypto.randomUUID(), chooser, accept: accept.slice(0, 1024) }; + tab.fileChooser = { + id: NodeCrypto.randomUUID(), + chooser, + accept: accept.slice(0, 1024), + offeredTo: null, + }; pushFileChooser(tab); }; const pushFileChooser = (tab: ServerTab) => { - const message = fileChooserMessage(tab); + const open = tab.fileChooser; const controller = [...tab.viewers].find((viewer) => viewer.id === tab.control.controller); - if (message) controller?.push(message); + if (!open || !controller) return; + if (open.offeredTo !== controller.id) { + // A new controller gets a fresh id; the previous one can no longer answer. + tab.fileChooser = { ...open, id: NodeCrypto.randomUUID(), offeredTo: controller.id }; + if (open.offeredTo !== null) + for (const viewer of tab.viewers) viewer.push({ _tag: "fileChooserClosed", id: open.id }); + } + const message = fileChooserMessage(tab); + if (message) controller.push(message); }; const closeFileChooser = (tab: ServerTab) => { @@ -1050,7 +1067,14 @@ const make = Effect.gen(function* () { ) => { const tab = tabs.get(tabKey(input.threadId, input.tabId)); const open = tab?.fileChooser; - if (!tab || !open || open.id !== input.chooserId) return false; + if ( + !tab || + !open || + open.id !== input.chooserId || + open.offeredTo === null || + open.offeredTo !== tab.control.controller + ) + return false; if (input.files.length > 0) { await open.chooser.setFiles( open.chooser.isMultiple() ? [...input.files] : input.files.slice(0, 1), diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 0654ea62a2b8..8fc153d9e2f5 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -5,6 +5,7 @@ import { expect, it } from "@effect/vitest"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, AuthSessionId, PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, PreviewStreamHostSetup, @@ -71,7 +72,7 @@ it.effect.each([ Effect.gen(function* () { const canOperate = hasOperateScope && interactive; const scopes = hasOperateScope - ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] + ? [AuthOrchestrationReadScope, AuthPreviewOperateScope] : [AuthOrchestrationReadScope]; const auth = makeAuth(scopes); const inputs: unknown[] = []; @@ -293,11 +294,12 @@ it.effect("passes uploaded files to the page's open picker and needs operate sco }); expect((yield* upload([AuthOrchestrationReadScope], "chooser-1")).status).toBe(403); expect(answers).toEqual([]); - expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(204); + expect((yield* upload([AuthOrchestrationOperateScope], "chooser-1")).status).toBe(403); + expect((yield* upload([AuthPreviewOperateScope], "chooser-1")).status).toBe(204); expect(answers).toEqual([ { chooserId: "chooser-1", files: [{ name: "notes.txt", text: "hello" }] }, ]); - expect((yield* upload([AuthOrchestrationOperateScope], "stale")).status).toBe(409); + expect((yield* upload([AuthPreviewOperateScope], "stale")).status).toBe(409); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index 964f143f5ef6..b2960c2401ef 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -1,7 +1,7 @@ import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import { - AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthPreviewOperateScope, PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE, PreviewStreamHostSetup, } from "@t3tools/contracts"; @@ -71,9 +71,9 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) => } const session = yield* authenticateMediaRequest(AuthOrchestrationReadScope); const params = url.value.searchParams; + // Watching needs read access; driving the page is a preview mutation. const canOperate = - session.scopes.includes(AuthOrchestrationOperateScope) && - params.get("interactive") !== "false"; + session.scopes.includes(AuthPreviewOperateScope) && params.get("interactive") !== "false"; const threadId = params.get("threadId") ?? ""; const tabId = params.get("tabId") ?? ""; if (threadId.length === 0 || tabId.length === 0) { @@ -238,7 +238,7 @@ const UPLOAD_MAX_FILES = 20; */ const receiveUpload = (browser: ServerBrowser.ServerBrowser["Service"], params: URLSearchParams) => Effect.gen(function* () { - yield* authenticateMediaRequest(AuthOrchestrationOperateScope); + yield* authenticateMediaRequest(AuthPreviewOperateScope); const request = yield* HttpServerRequest.HttpServerRequest; const fs = yield* FileSystem.FileSystem; const parts = yield* request.multipart.pipe( diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index ed383d95b98a..e1a04655d835 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -1,7 +1,7 @@ import { - AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + requiredScopesForProjectMutation, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -53,7 +53,9 @@ export const layer = HttpApiBuilder.group( "mutate", Effect.fn("environment.projects.mutate")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); - yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + for (const scope of requiredScopesForProjectMutation(args.payload)) { + yield* requireEnvironmentScope(scope); + } const operation = projectMutationOperation(projects, args.payload); return yield* startup .enqueueCommand(operation) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b5d39323a23d..b9446389d083 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import type { ProjectId } from "./baseSchemas.ts"; +import type { CommandId, ProjectId } from "./baseSchemas.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, + requiredScopesForProjectMutation, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -1068,3 +1069,21 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => { ).toBe(true); }); }); + +describe("requiredScopesForProjectMutation", () => { + it("adds the settings scope only when a mutation carries scripts", () => { + const projectId = "project-1" as ProjectId; + const commandId = "command-1" as CommandId; + expect( + requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }), + ).toEqual(["orchestration:operate"]); + expect( + requiredScopesForProjectMutation({ + type: "project.update", + commandId, + projectId, + scripts: [], + }), + ).toEqual(["orchestration:operate", "settings:write"]); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0038ede86c2d..0e5be96e740a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,5 +1,6 @@ import { SshDeviceHostConfigs } from "./device.ts"; import { + AuthOrchestrationOperateScope, AuthSettingsWriteScope, AuthProvidersManageScope, type AuthEnvironmentScope, @@ -27,7 +28,7 @@ import { ProviderOptionSelections, } from "./model.ts"; import { ModelSelection } from "./modelSelection.ts"; -import { ProjectScript } from "./project.ts"; +import { ProjectScript, type ProjectMutation } from "./project.ts"; import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { @@ -1583,6 +1584,23 @@ export function requiredScopesForServerSettingsPatch( ]; } +/** + * Project scripts run on the host when a worktree is created or a thread + * settles, so a project mutation that carries them needs the same grant as + * saving project scripts through settings, on top of `orchestration:operate`. + */ +export function requiredScopesForProjectMutation( + mutation: ProjectMutation, +): ReadonlyArray { + if (mutation.type === "project.delete" || mutation.scripts === undefined) { + return [AuthOrchestrationOperateScope]; + } + return [ + AuthOrchestrationOperateScope, + ...requiredScopesForServerSettingsPatch({ defaultProjectScripts: mutation.scripts }), + ]; +} + export const ClientSettingsPatch = Schema.Struct({ notificationMode: Schema.optionalKey(NotificationMode), inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), From dacd2cb649eea06a00244bd181eb400735e923d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFs=20Postula?= Date: Sat, 10 Oct 2026 20:14:09 +0200 Subject: [PATCH 028/250] fix(source-control): stop Forgejo status refresh from scanning every pull request (#12223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge Signed-off-by: Loïs Postula --- .../src/server/ForgejoSourceControlProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index fbcc5fbee99d..ab20a027f777 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -286,7 +286,7 @@ export const make = Effect.gen(function* () { const items = yield* request( { ...input, - path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`, + path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&head=${encodeURIComponent(branch)}&sort=recentupdate&limit=50&page=${page}`, }, Schema.Array(ForgejoPullRequestSchema), ); From fd3549c8ffa5eac8230a8b5e1a87e59d28ba488e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:06:59 -0700 Subject: [PATCH 029/250] refactor(contracts): source control provider kind is an open branded slug (#17739) Co-authored-by: Claude Opus 5.5 --- .../features/projects/AddProjectScreen.tsx | 5 +- apps/mobile/src/state/use-thread-pr.test.ts | 3 +- apps/server/src/git/GitManager.test.ts | 20 +- apps/server/src/git/GitManager.ts | 9 +- .../PullRequestSyncReactor.test.ts | 7 +- .../ThreadSettlementService.test.ts | 3 +- .../src/orchestration-v2/runtimeLayer.test.ts | 7 +- .../src/project/ProjectCloneTracker.test.ts | 5 +- .../server/src/project/ProjectCloneTracker.ts | 3 +- .../RepositoryIdentityResolver.test.ts | 3 +- .../PullRequestProviderRateLimit.test.ts | 3 +- .../pullRequest/PullRequestService.test.ts | 533 ++++++++++-------- .../src/pullRequest/PullRequestService.ts | 11 +- .../src/pullRequest/pullRequestSyncKey.ts | 5 +- .../src/sourceControl/BitbucketApi.test.ts | 11 +- .../SourceControlDiscovery.test.ts | 31 +- .../SourceControlProviderRegistry.test.ts | 11 +- .../SourceControlProviderRegistry.ts | 9 +- .../SourceControlRepositoryService.test.ts | 41 +- .../SourceControlRepositoryService.ts | 35 +- .../textGeneration/ThreadTitleLinks.test.ts | 3 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 3 +- apps/web/src/components/CommandPalette.tsx | 17 +- .../GitActionsControl.logic.test.ts | 3 +- apps/web/src/components/GitActionsControl.tsx | 24 +- .../components/ThreadStatusIndicators.test.ts | 5 +- .../src/components/ThreadStatusIndicators.tsx | 3 +- .../PullRequestDetailPanel.test.tsx | 3 +- .../PullRequestSummaryTab.test.tsx | 3 +- .../pullRequestDetail.logic.test.ts | 20 +- .../pullRequest/pullRequestDetail.logic.ts | 14 +- .../pullRequest/pullRequestList.logic.test.ts | 5 +- .../settings/SourceControlSettings.tsx | 2 +- apps/web/src/hooks/useNewProject.ts | 3 +- apps/web/src/state/pullRequests.test.ts | 3 +- .../src/state/sourceControlActions.test.ts | 3 +- apps/web/src/state/sourceControlActions.ts | 3 +- .../src/operations/projects.test.ts | 11 +- .../client-runtime/src/operations/projects.ts | 14 +- .../src/state/sourceControl.test.ts | 7 +- packages/contracts/src/pullRequest.test.ts | 17 +- packages/contracts/src/pullRequest.ts | 84 +-- packages/contracts/src/sourceControl.ts | 20 +- packages/shared/src/git.test.ts | 3 +- packages/shared/src/sourceControl.test.ts | 27 +- packages/shared/src/sourceControl.ts | 27 +- .../server/AzureDevOpsPullRequestProvider.ts | 9 +- .../AzureDevOpsSourceControlProvider.test.ts | 3 +- .../AzureDevOpsSourceControlProvider.ts | 21 +- .../src/server/driver.ts | 3 +- .../server/BitbucketPullRequestProvider.ts | 7 +- .../BitbucketSourceControlProvider.test.ts | 3 +- .../server/BitbucketSourceControlProvider.ts | 21 +- .../src/server/driver.ts | 3 +- .../src/server/SourceControlRateLimit.test.ts | 21 +- .../src/server/ForgejoPullRequestProvider.ts | 7 +- .../ForgejoSourceControlProvider.test.ts | 33 +- .../server/ForgejoSourceControlProvider.ts | 32 +- .../src/server/driver.ts | 3 +- .../src/server/forgejoPullRequests.ts | 3 +- .../src/server/GitCafePullRequestProvider.ts | 7 +- .../GitCafeSourceControlProvider.test.ts | 13 +- .../server/GitCafeSourceControlProvider.ts | 9 +- .../src/server/driver.ts | 3 +- .../src/server/GitHubApi.ts | 3 +- .../src/server/GitHubPullRequestProvider.ts | 5 +- .../src/server/GitHubQuota.ts | 3 +- .../GitHubSourceControlProvider.test.ts | 15 +- .../src/server/GitHubSourceControlProvider.ts | 15 +- .../src/server/driver.ts | 3 +- .../src/server/GitLabPullRequestProvider.ts | 5 +- .../GitLabSourceControlProvider.test.ts | 5 +- .../src/server/GitLabSourceControlProvider.ts | 27 +- .../src/server/driver.ts | 3 +- 74 files changed, 816 insertions(+), 551 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index aa7ccd9058d5..2f58bae6c4d0 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { MaterialListRow } from "../../components/MaterialListRow"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; @@ -812,7 +813,7 @@ export function AddProjectRepositoryScreen(props: { const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { - provider, + provider: SourceControlProviderKind.make(provider), repository: repositoryInput.trim(), }, }); @@ -1079,7 +1080,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s environmentId: environment.environmentId, input: { cwd: workspaceRoot, - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), visibility: "private", }, diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 75ec6743ac59..a1f05736907d 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -28,7 +29,7 @@ describe("presentThreadPr", () => { it("uses merge-request terminology for GitLab", () => { expect( presentThreadPr(pullRequest, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: "GitLab", baseUrl: "https://gitlab.com", }), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 79053fbb65eb..cd176d8f938a 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -410,7 +411,13 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { const ghCalls: string[] = []; const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => - new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); + new SourceControlProviderFailure({ + provider: SourceControlProviderKind.make("github"), + operation, + cwd, + detail, + cause, + }); /** `operation` is the provider method the call answers, which a failure reports. */ const execute = (input: { @@ -534,7 +541,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { /** What GitHubSourceControlProvider makes of a pull request it read. */ const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ ...summary, - provider: "github", + provider: SourceControlProviderKind.make("github"), state: summary.state ?? "open", closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, @@ -546,7 +553,7 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { - kind: "github", + kind: SourceControlProviderKind.make("github"), // The GitHub provider's own lookup rule and template convention, which GitManager reads // instead of the kind. headBranchProbe: ({ headSelectors }) => ({ @@ -602,7 +609,10 @@ function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { if (raw.length === 0) return []; const decoded = decodeGitHubPullRequestListJson(raw); return Result.isSuccess(decoded) - ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) + ? decoded.success.map((record) => ({ + provider: SourceControlProviderKind.make("github"), + ...record, + })) : []; }), ), @@ -2841,7 +2851,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { operation: "lookupStatusPr", branch: "feature/status-rate-limited", errorTag: "SourceControlProviderError", - provider: "github", + provider: SourceControlProviderKind.make("github"), providerOperation: "listChangeRequests", errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0eee415f19f9..e2ae3109b2fe 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -1787,7 +1788,9 @@ export const make = Effect.gen(function* () { ) { const terms = yield* sourceControlProvider(cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + Effect.orElseSucceed(() => + getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), + ), ); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; @@ -2853,7 +2856,9 @@ export const make = Effect.gen(function* () { const changeRequestTerms = wantsPr ? yield* sourceControlProvider(input.cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), + Effect.orElseSucceed(() => + getChangeRequestTerminologyForKind(SourceControlProviderKind.make("unknown")), + ), ) : null; diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 19e67f949b61..25a643a9655c 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Stream from "effect/Stream"; import { EventId, @@ -148,7 +149,7 @@ function makeSummary( overrides: Partial = {}, ): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: input.projectId, repository: input.repository, number: input.number, @@ -1009,7 +1010,7 @@ describe("PullRequestSyncReactor", () => { Effect.gen(function* () { if (input.host === "github.com" && (yield* Clock.currentTimeMillis) < retryAt) { const paused = new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestSummary", reason: "rate-limited", detail: "paused", @@ -1079,7 +1080,7 @@ describe("PullRequestSyncReactor", () => { operation: "stack", detail: "paused", cause: new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestStack", reason: "rate-limited", detail: "paused", diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 00daff5a8c87..ccf74f7d193e 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import { @@ -472,7 +473,7 @@ function makePullRequestSummary(input: { readonly updatedAt?: string; }): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: input.projectId, repository: input.repository, number: input.number, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 42c47bba51c0..4d3ff429f0c6 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -169,7 +170,7 @@ const watchedPullRequestDetail = (input: { readonly number: number; readonly at: string; }): PullRequestDetail => ({ - provider: "github", + provider: SourceControlProviderKind.make("github"), capabilities: { diff: true, comment: true, @@ -2572,7 +2573,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { operation: "getChangeRequest", detail: "github requests are paused until the rate limit resets", cause: new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequest", reason: "rate-limited", detail: "paused", @@ -2831,7 +2832,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { operation: "watchFingerprint", detail: "paused", cause: new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestWatchFingerprint", reason: "rate-limited", detail: "paused", diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index d26a2ed712ac..d327ca4436f0 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { OrchestrationDispatchCommandError, @@ -116,7 +117,7 @@ describe("ProjectCloneTracker", () => { ? Effect.fail( new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "fatal: repository not found", }), ) @@ -255,7 +256,7 @@ describe("ProjectCloneTracker", () => { Effect.fail( new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path already exists and is not empty.", }), ), diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 98c7b348d299..092a0376942b 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { ProjectCloneSnapshot, ProjectCloneStage, @@ -301,7 +302,7 @@ export const make = Effect.gen(function* () { if (!claimed) { return yield* new SourceControlRepositoryError({ operation: "cloneRepository", - provider: input.provider ?? "unknown", + provider: input.provider ?? SourceControlProviderKind.make("unknown"), detail: "A clone into this destination is already in progress.", }); } diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index ef5eaf4389dd..404b3c5b5b65 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -71,7 +72,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { if (refinementFails) return Effect.fail( new SourceControlProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation: "detectProvider", cwd: rootPath, detail: "account unavailable", diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 8271c2f22dc0..38969188dcd2 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; @@ -59,7 +60,7 @@ it("keeps GitHub's exact retry time", () => { assert.deepStrictEqual( gitHubProviderFailure( new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", retryAt: 1_786_802_400_000, }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 117fd3f69a32..cb544d4898ac 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; @@ -17,7 +18,6 @@ import type { ProjectId, PullRequestReviewCapabilities, PullRequestReviewerCapabilities, - SourceControlProviderKind, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import type { ServerSettings } from "@t3tools/contracts"; @@ -129,7 +129,7 @@ it.effect("caches narrow previews and invalidates them after refresh or mutation const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.die("preview must not read the viewer"), getChangeRequestPreview: () => Effect.sync(() => { @@ -175,13 +175,13 @@ it.effect("keeps cached previews available and pauses uncached previews until qu const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestPreview: ({ number }) => Effect.gen(function* () { reads++; if (reads === 2) return yield* new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestPreview", reason: "rate-limited", detail: "GitHub requests are paused until the rate limit resets.", @@ -218,7 +218,7 @@ it.effect("reuses only unexpired detail for previews", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), getChangeRequestPreview: () => Effect.sync(() => { @@ -250,7 +250,7 @@ it.effect("shares detail and activity for a minute, and for ten once merged", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: ({ number }) => Effect.sync(() => { reads.push(`detail #${number}`); @@ -314,7 +314,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Deferred.succeed(detailStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseDetail)), @@ -342,7 +342,7 @@ it.effect("keeps previews warm when another project finishes a turn", () => project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestPreview: (input) => Effect.sync(() => { reads.push(input.repository); @@ -374,11 +374,11 @@ it.effect("uses full detail for hosts without a narrow preview", () => title: "web", workspaceRoot: "/w", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { getChangeRequest: () => Effect.sync(() => { reads += 1; @@ -405,7 +405,7 @@ function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | } const requestFailed = new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "listChangeRequests", reason: "failed", detail: "HTTP 404", @@ -530,12 +530,12 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos title: "enterprise", workspaceRoot: "/repo", repository: "team/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { /** Supplies a PR only after verifying discovery retained the Enterprise repository target. */ listChangeRequests: ({ host, repository }) => { assert.strictEqual(host, "code.example.test"); @@ -554,7 +554,11 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos return Effect.succeed({ context: { ...context, - provider: { ...context.provider, kind: "github", name: "GitHub Self-Hosted" }, + provider: { + ...context.provider, + kind: SourceControlProviderKind.make("github"), + name: "GitHub Self-Hosted", + }, }, provider: undefined as never, }); @@ -576,7 +580,7 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ title: "self-hosted", workspaceRoot: "/gitlab", repository: "group/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }); const service = yield* makeService({ @@ -584,12 +588,15 @@ it.effect("refines unknown self-hosted GitLab projects before listing merge requ selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, ], - providers: [fakeProvider("gitlab")], + providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ context }) => { refinementCalls += 1; assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); return Effect.succeed({ - context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + context: { + ...context!, + provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, + }, provider: undefined as never, }); }, @@ -610,7 +617,7 @@ it.effect("derives a legacy repository host after refining its provider", () => title: "legacy self-hosted", workspaceRoot: "/gitlab", repository: "group/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }); const identity = current.repositoryIdentity!; @@ -625,10 +632,13 @@ it.effect("derives a legacy repository host after refining its provider", () => } as unknown as OrchestrationProjectShell; const service = yield* makeService({ projects: [legacy], - providers: [fakeProvider("gitlab")], + providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ context }) => Effect.succeed({ - context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + context: { + ...context!, + provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, + }, provider: undefined as never, }), }); @@ -648,18 +658,21 @@ it.effect("tries another checkout when provider refinement remains unknown", () title: "self-hosted", workspaceRoot: "/gone", repository: "group/project", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), host: "code.example.test", }); const service = yield* makeService({ projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }], - providers: [fakeProvider("gitlab")], + providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ cwd, context }) => { asked.push(cwd); return cwd === "/gone" ? Effect.succeed({ context: context!, provider: undefined as never }) : Effect.succeed({ - context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + context: { + ...context!, + provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, + }, provider: undefined as never, }); }, @@ -689,11 +702,11 @@ it.effect("reads nothing from a host with no implementation, but reports it", () title: "on gitlab", workspaceRoot: "/c", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { listed.push(input.repository); return Effect.succeed({ @@ -718,8 +731,8 @@ it.effect("reads nothing from a host with no implementation, but reports it", () projectCount: summary.projectCount, })), [ - { kind: "github", configured: true, projectCount: 1 }, - { kind: "gitlab", configured: false, projectCount: 1 }, + { kind: SourceControlProviderKind.make("github"), configured: true, projectCount: 1 }, + { kind: SourceControlProviderKind.make("gitlab"), configured: false, projectCount: 1 }, ], ); }), @@ -733,7 +746,7 @@ it.effect("asks for a whole page of a host, and for the reader's own size when g project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { limits.push(input.limit); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -759,7 +772,7 @@ it.effect("says where each repository carries on, and from nothing it has run ou project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -787,7 +800,7 @@ it.effect("offers no continuation for a host that cannot be carried on from", () project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -815,12 +828,12 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(7, "2026-07-02T00:00:00Z")], @@ -851,7 +864,7 @@ it.effect("reads only the repositories it was asked to carry on with", () => project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { listed.push(input.repository); cursors.push(input.cursor); @@ -882,7 +895,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice" project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The boundary instant is asked for inclusively, so the host hands back the rows // already sent at it alongside the ones beside them — which a strictly-older read // would have lost instead. @@ -922,7 +935,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -960,7 +973,9 @@ it.effect("refuses a continuation it did not issue, before asking any host anyth project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => Effect.die("should not be read"), + }), ], }); @@ -983,11 +998,11 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getViewer", reason: "failed", detail: "HTTP 500", @@ -1014,23 +1029,24 @@ it.effect("reports an unusable host over a merely failing one", () => title: "on gitlab", workspaceRoot: "/c", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getViewer", reason: "failed", detail: "HTTP 500", }), ), }), - fakeProvider("gitlab", { - getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + fakeProvider(SourceControlProviderKind.make("gitlab"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), ], }); @@ -1052,11 +1068,11 @@ it.effect("lists every host that has an implementation", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/sub/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-01T00:00:00Z")], @@ -1064,7 +1080,7 @@ it.effect("lists every host that has an implementation", () => continues: true, }), }), - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: (input) => // Nested groups need the full path, not the last two segments. input.repository === "group/sub/project" @@ -1100,12 +1116,14 @@ it.effect("narrows the listing to one host when asked", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { listChangeRequests: () => Effect.die("should not be read") }), - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: () => Effect.die("should not be read"), + }), + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(2, "2026-07-05T00:00:00Z")], @@ -1139,7 +1157,7 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ host }) => Effect.succeed({ items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [], @@ -1179,11 +1197,11 @@ it.effect("keeps one host listed when another is not set up", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-01T00:00:00Z")], @@ -1191,8 +1209,9 @@ it.effect("keeps one host listed when another is not set up", () => continues: true, }), }), - fakeProvider("gitlab", { - getViewer: () => Effect.fail(unusable("gitlab", "missing-tool")), + fakeProvider(SourceControlProviderKind.make("gitlab"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), ], }); @@ -1220,8 +1239,9 @@ it.effect("fails as unavailable only when no host can be read", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { - getViewer: () => Effect.fail(unusable("github", "missing-tool")), + fakeProvider(SourceControlProviderKind.make("github"), { + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), }), ], }); @@ -1250,7 +1270,7 @@ it.effect("reads a repository once when several worktrees share it", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { calls += 1; return Effect.succeed({ @@ -1278,7 +1298,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () => project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => input.repository === "pingdotgg/broken" ? Effect.fail(requestFailed) @@ -1309,11 +1329,11 @@ it.effect("tries another workspace on the same host for the viewer", () => project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => input.cwd === "/healthy" ? Effect.succeed("bilal") - : Effect.fail(unusable("github", "missing-tool")), + : Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -1345,7 +1365,7 @@ it.effect("routing verifies the current account on the requested host without ca }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: (input) => { assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" }); return Effect.succeed({ @@ -1359,7 +1379,7 @@ it.effect("routing verifies the current account on the requested host without ca const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; assert.deepStrictEqual(yield* service.routing(ref), { host: "github.example.test", - provider: "github", + provider: SourceControlProviderKind.make("github"), viewer: "first-account", accountId: "123", projectTitle: "web", @@ -1382,7 +1402,7 @@ it.effect("refuses an action the host never claimed it could run", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1428,12 +1448,12 @@ it.effect("publishes a merge for immediate settlement only after host confirmati project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => confirmationFails ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestSummary", reason: "failed", detail: "HTTP 504", @@ -1486,7 +1506,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes", project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.gen(function* () { yield* Deferred.succeed(confirmationStarted, undefined); @@ -1528,7 +1548,7 @@ it.effect("refuses an action this viewer may not take, and says what access it t const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The host merges; this account only reads it, and opened the change request — which // is every contributor to a repository they do not own. getViewerPermissions: () => @@ -1565,7 +1585,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1620,7 +1640,7 @@ it.effect("hands the host the strategy an armed merge was asked for", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1666,7 +1686,7 @@ it.effect("refuses an auto-merge the host never claimed, without asking it", () projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ // Bitbucket's shape: it merges, and has nothing that merges later on its own. - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { runAction: () => { ran = true; return Effect.void; @@ -1694,7 +1714,7 @@ it.effect("refuses to resolve a conversation this viewer may not, without asking const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: ["merge", "ready", "draft", "close", "reopen"], @@ -1729,7 +1749,7 @@ it.effect("asks nobody what the viewer may do when the host cannot do it at all" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1768,7 +1788,7 @@ it.effect("refuses a comment on a host that cannot post one", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: false, comment: false, @@ -1817,7 +1837,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), listChangeRequests: () => Effect.succeed({ @@ -1858,11 +1878,11 @@ it.effect("reports repositories on a host that could not be read", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => input.cwd === "/cloud" ? Effect.succeed("bilal") - : Effect.fail(unusable("github", "unauthenticated")), + : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), listChangeRequests: () => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -1893,7 +1913,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.sync(() => { listCalls += 1; @@ -1901,7 +1921,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail Effect.andThen( Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "listChangeRequests", reason: "rate-limited", detail: "GitHub API rate limit exceeded.", @@ -1933,13 +1953,14 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [provider, host] of [ +for (const [providerName, host] of [ ["github", "github.com"], ["gitlab", "gitlab.com"], ["forgejo", "code.example.test"], ["bitbucket", "bitbucket.org"], ["azure-devops", "dev.azure.com"], ] as const) { + const provider = SourceControlProviderKind.make(providerName); it.effect.each(["detail", "checks"] as const)( `shares fresh ${provider} %s reads and respects rate-limit resets`, (read) => @@ -2006,7 +2027,7 @@ it.effect("does not fall back to full detail when checks are unsupported", () => project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.die("Unexpected full detail read"), }), ], @@ -2025,7 +2046,7 @@ it.effect("uses a manual rate limit to pause later reads", () => project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.sync(() => { listCalls += 1; @@ -2034,7 +2055,7 @@ it.effect("uses a manual rate limit to pause later reads", () => runAction: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "runAction", reason: "rate-limited", detail: "GitHub API rate limit exceeded.", @@ -2066,7 +2087,7 @@ it.effect("flags a review request for the viewer but not on their own change req project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -2099,7 +2120,7 @@ it.effect("refuses a repository that does not belong to the requested project", projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], - providers: [fakeProvider("github")], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service @@ -2116,7 +2137,7 @@ it.effect("caches stack membership separately from action details", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestStack: (input) => Effect.sync(() => { reads.push(input.includeDetails === true); @@ -2157,7 +2178,7 @@ it.effect("reads a host-native stack through the provider and null where it has const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestStack: () => Effect.succeed({ id: "9", @@ -2185,7 +2206,7 @@ it.effect("reads a host-native stack through the provider and null where it has const withoutStacks = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider("github")], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); assert.isNull( yield* withoutStacks.stack({ @@ -2215,7 +2236,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ], providers: [ - fakeProvider("forgejo", { + fakeProvider(SourceControlProviderKind.make("forgejo"), { getViewer: (input) => { viewers.push(input.host); assert.strictEqual(input.host, "code.example:3000"); @@ -2244,7 +2265,11 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after return Effect.succeed({ context: { ...context, - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "http://code.example:3000" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "http://code.example:3000", + }, }, provider: undefined as never, }); @@ -2294,12 +2319,12 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => title: "http", workspaceRoot: "/http", repository: "team/repo", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "code.example", remoteUrl: "http://code.example:4000/team/repo.git", }), ], - providers: [fakeProvider("forgejo")], + providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], }); const failure = yield* service .summary( @@ -2327,7 +2352,7 @@ it.effect("resolves a project's repository identity when its shell has none cach const service = yield* makeService({ projects: [{ ...resolved, repositoryIdentity: null }], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], @@ -2351,7 +2376,7 @@ it.effect("routes a hosted reference to another repository through a project on project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); @@ -2381,12 +2406,12 @@ it.effect("routes Azure reads and writes through the requested organization's ch title: organization, workspaceRoot: `/${organization}`, repository: `${organization}/project/_git/web`, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ), providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -2434,7 +2459,7 @@ it.effect.each([ id: "target", title: "target", workspaceRoot: "/target", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), ...checkout, }); const service = yield* makeService({ @@ -2444,7 +2469,7 @@ it.effect.each([ id: repository, title: repository, workspaceRoot: `/${repository}`, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", repository, }), @@ -2452,7 +2477,7 @@ it.effect.each([ target, ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -2486,12 +2511,12 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou title: "org-a", workspaceRoot: "/org-a", repository: "org-a/project/_git/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), runAction: () => Effect.die("must not modify the wrong organization"), }), @@ -2518,7 +2543,7 @@ it.effect("refuses a hosted reference when nothing is checked out from that host projects: [ project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], - providers: [fakeProvider("github")], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service @@ -2546,11 +2571,11 @@ it.effect("refuses a diff on a host that cannot produce one", () => title: "on azure", workspaceRoot: "/a", repository: "org/project", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), }), ], providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { capabilities: { diff: false, comment: true, @@ -2580,7 +2605,11 @@ it.effect("rejects an empty comment before reaching the host", () => projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], - providers: [fakeProvider("github", { comment: () => Effect.die("must not be called") })], + providers: [ + fakeProvider(SourceControlProviderKind.make("github"), { + comment: () => Effect.die("must not be called"), + }), + ], }); const error = yield* service @@ -2606,11 +2635,11 @@ it.effect("refuses a verdict the host never claimed, without asking the provider title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -2658,7 +2687,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2704,7 +2733,7 @@ it.effect( }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { submitReview: () => { approved = true; return Effect.void; @@ -2736,7 +2765,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2777,7 +2806,7 @@ it.effect("refuses to react on a host with no reactions", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2814,7 +2843,7 @@ it.effect("refuses to react on a host whose capabilities omit reactions entirely project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -2855,7 +2884,7 @@ it.effect("passes a reaction through with its subject id on a host that has them project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { setReaction: (input) => { received = { subjectId: input.subjectId, @@ -2888,7 +2917,7 @@ it.effect("invalidates the cached activity after reacting, like the other mutati const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestActivity: () => { activityCalls += 1; return Effect.succeed({ @@ -2926,7 +2955,9 @@ it.effect("refuses an empty reply before it reaches the host", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { replyToThread: () => Effect.die("must not be called") }), + fakeProvider(SourceControlProviderKind.make("github"), { + replyToThread: () => Effect.die("must not be called"), + }), ], }); @@ -2952,7 +2983,7 @@ it.effect("refuses a merge strategy the host does not offer", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -3005,7 +3036,7 @@ it.effect("hands the provider the host its repository lives on", () => }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { hosts.push(input.host); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -3037,12 +3068,12 @@ it.effect("asks every host the reader's search, rather than filtering what came title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { listChangeRequests: listing }), - fakeProvider("gitlab", { listChangeRequests: listing }), + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }), + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }), ], }); @@ -3062,7 +3093,7 @@ it.effect("asks for no search when the reader has typed nothing", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { asked.push(input.query); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -3098,13 +3129,13 @@ it.effect("asks another checkout who is signed in when the first one cannot answ }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => { asked.push(input.cwd); return input.cwd === "/gone" ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getViewer", reason: "failed", detail: "not a git repository", @@ -3138,7 +3169,7 @@ it.effect("refuses to ask for a review on a host that cannot, before any call is const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -3179,7 +3210,7 @@ it.effect("refuses the candidate list on a host that has no such list to give", const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: false, comment: false, @@ -3215,7 +3246,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The host asks for reviews; this account only reads the repository. getViewerPermissions: () => Effect.succeed({ @@ -3254,7 +3285,7 @@ it.effect("keeps the menu from a viewer who may not ask, which is all it is for" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: [], @@ -3286,7 +3317,7 @@ it.effect("hands the host's own candidate list back, and asks for it with the ch const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listReviewerCandidates: (input) => { askedFor = input.number; return Effect.succeed({ @@ -3328,7 +3359,7 @@ it.effect("refuses a label change on a host that has not said it takes one", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The method is there; the capability that would let it be called is not. setLabels: () => { changed = true; @@ -3360,8 +3391,11 @@ it.effect("refuses a label change this viewer may not make, and says what access const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, labels: true }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + labels: true, + }, getViewerPermissions: () => Effect.succeed({ actions: [], @@ -3405,8 +3439,11 @@ it.effect("hands a label change to the host, and reads the labels back for the m const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, labels: true }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + labels: true, + }, listLabelCandidates: () => Effect.succeed({ candidates: [{ name: "bug", color: null, description: null, isApplied: false }], @@ -3447,7 +3484,7 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { hostCalls += 1; return Effect.succeed({ @@ -3496,7 +3533,7 @@ it.effect.each(["list", "listStats"] as const)( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => lookup.pipe( Effect.as({ @@ -3545,7 +3582,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.sync(() => { viewerCalls += 1; @@ -3583,7 +3620,7 @@ it.effect("uses five host reads for the normal indexed-repository page workflow" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.sync(() => { viewerCalls += 1; @@ -3643,7 +3680,7 @@ it.effect("returns the refreshed listing on the first read after its cache expir const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { hostCalls += 1; return Effect.succeed({ @@ -3682,7 +3719,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories); return Effect.succeed({ @@ -3725,7 +3762,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ filters }) => Effect.gen(function* () { reads += 1; @@ -3782,7 +3819,7 @@ it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and proj project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: (input) => Effect.sync(() => { calls.push(`${input.repository}/${input.number}`); @@ -3827,7 +3864,7 @@ it.effect( }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: (input) => Effect.sync(() => { hosts.push(input.host); @@ -3858,7 +3895,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }), }), ], @@ -3882,7 +3919,7 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getViewer: () => { viewerCalls += 1; return Effect.succeed("bilal"); @@ -3924,7 +3961,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), runAction: (input) => @@ -3976,7 +4013,7 @@ it.effect("explicit invalidation refreshes origin readers after a routed host mu project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), }), @@ -4021,7 +4058,7 @@ it.effect("does not cache a failed listing", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { // The viewer lookup is what fails the whole listing rather than one repository. getViewer: () => { hostCalls += 1; @@ -4052,11 +4089,11 @@ it.effect("reads a host's repositories in one search, and files the rows back un title: "on gitlab", workspaceRoot: "/c", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -4073,7 +4110,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un }, }), // A host with no search across repositories keeps being asked one at a time. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ @@ -4111,7 +4148,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: () => Effect.succeed({ items: [ @@ -4144,7 +4181,7 @@ it.effect("carries a slice on without sending the rows it already sent", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { cursors.push(input.cursor); return Effect.succeed({ @@ -4189,7 +4226,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", }), ), providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories.length); return Effect.succeed({ @@ -4218,7 +4255,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return repository === "acme/docs" @@ -4262,7 +4299,7 @@ it.effect("reads the repositories one at a time when the search itself fails", ( project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ @@ -4295,11 +4332,11 @@ it.effect("fills in the line counts for the rows it is given", () => title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: (input) => { asked.push(input.changeRequests); return Effect.succeed([ @@ -4308,7 +4345,7 @@ it.effect("fills in the line counts for the rows it is given", () => }, }), // Its listing carries the counts already, so it has nothing to be asked. - fakeProvider("gitlab"), + fakeProvider(SourceControlProviderKind.make("gitlab")), ], }); @@ -4355,7 +4392,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: (input) => { asked.push(input.changeRequests.map((ref) => ref.number)); return Effect.succeed( @@ -4404,7 +4441,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })), getChangeRequestSummary: () => @@ -4453,7 +4490,9 @@ it.effect("keeps the rows when the line counts cannot be read", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { listChangeRequestStats: () => Effect.fail(requestFailed) }), + fakeProvider(SourceControlProviderKind.make("github"), { + listChangeRequestStats: () => Effect.fail(requestFailed), + }), ], }); @@ -4478,7 +4517,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: () => { statsCalls += 1; return Effect.succeed([]); @@ -4556,7 +4595,7 @@ it.effect("shares linked summaries and reuses them for display without asking th const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.sync(() => { calls += 1; @@ -4567,7 +4606,7 @@ it.effect("shares linked summaries and reuses them for display without asking th shouldFail ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequestSummary", reason: "failed", detail: "HTTP 504", @@ -4622,8 +4661,11 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + viewedFiles: "host", + }, getFilesViewed: () => read().pipe( Effect.as({ @@ -4685,8 +4727,11 @@ it.effect("isolates routed caches for two credentials belonging to the same acco project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { - capabilities: { ...fakeProvider("github").capabilities, viewedFiles: "host" }, + fakeProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + viewedFiles: "host", + }, getFilesViewed: () => read().pipe( Effect.as({ @@ -4744,7 +4789,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), withVerifiedCredential: (_, use) => use({ @@ -4798,7 +4843,7 @@ it.effect("answers a known pull request immediately while the host refreshes", ( const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.gen(function* () { calls += 1; @@ -4829,7 +4874,7 @@ it.effect("does not ask the host again for a linked summary it already holds", ( const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.sync(() => { calls += 1; @@ -4858,7 +4903,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -4896,7 +4941,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("merged body", 4), @@ -4927,7 +4972,7 @@ it.effect("announces state a detail read sees first or newly", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), getChangeRequestSummary: () => Effect.succeed({ @@ -4979,7 +5024,7 @@ it.effect("announces a reported state without trusting it", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), }), ], @@ -5024,7 +5069,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.gen(function* () { detailCalls += 1; @@ -5078,7 +5123,7 @@ it.effect("does not let a still-cached detail overwrite a fresher linked summary const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), getChangeRequestSummary: () => Effect.succeed({ @@ -5114,11 +5159,11 @@ it.effect("tells the client when the host has no pull request under that number" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequest", reason: "not-found", detail: "Pull request not found. Check the PR number or URL and try again.", @@ -5142,12 +5187,12 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => failing ? Effect.fail( new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getChangeRequest", reason: "failed", detail: "spawn gh EAGAIN", @@ -5197,7 +5242,7 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -5243,13 +5288,13 @@ it.effect("narrows the rows of a host that ignored the filters it was handed", ( title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and // sending it a draft filter it quietly ignores used to put drafts on a filtered page. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5285,11 +5330,11 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5328,13 +5373,13 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho title: "web", workspaceRoot: "/a", repository: "acme/web", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved // locally too — the same helper both call sites lean on. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5395,7 +5440,7 @@ it.effect.each([false, true])( }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities, getViewerPermissions: () => Effect.succeed({ @@ -5463,7 +5508,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -5516,11 +5561,11 @@ it.effect("refuses to merge a target branch into a source branch on a host that title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -5575,12 +5620,12 @@ it.effect("judges the review filter only on a host that summarises its reviews", title: "on gitlab", workspaceRoot: "/b", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ // GitHub answers with the field on every row: null is "nobody has decided yet". - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -5595,7 +5640,7 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), }), // GitLab never supplies the field, so its rows are not the filter's to judge. - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(3, "2026-07-02T00:00:00Z")], @@ -5621,7 +5666,7 @@ it.effect("sends only the words a rewrite carries", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { updateChangeRequest: (input) => { received.push({ title: input.title, body: input.body }); return Effect.void; @@ -5647,7 +5692,9 @@ it.effect("refuses a rewrite that changes nothing, before any call is made", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { updateChangeRequest: () => Effect.die("must not be called") }), + fakeProvider(SourceControlProviderKind.make("github"), { + updateChangeRequest: () => Effect.die("must not be called"), + }), ], }); @@ -5665,7 +5712,7 @@ it.effect("refuses to rewrite anything on a host that never claimed it", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -5704,7 +5751,7 @@ it.effect("passes a rewritten remark through with the id and kind it arrived und const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { updateComment: (input) => { received = { id: input.commentId, kind: input.kind, body: input.body }; return Effect.void; @@ -5735,7 +5782,9 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { updateComment: () => Effect.die("must not be called") }), + fakeProvider(SourceControlProviderKind.make("github"), { + updateComment: () => Effect.die("must not be called"), + }), ], }); @@ -5761,7 +5810,7 @@ it.effect("forgets the cached detail after a rewrite or terminal turn", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => { coreCalls += 1; return Effect.succeed({ @@ -5813,7 +5862,7 @@ it.effect("names the signed-in account in the detail, and says nothing where the number: 1, }); }); - const readable = fakeProvider("github", { + const readable = fakeProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -5837,7 +5886,8 @@ it.effect("names the signed-in account in the detail, and says nothing where the const named = yield* detailFrom(readable); const unnamed = yield* detailFrom({ ...readable, - getViewer: () => Effect.fail(unusable("github", "unauthenticated")), + getViewer: () => + Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), }); assert.strictEqual(named.viewer, "bilal"); @@ -5855,7 +5905,7 @@ it.effect("keeps the diff cached across a file being ticked off", () => project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -5912,7 +5962,7 @@ it.effect("returns large diff slices intact without retaining them in either cac const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getDiff: () => Effect.sync(() => { reads += 1; @@ -5950,7 +6000,7 @@ it.effect("caches a small replacement after releasing a large diff", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { getDiff: () => Effect.sync(() => { reads += 1; @@ -5976,7 +6026,7 @@ const environmentViewedProvider = ( asked: Array>, unreadable: ReadonlySet = new Set(), ) => - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -6018,7 +6068,7 @@ const environmentViewedService = ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [environmentViewedProvider(revisions, asked, unreadable)], @@ -6083,7 +6133,7 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba title: "on forgejo", workspaceRoot: "/forgejo", repository: "reviewer/project", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "forge.example:3000", remoteUrl: "http://forge.example:3000/reviewer/project.git", }), @@ -6145,7 +6195,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi title: "first repository", workspaceRoot: "/first", repository: "reviewer/first", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "forge.example", remoteUrl: "https://forge.example:3000/reviewer/first.git", }), @@ -6153,13 +6203,20 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi const service = yield* makeService({ projects, providers: [ - { ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), kind: "forgejo" }, + { + ...environmentViewedProvider(new Map([["same.ts", "blob"]]), []), + kind: SourceControlProviderKind.make("forgejo"), + }, ], resolveHandle: ({ context }) => Effect.succeed({ context: { ...context!, - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forge.example:3000" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://forge.example:3000", + }, }, provider: undefined as never, }), @@ -6186,7 +6243,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi title: "second repository", workspaceRoot: "/second", repository: "reviewer/second", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "forge.example", remoteUrl: "https://forge.example:3000/reviewer/second.git", }), @@ -6201,7 +6258,7 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi title: "second repository over SSH", workspaceRoot: "/ssh", repository: "reviewer/second", - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), host: "ssh.forge.example", remoteUrl: "git@ssh.forge.example:reviewer/second.git", }), @@ -6269,7 +6326,7 @@ it.effect("holds what a whole-change answer carried, so the next tick reads noth title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -6643,11 +6700,11 @@ it.effect("still reports its own marks when the host will not say what the head title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -6699,11 +6756,11 @@ it.effect("finishes two presses on one file in the order they were made", () => title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -6755,7 +6812,7 @@ const azureViewedService = (projects: ReadonlyArray) makeService({ projects, providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { capabilities: { diff: true, comment: true, @@ -6781,7 +6838,7 @@ const AZURE_PAIR = [ title: "platform web", workspaceRoot: "/a", repository: "acme/platform/_git/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), project({ @@ -6789,7 +6846,7 @@ const AZURE_PAIR = [ title: "other web", workspaceRoot: "/b", repository: "acme/other/_git/web", - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ]; @@ -6880,7 +6937,7 @@ it.effect("keeps the marked paths when a whole-change answer is wider than the c title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -6949,7 +7006,7 @@ const environmentViewedServiceWithViewer = ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [{ ...environmentViewedProvider(revisions, []), getViewer }], @@ -6983,7 +7040,7 @@ it.effect("puts a listing and a press for one host on a single viewer lookup", ( title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7048,7 +7105,7 @@ it.effect("records a press while the host is backing off", () => title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7064,7 +7121,7 @@ it.effect("records a press while the host is backing off", () => getFileRevisions: () => Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getFileRevisions", reason: "rate-limited", detail: "API rate limit exceeded.", @@ -7111,7 +7168,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7129,7 +7186,7 @@ it.effect("asks who is reading through a pause only for the press that is waitin getFileRevisions: () => Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getFileRevisions", reason: "rate-limited", detail: "API rate limit exceeded.", @@ -7174,7 +7231,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed" title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ @@ -7185,7 +7242,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed" viewerLookups += 1; return Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getViewer", reason: "failed", detail: "glab exited with status 1", @@ -7197,7 +7254,7 @@ it.effect("does not ask a paused host who is reading again after the ask failed" runAction: () => Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "runAction", reason: "rate-limited", detail: "API rate limit exceeded.", @@ -7231,7 +7288,7 @@ it.effect("refuses the marks when the host could not be asked who is reading", ( ? Effect.succeed("bilal") : Effect.fail( new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getViewer", reason: "failed", detail: "glab exited with status 1", @@ -7276,11 +7333,11 @@ it.effect("refuses to track viewed files on a host that does not", () => title: "on gitlab", workspaceRoot: "/a", repository: "group/project", - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), }), ], providers: [ - fakeProvider("gitlab", { + fakeProvider(SourceControlProviderKind.make("gitlab"), { getFilesViewed: () => Effect.die("must not be called"), setFilesViewed: () => Effect.die("must not be called"), }), @@ -7308,12 +7365,12 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s title: organization, workspaceRoot: `/${organization}`, repository: `${organization}/project/_git/web`, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), host: "dev.azure.com", }), ), providers: [ - fakeProvider("azure-devops", { + fakeProvider(SourceControlProviderKind.make("azure-devops"), { listChangeRequests: (input) => Effect.sync(() => { seen.push(input.cwd); @@ -7363,9 +7420,9 @@ it.effect.each([ }, }, providers: [ - fakeProvider("github", { + fakeProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider("github").capabilities, + ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, actions: ["merge", "enable-auto-merge"], }, getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 51377f1b33b1..e4d8178dbbe9 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -72,7 +72,7 @@ import { type PullRequestThreadCommentsResult, type PullRequestUpdateInput, type SourceControlProviderInfo, - type SourceControlProviderKind, + SourceControlProviderKind, type ThreadPullRequestKey, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -736,14 +736,15 @@ export const make = Effect.gen(function* () { sourceControlRepositorySelector(project.repositoryIdentity) === null ) continue; - const host = pullRequestHostOf(identity, "unknown"); + const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); // A legacy identity has no canonical host until its provider is refined, so it must reach // the refinement before a host filter can decide whether it belongs in the result. if ( filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase() && - pullRequestHostOf(identity, "forgejo") !== filter.host.toLowerCase() && + pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== + filter.host.toLowerCase() && !isSshRemoteUrl(identity.locator.remoteUrl) ) { continue; @@ -768,7 +769,9 @@ export const make = Effect.gen(function* () { cwd: project.workspaceRoot, context: { provider: - provider.kind === "forgejo" ? { ...provider, kind: "unknown" } : provider, + provider.kind === "forgejo" + ? { ...provider, kind: SourceControlProviderKind.make("unknown") } + : provider, remoteName, remoteUrl, ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 334d48762f69..635d90797fc6 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -2,7 +2,7 @@ import { pullRequestHostOf, type PullRequestRef, type RepositoryIdentity, - type SourceControlProviderKind, + SourceControlProviderKind, type ThreadPullRequestKey, } from "@t3tools/contracts"; import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; @@ -18,7 +18,8 @@ export function pullRequestSyncKey( reference.repository.toLowerCase() !== sourceControlRepositorySelector(identity)?.toLowerCase() || (reference.host !== undefined && - reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops")) + reference.host.toLowerCase() !== + pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops"))) ) return null; const [host, ...repository] = identity.canonicalKey.split("/"); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 85e14a61bc62..f879d57665ba 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it, vi } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -362,7 +363,7 @@ it.effect.each([false, true])( ? { context: { provider: { - kind: "bitbucket" as const, + kind: SourceControlProviderKind.make("bitbucket"), name: "Bitbucket", baseUrl: "https://bitbucket.org", }, @@ -396,7 +397,11 @@ it.effect("prefers an explicit repository and uses context when the repository i return Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; const context = { - provider: { kind: "bitbucket" as const, name: "Bitbucket", baseUrl: "https://bitbucket.org" }, + provider: { + kind: SourceControlProviderKind.make("bitbucket"), + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, remoteName: "origin", remoteUrl: "git@bitbucket.org:another/context.git", }; @@ -838,7 +843,7 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket cwd: "/repo", context: { provider: { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), name: "Bitbucket", baseUrl: "https://bitbucket.org", }, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 8fa9201b94ca..ff3933a98ed3 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -176,37 +177,37 @@ it.effect("reports implemented tools separately from locally available executabl })), [ { - kind: "github", + kind: SourceControlProviderKind.make("github"), status: "available", auth: "authenticated", account: Option.some("juliusmarminge"), }, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), status: "missing", auth: "unknown", account: Option.none(), }, { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), status: "missing", auth: "unknown", account: Option.none(), }, { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), status: "available", auth: "unauthenticated", account: Option.none(), }, { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), status: "missing", auth: "unknown", account: Option.none(), }, { - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), status: "missing", auth: "unknown", account: Option.none(), @@ -327,37 +328,37 @@ Logged in to gitlab.com as gitlab-user })), [ { - kind: "github", + kind: SourceControlProviderKind.make("github"), auth: "authenticated", account: Option.some("octocat"), detail: Option.none(), }, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), auth: "authenticated", account: Option.some("gitlab-user"), detail: Option.none(), }, { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), auth: "authenticated", account: Option.some("azure-user@example.com"), detail: Option.none(), }, { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), auth: "authenticated", account: Option.some("bitbucket-user"), detail: Option.none(), }, { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), auth: "authenticated", account: Option.some("forgejo-user"), detail: Option.none(), }, { - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), auth: "authenticated", account: Option.some("cafe-user"), detail: Option.none(), @@ -528,7 +529,11 @@ it.effect( reference: "42", force: true, context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forgejo.test" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://forgejo.test", + }, remoteName: "origin", remoteUrl: "git@forgejo.test:maria/project.git", }, diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 1fab84a6acd0..d174ed19eeab 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; @@ -154,7 +155,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () => remotes: [], }); - const provider = yield* registry.get("github"); + const provider = yield* registry.get(SourceControlProviderKind.make("github")); assert.strictEqual(provider.kind, "github"); }), @@ -163,7 +164,7 @@ it.effect("routes directly by provider kind for remote-first workflows", () => it.effect("includes the request cwd when an unregistered provider is used", () => Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [] }); - const provider = yield* registry.get("unknown"); + const provider = yield* registry.get(SourceControlProviderKind.make("unknown")); const error = yield* provider .getChangeRequest({ cwd: "/repo", reference: "#42" }) @@ -257,7 +258,7 @@ it.effect("refines the caller-selected remote instead of choosing another config cwd: "/repo", context: { provider: { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: "self-hosted.example.test", baseUrl: "https://self-hosted.example.test", }, @@ -412,7 +413,7 @@ it.effect.each([ }, }); const handle = yield* registry.resolveHandle({ cwd: "/repo" }); - assert.strictEqual(handle.provider.kind, scenario.expected); + assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected)); assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); assert.deepStrictEqual(hosts, ["code.example.test"]); // Recognition goes through the credential layer; gh is only one of its sources. @@ -437,7 +438,7 @@ it.effect("skips GitHub discovery for the identity resolver's empty base URL", ( }, }); const context = { - provider: { kind: "unknown" as const, name: "Unknown", baseUrl: "" }, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, remoteName: "origin", remoteUrl, }; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 7af90663dc93..821e24bf7bc3 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -8,7 +9,7 @@ import { SourceControlProviderError, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; -import type { SourceControlProviderKind } from "@t3tools/contracts"; +import type {} from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as BuiltInDrivers from "./builtInDrivers.ts"; @@ -214,7 +215,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.mapError( (error) => new SourceControlProviderError({ - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), operation: "detectProvider", cwd, detail: "Failed to detect source control provider.", @@ -226,7 +227,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.mapError( (error) => new SourceControlProviderError({ - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), operation: "detectProvider", cwd, detail: "Failed to detect source control provider.", @@ -265,7 +266,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }) ).pipe( Effect.map((context) => { - const kind = context?.provider.kind ?? "unknown"; + const kind = context?.provider.kind ?? SourceControlProviderKind.make("unknown"); const provider = providers.get(kind) ?? unsupportedProvider(kind); return { provider: bindProviderContext(provider, context), diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index c051ab47f42f..509fd0582eff 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; @@ -37,7 +38,7 @@ function makeProvider( >; return { - kind: "github", + kind: SourceControlProviderKind.make("github"), listChangeRequests: () => unsupported("listChangeRequests"), getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), @@ -114,19 +115,22 @@ it.effect("looks up repositories through the requested provider without search", return Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.lookupRepository({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", cwd: "/workspace", }); - assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS }); + assert.deepStrictEqual(result, { + provider: SourceControlProviderKind.make("github"), + ...CLONE_URLS, + }); assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); }).pipe(Effect.provide(layer({ provider }))); }); it.effect("preserves provider failures without deriving the repository message from them", () => { const providerCause = new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "getRepositoryCloneUrls", cwd: "/workspace", repository: "octocat/t3code", @@ -140,7 +144,7 @@ it.effect("preserves provider failures without deriving the repository message f const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( service.lookupRepository({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", cwd: "/workspace", }), @@ -176,7 +180,7 @@ it.effect( yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const input = { - provider: "bitbucket" as const, + provider: SourceControlProviderKind.make("bitbucket"), repository: locatorCause.repository, cwd: "/workspace", }; @@ -204,11 +208,11 @@ it.effect( it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => { const provider = makeProvider({ - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), getRepositoryCloneUrls: () => Effect.fail( new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getRepositoryCloneUrls", cwd: "/workspace", detail: "credential token abc123 was rejected", @@ -224,7 +228,10 @@ it.effect("keeps a plain Bitbucket locator lookalike out of the repository messa return Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( - service.lookupRepository({ provider: "bitbucket", repository: "t3code" }), + service.lookupRepository({ + provider: SourceControlProviderKind.make("bitbucket"), + repository: "t3code", + }), ); assert.strictEqual(error.detail, "The source control operation could not be completed."); @@ -250,7 +257,7 @@ it.effect("keeps arbitrary Bitbucket request failures out of the repository mess const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( service.lookupRepository({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), repository: "pingdotgg/t3code", cwd: "/workspace", }), @@ -276,7 +283,7 @@ it.effect("clones a looked-up repository into the requested destination", () => yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.cloneRepository({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", destinationPath, protocol: "https", @@ -285,7 +292,7 @@ it.effect("clones a looked-up repository into the requested destination", () => assert.deepStrictEqual(result, { cwd: destinationPath, remoteUrl: CLONE_URLS.url, - repository: { provider: "github", ...CLONE_URLS }, + repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, }); assert.deepStrictEqual(cloneCalls, [ { @@ -546,7 +553,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", visibility: "private", remoteName: "origin", @@ -554,7 +561,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up }); assert.deepStrictEqual(result, { - repository: { provider: "github", ...CLONE_URLS }, + repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, remoteName: "origin", remoteUrl: CLONE_URLS.sshUrl, branch: "feature/remote-v1", @@ -601,7 +608,7 @@ it.effect("publishes to the remote name returned by ensureRemote", () => { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", visibility: "private", remoteName: "origin", @@ -637,7 +644,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const result = yield* service.publishRepository({ cwd: "/workspace", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "octocat/t3code", visibility: "private", remoteName: "origin", @@ -645,7 +652,7 @@ it.effect("publish succeeds with status remote_added when the local repo has no }); assert.deepStrictEqual(result, { - repository: { provider: "github", ...CLONE_URLS }, + repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, remoteName: "origin", remoteUrl: CLONE_URLS.sshUrl, branch: "main", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index d17f860050bb..c273535bd69f 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -12,7 +12,7 @@ import { type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, type SourceControlCloneProtocol, - type SourceControlProviderKind, + SourceControlProviderKind, type SourceControlPublishRepositoryInput, type SourceControlPublishRepositoryResult, type SourceControlRepositoryCloneUrls, @@ -209,7 +209,7 @@ export const make = Effect.gen(function* () { if (trimmed.length === 0) { return yield* new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Choose a destination path before cloning.", }); } @@ -229,7 +229,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path already exists and is not a directory.", cause, }), @@ -238,7 +238,7 @@ export const make = Effect.gen(function* () { if (entries.length > 0) { return yield* new SourceControlRepositoryError({ operation: "cloneRepository", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path already exists and is not empty.", }); } @@ -260,7 +260,8 @@ export const make = Effect.gen(function* () { const preparedDestination = yield* prepareDestination(input.destinationPath); let repository: SourceControlRepositoryInfo | null = null; let remoteUrl = input.remoteUrl?.trim() ?? null; - let provider: SourceControlProviderKind = input.provider ?? "unknown"; + let provider: SourceControlProviderKind = + input.provider ?? SourceControlProviderKind.make("unknown"); if (input.provider && input.repository) { repository = yield* lookupRepository({ @@ -335,7 +336,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "cloneRepository", - provider: input.provider ?? "unknown", + provider: input.provider ?? SourceControlProviderKind.make("unknown"), detail: stderrTail.length > 0 ? stderrTail.join(" ") @@ -370,7 +371,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "discardClone", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "The clone destination could not be inspected.", cause, }), @@ -379,7 +380,7 @@ export const make = Effect.gen(function* () { if (entries.length > 0 && !entries.includes(".git")) { return yield* new SourceControlRepositoryError({ operation: "discardClone", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "Destination path contains files that are not from the clone.", }); } @@ -393,7 +394,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "discardClone", - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), detail: "The partial clone could not be removed.", cause, }), @@ -462,13 +463,23 @@ export const make = Effect.gen(function* () { lookupRepository: (input) => lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), prepareClone: (input) => - prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")), + prepareClone(input).pipe( + mapRepositoryError( + "cloneRepository", + input.provider ?? SourceControlProviderKind.make("unknown"), + ), + ), cloneRepository: (input, options) => cloneRepository(input, options).pipe( - mapRepositoryError("cloneRepository", input.provider ?? "unknown"), + mapRepositoryError( + "cloneRepository", + input.provider ?? SourceControlProviderKind.make("unknown"), + ), ), discardClone: (destinationPath) => - discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")), + discardClone(destinationPath).pipe( + mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")), + ), publishRepository: (input) => publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), }); diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts index f75a06cfb59d..bf4323a195e9 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -87,7 +88,7 @@ it.effect("keeps lookup failure out of generation and skips unlinked messages", resolveLink: () => Effect.fail( new SourceControlProviderError({ - provider: "unknown", + provider: SourceControlProviderKind.make("unknown"), operation: "resolveLink", cwd: "/tmp", detail: "Unavailable", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 35d013433c49..9e9f2e4d2cca 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; @@ -34,7 +35,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, sourceControlProvider: { - kind: "github", + kind: SourceControlProviderKind.make("github"), name: "GitHub", baseUrl: "https://github.com", }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48c4b20a777f..b85b0ca2cdc3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -39,7 +39,7 @@ import { type FilesystemBrowseResult, type ProjectId, type SourceControlDiscoveryResult, - type SourceControlProviderKind, + SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, @@ -257,10 +257,13 @@ interface AddProjectEnvironmentOption { readonly status: string; } -type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" ->; +type AddProjectRemoteProviderKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops" + | "gitcafe"; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; type AddProjectCloneFlow = @@ -421,7 +424,7 @@ function buildAddProjectRemoteSourceReadiness( for (const source of REMOTE_PROJECT_SOURCES) { const kind = sourceProviderKind(source); if (!kind) continue; - const provider = providerByKind.get(kind); + const provider = providerByKind.get(SourceControlProviderKind.make(kind)); if (!provider) { readiness[source] = unavailable; continue; @@ -2628,7 +2631,7 @@ function OpenCommandPaletteDialog(props: { const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { - provider, + provider: SourceControlProviderKind.make(provider), repository: rawRepository, }, }); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index b29a44be3702..12aee26c757e 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { @@ -343,7 +344,7 @@ describe("when: source control provider uses merge requests", () => { const gitlabStatus = status({ aheadCount: 2, sourceControlProvider: { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: "GitLab", baseUrl: "https://gitlab.com", }, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2063767f315c..703694ce1651 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,6 +3,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, + SourceControlProviderKind, type ScopedThreadRef, } from "@t3tools/contracts"; import { @@ -14,7 +15,6 @@ import type { GitStackedAction, SourceControlCloneProtocol, SourceControlProviderDiscoveryItem, - SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, VcsStatusResult, @@ -158,10 +158,13 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } -type PublishProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" ->; +type PublishProviderKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops" + | "gitcafe"; type GitActionToastId = ReturnType; @@ -265,9 +268,7 @@ function publishProviderOption(provider: PublishProviderKind) { ); } -function isPublishProviderKind( - provider: SourceControlProviderKind, -): provider is PublishProviderKind { +function isPublishProviderKind(provider: string): provider is PublishProviderKind { return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); } @@ -582,8 +583,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - if (isPublishProviderKind(provider.kind)) { - accounts[provider.kind] = Option.getOrNull(provider.auth.account); + const kind: string = provider.kind; + if (isPublishProviderKind(kind)) { + accounts[kind] = Option.getOrNull(provider.auth.account); } } return accounts; @@ -670,7 +672,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { void (async () => { const result = await publishRepositoryAction.run({ - provider: publishProvider, + provider: SourceControlProviderKind.make(publishProvider), repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 75d0a16f5af7..976a220703c0 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -93,7 +94,7 @@ function pullRequestSummary( updatedAt: string, ): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project-1"), repository: "pingdotgg/t3code", number: 42, @@ -179,7 +180,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { url: "https://github.com/pingdotgg/t3code/pull/42", }; const provider = { - kind: "github" as const, + kind: SourceControlProviderKind.make("github"), name: "GitHub", baseUrl: "https://github.com", }; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8368dbb0eac9..146fac555c72 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -13,6 +13,7 @@ import { type ThreadLinkedPullRequest, type ThreadPullRequestLink, type VcsStatusResult, + SourceControlProviderKind, } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; @@ -154,7 +155,7 @@ export function linkedPullRequestSnapshotStatus( baseRef: snapshot.baseBranch, ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), }, - sourceControlProvider: { kind, name: kind, baseUrl: "" }, + sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" }, }; } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx index daf9356003af..3f192f745fa8 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { EnvironmentId, ProjectId, @@ -151,7 +152,7 @@ import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; import { pullRequestPanelContext } from "./pullRequestDetail.logic"; const detail: PullRequestDetailView = { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project"), projectTitle: "Project", workspaceRoot: "/workspace", diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx index 27191a970b3e..1362eb51d12f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.test.tsx @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; import { act, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -26,7 +27,7 @@ vi.mock("../ui/tooltip", () => ({ import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; const detail: PullRequestDetailView = { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project"), projectTitle: "Project", workspaceRoot: "/workspace", diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 3d5676ecbf55..7242eac8695a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { resolvePlanFollowUpSubmission } from "../../proposedPlan"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { @@ -95,12 +96,14 @@ describe("pull request checkout commands", () => { ], ["unknown", "feature", null, null], ] as const)("builds the %s command", (provider, branch, repository, expected) => { - expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected); + expect( + pullRequestCheckoutCommand(SourceControlProviderKind.make(provider), 42, branch, repository), + ).toBe(expected); }); it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => { expect( pullRequestCheckoutCommand( - "forgejo", + SourceControlProviderKind.make("forgejo"), 42, "feature", null, @@ -113,7 +116,7 @@ describe("pull request checkout commands", () => { it("quotes shell metacharacters in Forgejo repository URLs", () => { expect( pullRequestCheckoutCommand( - "forgejo", + SourceControlProviderKind.make("forgejo"), 42, "feature", null, @@ -1714,7 +1717,7 @@ describe("cached pull request detail", () => { const reference = { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 7 }; const detail = (overrides: Partial = {}): PullRequestDetail => ({ - provider: "github", + provider: SourceControlProviderKind.make("github"), capabilities: { diff: true, comment: true, @@ -1797,7 +1800,7 @@ describe("cached pull request detail", () => { remoteName: "origin", remoteUrl: "https://github.com/acme/web.git", }, - provider: "github", + provider: SourceControlProviderKind.make("github"), }); expect(readPullRequestDetailSnapshot(storage, "env-1", resolved)?.title).toBe( "Cache the title", @@ -1824,7 +1827,7 @@ describe("cached pull request detail", () => { remoteName: "origin", remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web", }, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), }), ).toBe(reference); expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference); @@ -1847,7 +1850,7 @@ describe("cached pull request detail", () => { it("keeps Forgejo ports isolated when recovering legacy snapshots", () => { const storage = makeStorage(); const cached = detail({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), url: "https://forge.example:8443/acme/web/pulls/7", }); writePullRequestDetailSnapshot(storage, "env-1", reference, cached); @@ -1869,7 +1872,8 @@ describe("cached pull request detail", () => { it.each(["github", "gitlab"] as const)( "retains portless %s snapshot identities for custom web ports", - (provider) => { + (providerName) => { + const provider = SourceControlProviderKind.make(providerName); const storage = makeStorage(); const host = `${provider}.example.com`; const hosted = { ...reference, host }; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 083f2c8db22a..0a367768a2de 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -21,7 +21,7 @@ import { type PullRequestReviewThread, type PullRequestState, type PullRequestUpdateMethod, - type SourceControlProviderKind, + SourceControlProviderKind, type ThreadLinkedPullRequest, type ThreadPullRequestLink, type VcsRef, @@ -140,7 +140,7 @@ export function pullRequestCheckoutCommand( } return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`; } - case "unknown": + default: return null; } } @@ -155,10 +155,11 @@ export function loadingPullRequestCheckoutCommand( identity?.provider ?? (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null); if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null; - if (identity?.provider !== undefined && host && pullRequestHostOf(identity, provider) !== host) { + const kind = SourceControlProviderKind.make(provider); + if (identity?.provider !== undefined && host && pullRequestHostOf(identity, kind) !== host) { return null; } - return pullRequestCheckoutCommand(provider, reference.number, ""); + return pullRequestCheckoutCommand(kind, reference.number, ""); } /** Activity changes only when the same host resource reports a newer revision. */ @@ -1304,7 +1305,10 @@ export function resolvePullRequestReferenceHost( ): PullRequestRef { // Other providers may resolve an SSH remote to a different web authority on the server. if (reference.host !== undefined || identity?.provider !== "github") return reference; - return { ...reference, host: pullRequestHostOf(identity, "github") }; + return { + ...reference, + host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")), + }; } export interface PullRequestDetailSnapshotRef { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 3bd486fa9e9c..ebaa045a66a7 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -330,7 +331,7 @@ describe("pull request involvement filtering", () => { entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), entry({ number: 2, - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), host: "gitlab.com", author: { login: "Bilal", name: null, avatarUrl: null }, }), @@ -1322,7 +1323,7 @@ describe("merging the environments' own listings", () => { providers: [ { host: "github.com", - kind: "github", + kind: SourceControlProviderKind.make("github"), searchesOnHost: false, projectCount: 2, configured: false, diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 68465b06d0fd..ec408ad97cdb 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -78,7 +78,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { sourceControlProviders: [], }; -const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { +const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { github: GitHubIcon, gitlab: GitLabIcon, forgejo: ForgejoIcon, diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts index 175609cbfc42..fc1ff81ce1af 100644 --- a/apps/web/src/hooks/useNewProject.ts +++ b/apps/web/src/hooks/useNewProject.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { getNewProjectGitHubRepository } from "@t3tools/client-runtime/operations/projects"; import { @@ -45,7 +46,7 @@ export function useNewProject() { environmentId: input.environmentId, input: { cwd: input.workspaceRoot, - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: getNewProjectGitHubRepository(input, input.workspaceRoot), visibility: "private", }, diff --git a/apps/web/src/state/pullRequests.test.ts b/apps/web/src/state/pullRequests.test.ts index 48ee5b51b884..42dc8c2fba6f 100644 --- a/apps/web/src/state/pullRequests.test.ts +++ b/apps/web/src/state/pullRequests.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -5,7 +6,7 @@ import { newestPullRequestObservation, newestPullRequestSummary } from "./pullRe function summary(overrides: Partial = {}): PullRequestSummary { return { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("pull-request-cache-test"), repository: "acme/widget", number: 7, diff --git a/apps/web/src/state/sourceControlActions.test.ts b/apps/web/src/state/sourceControlActions.test.ts index 1747f7996345..1e04c3f340ed 100644 --- a/apps/web/src/state/sourceControlActions.test.ts +++ b/apps/web/src/state/sourceControlActions.test.ts @@ -3,6 +3,7 @@ import { AuthSourceControlWriteScope, EnvironmentId, ThreadId, + SourceControlProviderKind, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -98,7 +99,7 @@ const cases = [ isAllowed: action.isAllowed, run: () => action.run({ - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "owner/repo", visibility: "private", remoteName: "origin", diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index f760678136dc..ec026c00f413 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -16,6 +16,7 @@ import { type EnvironmentId, type GitResolvePullRequestResult, type SourceControlCloneProtocol, + type SourceControlProviderKind, type SourceControlRepositoryVisibility, type ThreadId, } from "@t3tools/contracts"; @@ -275,7 +276,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; + provider: SourceControlProviderKind; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 9343e9842056..7df9904158b5 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, @@ -71,14 +72,14 @@ describe("add project shared logic", () => { it("uses HTTPS for repositories selected through a provider", () => { expect( getDefaultCloneUrl({ - provider: "github", + provider: SourceControlProviderKind.make("github"), url: "https://github.com/imputnet/helium", sshUrl: "git@github.com:imputnet/helium.git", }), ).toBe("https://github.com/imputnet/helium"); expect( getDefaultCloneUrl({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), url: "https://forgejo.example.test:8443/owner/repo.git", sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git", }), @@ -88,7 +89,7 @@ describe("add project shared logic", () => { it("preserves existing clone transport behavior for other providers", () => { expect( getDefaultCloneUrl({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), url: "https://gitlab.com/group/project.git", sshUrl: "git@gitlab.com:group/project.git", }), @@ -187,7 +188,7 @@ describe("add project shared logic", () => { versionControlSystems: [], sourceControlProviders: [ { - kind: "github", + kind: SourceControlProviderKind.make("github"), label: "GitHub", status: "available", installHint: "Install gh", @@ -201,7 +202,7 @@ describe("add project shared logic", () => { }, }, { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), label: "GitLab", status: "available", installHint: "Install glab", diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 8dd937d3a68d..f3b7bee7ef22 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -6,9 +6,9 @@ import type { ProjectId, ServerConfig, SourceControlDiscoveryResult, - SourceControlProviderKind, SourceControlRepositoryInfo, } from "@t3tools/contracts"; +import { SourceControlProviderKind } from "@t3tools/contracts"; import { newProjectFolderName } from "@t3tools/shared/path"; import * as Arr from "effect/Array"; import * as Option from "effect/Option"; @@ -25,10 +25,12 @@ import { } from "../state/projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; -export type AddProjectRemoteProviderKind = Extract< - SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" ->; +export type AddProjectRemoteProviderKind = + | "github" + | "gitlab" + | "forgejo" + | "bitbucket" + | "azure-devops"; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; export function canCreateProjectInEnvironment( @@ -190,7 +192,7 @@ export function buildAddProjectRemoteSourceReadiness( for (const source of ADD_PROJECT_REMOTE_SOURCES) { const kind = addProjectRemoteSourceProvider(source); if (!kind) continue; - const provider = providerByKind.get(kind); + const provider = providerByKind.get(SourceControlProviderKind.make(kind)); if (!provider) { readiness[source] = unavailable; continue; diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 24c258e2ae21..ad974ea6841a 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { EnvironmentId, @@ -36,7 +37,7 @@ const TARGET = new PrimaryConnectionTarget({ const PUBLISH_RESULT: SourceControlPublishRepositoryResult = { repository: { - provider: "github", + provider: SourceControlProviderKind.make("github"), nameWithOwner: "t3tools/t3code", url: "https://github.com/t3tools/t3code", sshUrl: "git@github.com:t3tools/t3code.git", @@ -140,7 +141,7 @@ describe("source control environment atoms", () => { environmentId: TARGET.environmentId, input: { cwd: "/repo", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "t3tools/t3code", visibility: "private", }, @@ -156,7 +157,7 @@ describe("source control environment atoms", () => { environmentId: TARGET.environmentId, input: { cwd: "/repo", - provider: "github", + provider: SourceControlProviderKind.make("github"), repository: "t3tools/t3code", visibility: "private", }, diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 3d979e852abc..2360d232895d 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "./sourceControl.ts"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; @@ -25,7 +26,7 @@ const LIST_RESULT: PullRequestListResult = { providers: [ { host: "github.com", - kind: "github", + kind: SourceControlProviderKind.make("github"), searchesOnHost: true, projectCount: 1, configured: true, @@ -33,7 +34,7 @@ const LIST_RESULT: PullRequestListResult = { }, { host: "gitlab.com", - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), searchesOnHost: true, projectCount: 1, configured: false, @@ -42,7 +43,7 @@ const LIST_RESULT: PullRequestListResult = { ], entries: [ { - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"], projectTitle: "t3code", @@ -75,12 +76,16 @@ describe("PullRequestListResult", () => { canonicalKey: "forge.example/team/repo", locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, }; - expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000"); - expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example"); + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo"))).toBe( + "forge.example:3000", + ); + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe( + "forge.example", + ); expect( pullRequestHostOf( { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } }, - "forgejo", + SourceControlProviderKind.make("forgejo"), ), ).toBe("forge.example"); }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 82e5dc1cd15f..031439f9a293 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1301,38 +1301,54 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T * 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: - "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: - "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", - unauthenticated: - "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", - }, - gitlab: { - missing: - "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", - unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", - }, - "azure-devops": { - missing: - "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", - unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", - }, - bitbucket: { - missing: - "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", - unauthenticated: - "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", - }, -}; +const PROVIDER_REQUIREMENT = new Map< + string, + { readonly missing: string; readonly unauthenticated: string } +>([ + [ + "github", + { + missing: + "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: + "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", + unauthenticated: + "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", + }, + ], + [ + "gitlab", + { + missing: + "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", + unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", + }, + ], + [ + "azure-devops", + { + missing: + "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.", + unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.", + }, + ], + [ + "bitbucket", + { + missing: + "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", + unauthenticated: + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", + }, + ], +]); /** * The host a project's repository is addressed below. `canonicalKey` is the normalized remote, @@ -1394,7 +1410,7 @@ export function pullRequestProviderRequirement( provider: SourceControlProviderKind, reason: PullRequestUnavailableReason, ): string | null { - const requirement = PROVIDER_REQUIREMENT[provider]; + const requirement = PROVIDER_REQUIREMENT.get(provider); if (requirement === undefined) return null; switch (reason) { case "cli-missing": @@ -1427,7 +1443,7 @@ export class PullRequestUnavailableError extends Schema.TaggedError { const current: VcsStatusResult = { isRepo: true, sourceControlProvider: { - kind: "github", + kind: SourceControlProviderKind.make("github"), name: "GitHub", baseUrl: "https://github.com", }, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index b72f35793de5..46d0f70fea5c 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -10,30 +11,38 @@ import { describe("source control presentation", () => { it("uses merge request terminology for GitLab", () => { - expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("gitlab"))).toEqual({ shortLabel: "MR", singular: "merge request", }); }); it("uses pull request terminology for GitHub-compatible providers", () => { - expect(getChangeRequestTerminologyForKind("github")).toEqual({ + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("github"))).toEqual({ shortLabel: "PR", singular: "pull request", }); - expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({ - shortLabel: "PR", - singular: "pull request", - }); - expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({ + expect( + getChangeRequestTerminologyForKind(SourceControlProviderKind.make("azure-devops")), + ).toEqual({ shortLabel: "PR", singular: "pull request", }); + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("bitbucket"))).toEqual( + { + shortLabel: "PR", + singular: "pull request", + }, + ); }); it("falls back to generic change request copy for unknown providers", () => { expect( - resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }), + resolveChangeRequestPresentation({ + kind: SourceControlProviderKind.make("unknown"), + name: "forge", + baseUrl: "", + }), ).toEqual( expect.objectContaining({ shortName: "change request", @@ -69,7 +78,7 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { }, ); } - expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({ + expect(getChangeRequestTerminologyForKind(SourceControlProviderKind.make("forgejo"))).toEqual({ shortLabel: "PR", singular: "pull request", }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index be09c017767e..f8c1f99c5d30 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,8 +1,5 @@ -import type { - RepositoryIdentity, - SourceControlProviderInfo, - SourceControlProviderKind, -} from "@t3tools/contracts"; +import { SourceControlProviderKind } from "@t3tools/contracts"; +import type { RepositoryIdentity, SourceControlProviderInfo } from "@t3tools/contracts"; export interface ChangeRequestPresentation { readonly icon: @@ -124,7 +121,7 @@ export function resolveChangeRequestPresentation( return BITBUCKET_CHANGE_REQUEST_PRESENTATION; case "gitcafe": return GITCAFE_CHANGE_REQUEST_PRESENTATION; - case "unknown": + default: return GENERIC_CHANGE_REQUEST_PRESENTATION; } } @@ -244,7 +241,11 @@ export function detectSourceControlProviderFromRemoteUrl( const hostname = parseHostName(host); if (isGitCafeHost(hostname)) { - return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + return { + kind: SourceControlProviderKind.make("gitcafe"), + name: "GitCafe", + baseUrl: toBaseUrl(hostname), + }; } if ( @@ -253,7 +254,7 @@ export function detectSourceControlProviderFromRemoteUrl( hasDnsLabel(hostname, "gitea") ) { return { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), name: "Forgejo", baseUrl: /^https?:/iu.test(remoteUrl.trim()) ? new URL(remoteUrl.trim()).origin @@ -263,7 +264,7 @@ export function detectSourceControlProviderFromRemoteUrl( if (isGitHubHost(hostname)) { return { - kind: "github", + kind: SourceControlProviderKind.make("github"), name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", baseUrl: toBaseUrl(host), }; @@ -271,7 +272,7 @@ export function detectSourceControlProviderFromRemoteUrl( if (isGitLabHost(hostname)) { return { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", baseUrl: toBaseUrl(host), }; @@ -279,7 +280,7 @@ export function detectSourceControlProviderFromRemoteUrl( if (isAzureDevOpsHost(hostname)) { return { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), name: "Azure DevOps", baseUrl: toBaseUrl(host), }; @@ -287,14 +288,14 @@ export function detectSourceControlProviderFromRemoteUrl( if (isBitbucketHost(hostname)) { return { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", baseUrl: toBaseUrl(host), }; } return { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: host, baseUrl: toBaseUrl(host), }; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index b30356bba7b5..965726c223b1 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -157,7 +158,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) => new PullRequestProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation, ...azureDevOpsProviderFailure(error), detail: error.detail, @@ -168,7 +169,7 @@ export const make = Effect.gen(function* () { const unsupported = (operation: string) => Effect.fail( new PullRequestProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation, reason: "failed", detail: "Azure DevOps reviews cannot be written from here yet.", @@ -296,7 +297,7 @@ export const make = Effect.gen(function* () { }; const provider: PullRequestProviderApi = { - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), capabilities: CAPABILITIES, // The bare repository name repeats across an organization; the project path does not. repositoryKey: ({ canonicalKey }) => canonicalKey, @@ -612,7 +613,7 @@ export const make = Effect.gen(function* () { listReviewerCandidates: () => Effect.fail( new PullRequestProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "listReviewerCandidates", reason: "failed", detail: "Azure DevOps cannot say who may review a pull request.", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index f0362851596d..f4a8b2ec9c0e 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -35,7 +36,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }); assert.deepStrictEqual(changeRequest, { - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), number: 42, title: "Add Azure provider", url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index f935fb808709..efecb2b3ec3a 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -39,7 +40,7 @@ function parseAzureAuth(input: SourceControlAuthProbeInput) { export const discovery = { type: "cli", - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), label: "Azure DevOps", executable: "az", versionArgs: ["--version"], @@ -67,7 +68,7 @@ function toChangeRequest(summary: { readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), number: summary.number, title: summary.title, url: summary.url, @@ -86,7 +87,7 @@ export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; return SourceControlProvider.SourceControlProvider.of({ - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return azure @@ -102,7 +103,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "listChangeRequests", command: error.command, cwd: input.cwd, @@ -121,7 +122,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "getChangeRequest", command: error.command, cwd: input.cwd, @@ -149,7 +150,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "createChangeRequest", command: error.command, cwd: input.cwd, @@ -167,7 +168,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "getRepositoryCloneUrls", command: error.command, cwd: input.cwd, @@ -184,7 +185,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "createRepository", command: error.command, cwd: input.cwd, @@ -201,7 +202,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "getDefaultBranch", command: error.command, cwd: input.cwd, @@ -221,7 +222,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 7a0c42c9f3ca..5545ece49f82 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`. * @@ -10,7 +11,7 @@ import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvide import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "azure-devops", + kind: SourceControlProviderKind.make("azure-devops"), make: Effect.all({ sourceControl: AzureDevOpsSourceControlProvider.make, discovery: Effect.succeed(AzureDevOpsSourceControlProvider.discovery), diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 0abcb7d95e7e..d09ec748b3ef 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -122,7 +123,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => new PullRequestProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation, ...bitbucketProviderFailure(error), // Every Bitbucket failure states its own fact; this names the operation around it, so @@ -157,7 +158,7 @@ export const make = Effect.gen(function* () { }; const provider: PullRequestProviderApi = { - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, // Bitbucket credentials come from the server's environment rather than a checkout, so the @@ -363,7 +364,7 @@ export const make = Effect.gen(function* () { setReaction: () => Effect.fail( new PullRequestProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "setReaction", reason: "failed", detail: "Bitbucket does not support reactions.", diff --git a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts index 52a15547c573..e3e4b669c5e7 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -36,7 +37,7 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( }); assert.deepStrictEqual(changeRequest, { - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), number: 42, title: "Add Bitbucket provider", url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", diff --git a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts index b9afd60bc389..e4b4b1311ab4 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -9,7 +10,7 @@ import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { return { - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), number: summary.number, title: summary.title, url: summary.url, @@ -34,7 +35,7 @@ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; return SourceControlProvider.SourceControlProvider.of({ - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); return bitbucket @@ -51,7 +52,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "listChangeRequests", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -69,7 +70,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getChangeRequest", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -97,7 +98,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "createChangeRequest", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -114,7 +115,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getRepositoryCloneUrls", cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -130,7 +131,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "createRepository", cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -151,7 +152,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "getDefaultBranch", cwd: input.cwd, detail: "Failed to get default branch.", @@ -171,7 +172,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "bitbucket", + provider: SourceControlProviderKind.make("bitbucket"), operation: "checkoutChangeRequest", cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( @@ -190,7 +191,7 @@ export const makeDiscovery = Effect.gen(function* () { return { type: "api", - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), label: "Bitbucket", installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 06cd225ab4e4..871970423e97 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the * Bitbucket REST API. @@ -11,7 +12,7 @@ import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "bitbucket", + kind: SourceControlProviderKind.make("bitbucket"), make: Effect.all({ sourceControl: BitbucketSourceControlProvider.make, discovery: BitbucketSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-core/src/server/SourceControlRateLimit.test.ts b/packages/source-control-core/src/server/SourceControlRateLimit.test.ts index e46005c73f0e..be0451954f18 100644 --- a/packages/source-control-core/src/server/SourceControlRateLimit.test.ts +++ b/packages/source-control-core/src/server/SourceControlRateLimit.test.ts @@ -1,10 +1,11 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -const github = { provider: "github" as const, host: "github.com" }; +const github = { provider: SourceControlProviderKind.make("github"), host: "github.com" }; it.effect("isolates cooldowns for verified credentials on the same host", () => Effect.gen(function* () { @@ -41,7 +42,7 @@ it.effect("backs off repeated rate limits until a successful request", () => const firstPause = yield* Effect.flip(limits.check(github)); assert.deepInclude(firstPause, { _tag: "SourceControlRateLimitPausedError", - provider: "github", + provider: SourceControlProviderKind.make("github"), host: "github.com", retryAt: 30_000, }); @@ -105,8 +106,20 @@ it.effect("keeps providers and hosts isolated", () => const lease = yield* limits.check(github); yield* limits.recordRateLimit({ ...github, lease }); - assert.equal(yield* limits.check({ provider: "gitlab", host: "github.com" }), 0); - assert.equal(yield* limits.check({ provider: "github", host: "github.example.com" }), 0); + assert.equal( + yield* limits.check({ + provider: SourceControlProviderKind.make("gitlab"), + host: "github.com", + }), + 0, + ); + assert.equal( + yield* limits.check({ + provider: SourceControlProviderKind.make("github"), + host: "github.example.com", + }), + 0, + ); }).pipe(Effect.provide(SourceControlRateLimit.layer)), ); diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 4a2d8afba121..51b3974a6bd2 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; @@ -67,7 +68,7 @@ export const make = Effect.gen(function* () { const cli = yield* ForgejoCli.ForgejoCli; const failure = (operation: string, detail: string, cause?: unknown) => new PullRequestProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation, reason: "failed", detail, @@ -78,7 +79,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new PullRequestProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation: input.path, detail: error.detail, cause: error, @@ -203,7 +204,7 @@ export const make = Effect.gen(function* () { const unsupported = (operation: string) => Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); const provider: PullRequestProviderApi = { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), capabilities: CAPABILITIES, getViewer, listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index e3dae3a6c529..468d1402eb1d 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -258,7 +259,11 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the }); const served = (baseUrl: string) => () => Effect.succeed({ - provider: { kind: "forgejo" as const, name: "Forgejo / Gitea", baseUrl }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo / Gitea", + baseUrl, + }, remoteName: "origin", remoteUrl: "", }); @@ -271,7 +276,7 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the }), { ...identity("http://forge.test:3000/git/team/repo.git"), - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), webUrl: "http://forge.test:3000/git/team/repo", }, ); @@ -557,7 +562,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () => cwd: "/repo", context: { provider: { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: "git.forgejo.local", baseUrl: "https://git.forgejo.local", }, @@ -577,7 +582,7 @@ it.effect("discovers Forgejo accounts and retains the server port", () => ), }); assert.deepStrictEqual(refined, { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), name: "Forgejo / Gitea", baseUrl: "http://forgejo.local:3000", }); @@ -622,7 +627,11 @@ it.effect("does not choose a default Forgejo login across ambiguous SSH server p const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ cwd: "/repo", context: { - provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, + provider: { + kind: SourceControlProviderKind.make("unknown"), + name: "Forgejo", + baseUrl: "https://forgejo.local", + }, remoteName: "origin", remoteUrl: "git@forgejo.local:maria/project.git", requestedHost: "forgejo.local:4000", @@ -732,7 +741,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP cwd: "/repo", repository: "forgejo/maria/project", context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://code.test/forgejo", + }, remoteName: "origin", remoteUrl: "https://code.test/forgejo/maria/project.git", }, @@ -744,7 +757,11 @@ it.effect("routes mounted Forgejo repositories without repeating the mount in AP cwd: "/repo", repository: "forgejo/project", context: { - provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "https://code.test/forgejo", + }, remoteName: "origin", remoteUrl: "ssh://git@code.test/forgejo/project.git", }, @@ -827,7 +844,7 @@ it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { repository: "maria/project", context: { provider: { - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), name: "Forgejo", baseUrl: "http://forgejo.local:3000", }, diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index ab20a027f777..4ea8e16ed35f 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -18,7 +19,7 @@ const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); export const discovery = { type: "cli", - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), label: "Forgejo / Gitea", executable: "tea", versionArgs: ["--version"], @@ -47,7 +48,13 @@ export const discovery = { remote, input.context.requestedHost, ); - return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null; + return login + ? { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo / Gitea", + baseUrl: login.url, + } + : null; }, installHint: "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.", @@ -60,7 +67,7 @@ export const makeDiscovery = Effect.gen(function* () { if (!listLogins) return discovery; return { type: "managed-cli", - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), label: discovery.label, installHint: discovery.installHint, probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) { @@ -150,7 +157,12 @@ export const makeDiscovery = Effect.gen(function* () { remoteUrl: input.context.remoteUrl, }).pipe(Effect.orElseSucceed(() => [])); const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); - if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url }; + if (login) + return { + kind: SourceControlProviderKind.make("forgejo"), + name: discovery.label, + baseUrl: login.url, + }; } return null; }, @@ -188,7 +200,7 @@ const refineRepositoryIdentity: NonNullable< const context = yield* resolveContext({ cwd: identity.rootPath, context: { - provider: { kind: "unknown", name: "Unknown", baseUrl: "" }, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, remoteName: identity.locator.remoteName, remoteUrl: identity.locator.remoteUrl, }, @@ -200,7 +212,11 @@ const refineRepositoryIdentity: NonNullable< !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`) ? remote.path.slice(basePath.length + 1) : remote.path; - return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` }; + return { + ...identity, + provider: SourceControlProviderKind.make("forgejo"), + webUrl: `${baseUrl}/${path}`, + }; }); const RepositorySchema = Schema.Struct({ @@ -245,7 +261,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause: unknown) => new SourceControlProviderError({ - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), operation, cwd, ...(isForgejoCliError(cause) ? { command: cause.command } : {}), @@ -272,7 +288,7 @@ export const make = Effect.gen(function* () { ); }); return SourceControlProvider.SourceControlProvider.of({ - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), repositoryNameFromRemoteUrl, refineRepositoryIdentity, listChangeRequests: (input) => diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index ae76d7c6409a..d2fe9ee4e4c3 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through * `fj` or `tea`. @@ -11,7 +12,7 @@ import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "forgejo", + kind: SourceControlProviderKind.make("forgejo"), make: Effect.all({ sourceControl: ForgejoSourceControlProvider.make, discovery: ForgejoSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts index affb9bae74c7..dc084d932532 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -26,7 +27,7 @@ export const ForgejoPullRequestSchema = Schema.Struct({ }); export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest { return { - provider: "forgejo", + provider: SourceControlProviderKind.make("forgejo"), number: raw.number, title: raw.title, url: raw.html_url, diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 7ea6a1e6eb87..70d8c107ea0b 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -176,7 +177,7 @@ export const make = Effect.gen(function* () { const failure = (operation: string, detail: string, cause?: unknown) => new PullRequestProviderError({ - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), operation, reason: "failed", detail, @@ -207,7 +208,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new PullRequestProviderError({ - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), operation, reason: error.reason, detail: error.detail, @@ -475,7 +476,7 @@ export const make = Effect.gen(function* () { }); const provider: PullRequestProviderApi = { - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), capabilities: CAPABILITIES, getViewer: (input) => read( diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index aaaf5d0e90ff..996925033eb3 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: * @@ -87,7 +88,11 @@ describe("GitCafeSourceControlProvider", () => { ); }); const context = { - provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + provider: { + kind: SourceControlProviderKind.make("gitcafe"), + name: "GitCafe", + baseUrl: `https://${host}`, + }, remoteName: "origin", remoteUrl, }; @@ -220,7 +225,11 @@ describe("GitCafeSourceControlProvider", () => { remoteUrl === undefined ? undefined : { - provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + provider: { + kind: SourceControlProviderKind.make("gitcafe"), + name: "GitCafe", + baseUrl: "https://git.cafe", + }, remoteName: "origin", remoteUrl, }; diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index 5d55bd72bac3..5e271261f1e5 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; @@ -59,7 +60,7 @@ const cliFailure = (output: string) => export const discovery = { type: "cli", - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), label: "GitCafe", executable: "cafe", versionArgs: ["--version"], @@ -150,7 +151,7 @@ export const make = Effect.gen(function* () { const error = (operation: string, cwd: string, detail: string, cause?: unknown) => new SourceControlProviderError({ - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), operation, cwd, detail, @@ -269,7 +270,7 @@ export const make = Effect.gen(function* () { ); const owner = target.repository.split("/")[0] ?? null; return { - provider: "gitcafe", + provider: SourceControlProviderKind.make("gitcafe"), number: pull.number, title: pull.title, url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, @@ -290,7 +291,7 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, getChangeRequest: getPull, listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 467614c2ec1c..6fc49c393f21 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull * requests through GitCafe's REST API on git.cafe and staging.git.cafe. @@ -11,7 +12,7 @@ import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "gitcafe", + kind: SourceControlProviderKind.make("gitcafe"), make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), diff --git a/packages/source-control-github/src/server/GitHubApi.ts b/packages/source-control-github/src/server/GitHubApi.ts index 111281a0484e..29d8b2904b1f 100644 --- a/packages/source-control-github/src/server/GitHubApi.ts +++ b/packages/source-control-github/src/server/GitHubApi.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -389,7 +390,7 @@ export const make = Effect.gen(function* () { // this reads today is `core`; a `search/` read would need its own resource here. A refusal // still pauses the whole host, the key PullRequestService records its own backoff under. const resource = input.graphql === true ? "graphql" : "core"; - const key = { provider: "github" as const, host }; + const key = { provider: SourceControlProviderKind.make("github"), host }; const run = Effect.gen(function* () { const lease = yield* quota .admit(host, resource, { allowReserve: input.allowReserve }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 7b74bd368b81..b99972d58af0 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import * as Effect from "effect/Effect"; import type { @@ -202,7 +203,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => new PullRequestProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation, ...gitHubProviderFailure(error), detail: error.message, @@ -261,7 +262,7 @@ export const make = Effect.gen(function* () { ); const provider: PullRequestProviderApi = { - kind: "github", + kind: SourceControlProviderKind.make("github"), capabilities: CAPABILITIES, mergeMessageRewrite: removeAgentCredits, getRoutingIdentity: (input) => diff --git a/packages/source-control-github/src/server/GitHubQuota.ts b/packages/source-control-github/src/server/GitHubQuota.ts index 5dcd5469b9ed..3bdffb80e629 100644 --- a/packages/source-control-github/src/server/GitHubQuota.ts +++ b/packages/source-control-github/src/server/GitHubQuota.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -86,7 +87,7 @@ const make = Effect.gen(function* () { const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; if (snapshot.remaining >= floor) return; return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), host: host.trim().toLowerCase(), retryAt: snapshot.resetAtMs, }); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 7a902965b7aa..73475840ae22 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -137,7 +138,11 @@ const makeProvider = (api: Partial) => GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); const githubContext = (host: string) => ({ - provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: `https://${host}`, + }, remoteName: "origin", remoteUrl: `git@${host}:acme/web.git`, }); @@ -1067,7 +1072,7 @@ it.live.each([ cwd: "/repo", context: { provider: { - kind: "github" as const, + kind: SourceControlProviderKind.make("github"), name: "GitHub Self-Hosted", baseUrl: "https://code.example.test", }, @@ -1184,7 +1189,11 @@ it.effect.each([ const input = { cwd: "/repo", context: { - provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + provider: { + kind: SourceControlProviderKind.make("github"), + name: "GitHub", + baseUrl: `https://${host}`, + }, remoteName: "origin", remoteUrl: `git@${host}:me/web.git`, }, diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 6cf169a1b754..66ac324aaed5 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -62,7 +63,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ); function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { - return { provider: "github", ...record }; + return { provider: SourceControlProviderKind.make("github"), ...record }; } function authAccounts(accounts: ReadonlyArray) { @@ -164,7 +165,7 @@ export function parseGitHubAuth( export const discovery = { type: "cli", - kind: "github", + kind: SourceControlProviderKind.make("github"), label: "GitHub", executable: "gh", versionArgs: ["--version"], @@ -271,7 +272,7 @@ export const makeDiscovery = Effect.gen(function* () { Effect.map((known) => known ? ({ - kind: "github", + kind: SourceControlProviderKind.make("github"), name: "GitHub Self-Hosted", baseUrl: context.provider.baseUrl, } as const) @@ -878,7 +879,7 @@ export const make = Effect.gen(function* () { ) => (error: GitHubFailure) => new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation, cwd, ...(context?.reference === undefined @@ -915,7 +916,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "resolveLink", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -927,7 +928,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "github", + provider: SourceControlProviderKind.make("github"), operation: "resolveLink.decode", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -939,7 +940,7 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ - kind: "github", + kind: SourceControlProviderKind.make("github"), // `gh pr list --head` filters on the head ref name alone and accepts anything, so an // `owner:branch` or `remote:branch` selector silently lists zero pull requests while // spending a GraphQL call; the bare branch is always among the selectors. Without the owner, diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index d57ba07b2017..cc13f4f78fa3 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests * through the GitHub REST and GraphQL APIs. @@ -11,7 +12,7 @@ import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "github", + kind: SourceControlProviderKind.make("github"), make: Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 1b5b987cd5ac..db6548eb18f3 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, @@ -111,7 +112,7 @@ export const make = Effect.gen(function* () { const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => new PullRequestProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation, ...gitLabProviderFailure(error), detail: error.detail, @@ -119,7 +120,7 @@ export const make = Effect.gen(function* () { }); const provider: PullRequestProviderApi = { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), capabilities: CAPABILITIES, getViewer: (input) => diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index 513c2e2fc3b5..ccfc49aff208 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -38,7 +39,7 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = }); assert.deepStrictEqual(changeRequest, { - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), number: 42, title: "Add GitLab provider", url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", @@ -189,7 +190,7 @@ it("refines unknown GitLab remotes with mixed-case provider hosts", () => { cwd: "/repo", context: { provider: { - kind: "unknown", + kind: SourceControlProviderKind.make("unknown"), name: "Self-Hosted.Example.Test", baseUrl: "https://Self-Hosted.Example.Test", }, diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index ef63f11cfdf2..6c15029d0e3f 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -25,7 +26,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { return { - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), number: summary.number, title: summary.title, url: summary.url, @@ -90,7 +91,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } return { - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), name: "GitLab Self-Hosted", baseUrl: input.context.provider.baseUrl, } as const; @@ -98,7 +99,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn export const discovery = { type: "cli", - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), label: "GitLab", executable: "glab", versionArgs: ["--version"], @@ -127,7 +128,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "resolveLink", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -139,7 +140,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "resolveLink.decode", cwd: input.cwd, detail: "The linked subject could not be read.", @@ -151,7 +152,7 @@ export const make = Effect.gen(function* () { }); return SourceControlProvider.SourceControlProvider.of({ - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), resolveLink: (input) => { // Automatic enrichment must not send ambient CLI credentials to a host from message text. if (input.url.host !== "gitlab.com") return undefined; @@ -179,7 +180,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "listChangeRequests", command: error.command, cwd: input.cwd, @@ -198,7 +199,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getChangeRequest", command: error.command, cwd: input.cwd, @@ -226,7 +227,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "createChangeRequest", command: error.command, cwd: input.cwd, @@ -244,7 +245,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getRepositoryCloneUrls", command: error.command, cwd: input.cwd, @@ -261,7 +262,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "createRepository", command: error.command, cwd: input.cwd, @@ -278,7 +279,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "getDefaultBranch", command: error.command, cwd: input.cwd, @@ -292,7 +293,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (error) => new SourceControlProviderError({ - provider: "gitlab", + provider: SourceControlProviderKind.make("gitlab"), operation: "checkoutChangeRequest", command: error.command, cwd: input.cwd, diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 29327a35019b..56d75ccc64de 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,3 +1,4 @@ +import { SourceControlProviderKind } from "@t3tools/contracts"; /** * The GitLab driver: repository operations, discovery, and merge requests through `glab`. * @@ -10,7 +11,7 @@ import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; export const driver = defineSourceControlDriver({ - kind: "gitlab", + kind: SourceControlProviderKind.make("gitlab"), make: Effect.all({ sourceControl: GitLabSourceControlProvider.make, discovery: Effect.succeed(GitLabSourceControlProvider.discovery), From 7bc732804222946c0c9b23af057484e4412698fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:06:59 -0700 Subject: [PATCH 030/250] feat(source-control): each host package ships a client definition (#17746) Co-authored-by: Claude Opus 5.5 --- packages/client-runtime/package.json | 11 ++ .../src/sourceControlClients.test.ts | 17 ++ .../src/sourceControlClients.ts | 41 +++++ .../source-control-azure-devops/package.json | 4 + .../src/client/definition.ts | 33 ++++ .../source-control-bitbucket/package.json | 4 + .../src/client/definition.ts | 39 ++++ packages/source-control-core/package.json | 4 + .../src/client/definition.ts | 166 ++++++++++++++++++ packages/source-control-forgejo/package.json | 4 + .../src/client/definition.ts | 52 ++++++ packages/source-control-gitcafe/package.json | 4 + .../src/client/definition.ts | 45 +++++ packages/source-control-github/package.json | 4 + .../src/client/definition.ts | 33 ++++ packages/source-control-gitlab/package.json | 4 + .../src/client/definition.ts | 30 ++++ pnpm-lock.yaml | 21 +++ 18 files changed, 516 insertions(+) create mode 100644 packages/client-runtime/src/sourceControlClients.test.ts create mode 100644 packages/client-runtime/src/sourceControlClients.ts create mode 100644 packages/source-control-azure-devops/src/client/definition.ts create mode 100644 packages/source-control-bitbucket/src/client/definition.ts create mode 100644 packages/source-control-core/src/client/definition.ts create mode 100644 packages/source-control-forgejo/src/client/definition.ts create mode 100644 packages/source-control-gitcafe/src/client/definition.ts create mode 100644 packages/source-control-github/src/client/definition.ts create mode 100644 packages/source-control-gitlab/src/client/definition.ts diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index aea6ead6b943..987ba69fe02b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -390,6 +390,10 @@ "./state/mcp-apps": { "types": "./src/state/mcpApps.ts", "default": "./src/state/mcpApps.ts" + }, + "./source-control-clients": { + "types": "./src/sourceControlClients.ts", + "default": "./src/sourceControlClients.ts" } }, "scripts": { @@ -399,6 +403,13 @@ "dependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/source-control-azure-devops": "workspace:*", + "@t3tools/source-control-bitbucket": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitcafe": "workspace:*", + "@t3tools/source-control-github": "workspace:*", + "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", "three": "0.180.0" }, diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts new file mode 100644 index 000000000000..11e1e1c09c9c --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sourceControlClients } from "./sourceControlClients.ts"; + +describe("sourceControlClients", () => { + it("reads a missing kind as the first host and an unshipped kind as the generic one", () => { + expect(sourceControlClients.get(undefined).kind).toBe("github"); + expect(sourceControlClients.get("forkhost").kind).toBe("unknown"); + }); + + it("gives a change request URL to the first host whose shape it has", () => { + // GitCafe and Forgejo share `/pulls/`; GitCafe only claims its own hostnames. + const kindOf = (url: string) => sourceControlClients.findByChangeRequestUrl(url)?.kind; + expect(kindOf("https://git.cafe/acme/web/pulls/7")).toBe("gitcafe"); + expect(kindOf("https://codeberg.org/acme/web/pulls/7")).toBe("forgejo"); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts new file mode 100644 index 000000000000..108644a04b4d --- /dev/null +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -0,0 +1,41 @@ +/** + * The source control host definitions web and mobile ship, and the one lookup they use. + * + * Adding a host means writing its `@t3tools/source-control-` package with a + * `./client/definition` entry and adding it here; clients then label, list, and check out its + * change requests without further branches. + * + * @module client-runtime/sourceControlClients + */ +import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; +import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; +import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; + +export { + type ChangeRequestTerminology, + type SourceControlClientDefinition, +} from "@t3tools/source-control-core/client/definition"; +import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; +import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; +import * as GitHub from "@t3tools/source-control-github/client/definition"; +import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; + +/** + * GitHub comes first because a repository that has not reported its host yet reads as GitHub, + * which is what clients have always shown there. A change request URL goes to the first host + * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before + * Forgejo, which claims it everywhere. Pickers sort by readiness and label, not by this order. + */ +const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ + GitHub.definition, + GitLab.definition, + GitCafe.definition, + Forgejo.definition, + Bitbucket.definition, + AzureDevOps.definition, +]; + +/** `definitions` lists the built-in hosts in that order, for pickers that offer every host. */ +export const sourceControlClients = makeSourceControlClientRegistry( + BUILT_IN_SOURCE_CONTROL_CLIENTS, +); diff --git a/packages/source-control-azure-devops/package.json b/packages/source-control-azure-devops/package.json index 10347850bf19..4e5f9747c379 100644 --- a/packages/source-control-azure-devops/package.json +++ b/packages/source-control-azure-devops/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/AzureDevOpsCli": { "types": "./src/server/AzureDevOpsCli.ts", "import": "./src/server/AzureDevOpsCli.ts" diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts new file mode 100644 index 000000000000..5b935c8925dc --- /dev/null +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -0,0 +1,33 @@ +/** + * Azure DevOps's client definition. Browser- and React Native-safe. + * + * @module source-control-azure-devops/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("azure-devops"), + label: "Azure DevOps", + pickerLabel: "Azure DevOps", + icon: "azure-devops", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "project/repository", + // `az repos` reads need the checkout's organization and project, so a bare hostname names no + // repository this client can act on. + publicHost: null, + publishDescription: "dev.azure.com", + publishHost: () => "dev.azure.com", + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, + checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), +}); diff --git a/packages/source-control-bitbucket/package.json b/packages/source-control-bitbucket/package.json index c878093ef54e..890c4c34cbe5 100644 --- a/packages/source-control-bitbucket/package.json +++ b/packages/source-control-bitbucket/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/BitbucketApi": { "types": "./src/server/BitbucketApi.ts", "import": "./src/server/BitbucketApi.ts" diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts new file mode 100644 index 000000000000..25010be99e93 --- /dev/null +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -0,0 +1,39 @@ +/** + * Bitbucket's client definition. Browser- and React Native-safe. + * + * @module source-control-bitbucket/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; +const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("bitbucket"), + label: "Bitbucket", + pickerLabel: "Bitbucket", + icon: "bitbucket", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "workspace/repository", + publicHost: "bitbucket.org", + publishDescription: "bitbucket.org", + publishHost: () => "bitbucket.org", + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/pull-requests/${number}`, + // Bitbucket has no checkout CLI, so clone the head branch from its own repository. + checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => + headRepositoryNameWithOwner && + repositoryName.test(headRepositoryNameWithOwner) && + safeShellArgument.test(headBranch) + ? `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}` + : null, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), +}); diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json index ee1045b278cd..d463190918b2 100644 --- a/packages/source-control-core/package.json +++ b/packages/source-control-core/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/diffFileRevisions": { "types": "./src/server/diffFileRevisions.ts", "import": "./src/server/diffFileRevisions.ts" diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts new file mode 100644 index 000000000000..8db86cfcaf4d --- /dev/null +++ b/packages/source-control-core/src/client/definition.ts @@ -0,0 +1,166 @@ +/** + * The client half of a source control host package. + * + * Every host package exports a `SourceControlClientDefinition` from its `./client/definition` + * entry point, the way `./server/driver` is its server entry. Web, mobile, and client-runtime + * read labels, change request nouns, picker copy, and checkout commands from these definitions, + * so a new host needs no per-kind branches in client code. + * + * Definitions are browser- and React Native-safe: plain data and pure functions, no server or + * platform UI imports. + * + * @module source-control-core/client/definition + */ +import { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts"; + +/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ +export interface ChangeRequestTerminology { + readonly shortLabel: string; + readonly singular: string; +} + +/** Where a project's repository lives, for building its change request URLs. */ +export interface ChangeRequestUrlInput { + /** The host below which the repository is addressed, as `pullRequestHostOf` reports it. */ + readonly host: string; + /** The repository path below the host. */ + readonly repository: string; + readonly number: number; + /** The checkout's remote URL, whose origin some hosts serve their web pages from. */ + readonly remoteUrl?: string | undefined; + /** The repository's browser URL, when the server resolved one from a hosting account. */ + readonly webUrl?: string | undefined; +} + +/** The change request a checkout command is built for. */ +export interface ChangeRequestCheckoutInput { + readonly number: number; + readonly headBranch: string; + readonly headRepositoryNameWithOwner?: string | null | undefined; + /** The repository's web URL, for hosts whose checkout fetches from it. */ + readonly repositoryUrl?: string | null | undefined; +} + +export interface SourceControlClientDefinition { + readonly kind: SourceControlProviderKind; + /** The host's name, as in "Open on GitHub". */ + readonly label: string; + /** How the clone and publish pickers name the host; Forgejo's covers Gitea too. */ + readonly pickerLabel: string; + /** Glyph key each client maps to its own icon; a key a client lacks draws the generic glyph. */ + readonly icon: string; + readonly changeRequest: ChangeRequestTerminology; + /** Placeholder for a repository path on this host, such as `owner/repo`. */ + readonly repositoryPathHint: string; + /** + * The hostname a change request reference can be attributed to before any repository + * identity is known, or null when a hostname alone does not name this host. + */ + readonly publicHost: string | null; + /** Subtitle beside the host in the publish picker. */ + readonly publishDescription: string; + /** Where a publish lands, given the host the server is signed in to, if it reported one. */ + readonly publishHost: (signedInHost: string | null) => string; + /** Which of a repository's clone URLs a new clone uses. */ + readonly defaultCloneTransport: "https" | "ssh"; + /** The web URL of a change request, or null when this client cannot build one for the host. */ + readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + /** A shell command that checks the change request out, or null when it cannot be built. */ + readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; + /** A comment author's profile page, or null where the host has none clients can link. */ + readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; + /** + * The repository URL that `#123` and commit SHAs in change request text link under, using + * GitHub's `/issues/` and `/commit/` routes, or null where the host does not route them so. + */ + readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; + /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ + readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; + /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ + readonly isChangeRequestUrl: (url: string) => boolean; +} + +/** + * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number. + * Matching the parsed path keeps a query or fragment that mentions another host's route out. + */ +export function isChangeRequestPath(url: string, route: string): boolean { + try { + const path = new URL(url).pathname; + const at = path.indexOf(route); + return at > 0 && /^\d+(?:\/|$)/u.test(path.slice(at + route.length)); + } catch { + return false; + } +} + +export function defineSourceControlClient( + definition: Definition, +): Definition { + return definition; +} + +/** What clients show for a host they ship no definition for, including `unknown`. */ +const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { + kind: SourceControlProviderKind.make("unknown"), + label: "source control", + pickerLabel: "source control", + icon: "change-request", + changeRequest: { shortLabel: "change request", singular: "change request" }, + repositoryPathHint: "URL", + publicHost: null, + publishDescription: "Your signed-in server", + publishHost: (signedInHost) => signedInHost ?? "your server", + defaultCloneTransport: "ssh", + changeRequestUrl: () => null, + checkoutCommand: () => null, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + isChangeRequestUrl: () => false, +}; + +/** The host definitions a client loaded. */ +export interface SourceControlClientRegistry { + readonly definitions: ReadonlyArray; + /** + * The definition for a kind. No kind at all, before a repository reports its host, reads as + * the first definition; a kind this client lacks, `unknown` included, reads as + * `UNKNOWN_SOURCE_CONTROL_CLIENT`. + */ + readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; + /** The definition for a kind, or `undefined` for one this client lacks. */ + readonly find: (kind: string) => SourceControlClientDefinition | undefined; + /** The definition whose public instance is `hostname`, if any. */ + readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; + /** The definition whose change request path shape `url` has, if any. */ + readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; + /** The name of the host a change request URL is on, for copy about that change request. */ + readonly hostLabelForChangeRequestUrl: (url: string) => string; +} + +export function makeSourceControlClientRegistry( + definitions: ReadonlyArray, +): SourceControlClientRegistry { + const byKind = new Map(); + for (const definition of definitions) { + if (byKind.has(definition.kind)) { + throw new Error(`Source control host '${definition.kind}' is defined more than once.`); + } + byKind.set(definition.kind, definition); + } + return { + definitions, + get: (kind) => + (kind == null ? definitions[0] : byKind.get(kind)) ?? UNKNOWN_SOURCE_CONTROL_CLIENT, + find: (kind) => byKind.get(kind), + findByPublicHost: (hostname) => { + const host = hostname.toLowerCase(); + return definitions.find((definition) => definition.publicHost === host); + }, + findByChangeRequestUrl: (url) => + definitions.find((definition) => definition.isChangeRequestUrl(url)), + hostLabelForChangeRequestUrl: (url) => + definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", + }; +} diff --git a/packages/source-control-forgejo/package.json b/packages/source-control-forgejo/package.json index 4c6b534e2e81..a33de46a6de8 100644 --- a/packages/source-control-forgejo/package.json +++ b/packages/source-control-forgejo/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts new file mode 100644 index 000000000000..aab19fcde22d --- /dev/null +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -0,0 +1,52 @@ +/** + * Forgejo's client definition, which covers Gitea as well. Browser- and React Native-safe. + * + * @module source-control-forgejo/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("forgejo"), + label: "Forgejo", + pickerLabel: "Forgejo / Gitea", + icon: "forgejo", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "owner/repo", + // Forgejo and Gitea are self-hosted, so no hostname names them. + publicHost: null, + publishDescription: "Your signed-in server", + publishHost: (signedInHost) => signedInHost ?? "your server", + defaultCloneTransport: "https", + // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the + // origin, which may carry a port. SSH remotes say nothing about the web origin. + changeRequestUrl: ({ host, repository, number, remoteUrl, webUrl }) => { + if (webUrl) return `${webUrl.replace(/\/+$/, "")}/pulls/${number}`; + try { + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return `${remote.origin}/${repository}/pulls/${number}`; + } + } catch { + // Not an HTTP remote. + } + return `https://${host}/${repository}/pulls/${number}`; + }, + // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. + checkoutCommand: ({ number, repositoryUrl }) => + repositoryUrl + ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` + : null, + authorProfileUrl: () => null, + // Forgejo refuses a change request review without a summary, even with inline comments. + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: (verdict) => verdict === "request-changes", + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), +}); diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json index eeda68c5e92d..7107cd1e47f0 100644 --- a/packages/source-control-gitcafe/package.json +++ b/packages/source-control-gitcafe/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts new file mode 100644 index 000000000000..6407d57828d1 --- /dev/null +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -0,0 +1,45 @@ +/** + * GitCafe's client definition. Browser- and React Native-safe. + * + * @module source-control-gitcafe/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +/** GitCafe has no self-hosted installs: production and staging are its only hosts. */ +const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("gitcafe"), + label: "GitCafe", + pickerLabel: "GitCafe", + icon: "gitcafe", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "owner/repo", + // Left unattributed so a reference with no repository identity behind it gets no checkout + // command guessed from its hostname alone. + publicHost: null, + publishDescription: "git.cafe", + publishHost: () => "git.cafe", + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/pulls/${number}`, + checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. + isChangeRequestUrl: (url) => { + try { + return ( + GITCAFE_HOSTS.has(new URL(url).hostname.toLowerCase()) && + isChangeRequestPath(url, "/pulls/") + ); + } catch { + return false; + } + }, +}); diff --git a/packages/source-control-github/package.json b/packages/source-control-github/package.json index 46a8965976fd..7cd6bed31b83 100644 --- a/packages/source-control-github/package.json +++ b/packages/source-control-github/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts new file mode 100644 index 000000000000..fe79b7f91cc3 --- /dev/null +++ b/packages/source-control-github/src/client/definition.ts @@ -0,0 +1,33 @@ +/** + * GitHub's client definition. Browser- and React Native-safe. + * + * @module source-control-github/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("github"), + label: "GitHub", + pickerLabel: "GitHub", + icon: "github", + changeRequest: { shortLabel: "PR", singular: "pull request" }, + repositoryPathHint: "owner/repo", + publicHost: "github.com", + publishDescription: "github.com", + publishHost: () => "github.com", + defaultCloneTransport: "https", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/pull/${number}`, + checkoutCommand: ({ number }) => `gh pr checkout ${number}`, + authorProfileUrl: (login, repositoryUrl) => + login.endsWith("[bot]") + ? null + : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), + referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, + reviewSummaryRequired: () => false, + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), +}); diff --git a/packages/source-control-gitlab/package.json b/packages/source-control-gitlab/package.json index 6b99c878ccb8..def5c117c558 100644 --- a/packages/source-control-gitlab/package.json +++ b/packages/source-control-gitlab/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./client/definition": { + "types": "./src/client/definition.ts", + "import": "./src/client/definition.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts new file mode 100644 index 000000000000..23c88b1b6d2f --- /dev/null +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -0,0 +1,30 @@ +/** + * GitLab's client definition. Browser- and React Native-safe. + * + * @module source-control-gitlab/client/definition + */ +import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + defineSourceControlClient, + isChangeRequestPath, +} from "@t3tools/source-control-core/client/definition"; + +export const definition = defineSourceControlClient({ + kind: SourceControlProviderKind.make("gitlab"), + label: "GitLab", + pickerLabel: "GitLab", + icon: "gitlab", + changeRequest: { shortLabel: "MR", singular: "merge request" }, + repositoryPathHint: "group/project", + publicHost: "gitlab.com", + publishDescription: "gitlab.com", + publishHost: () => "gitlab.com", + defaultCloneTransport: "ssh", + changeRequestUrl: ({ host, repository, number }) => + `https://${host}/${repository}/-/merge_requests/${number}`, + checkoutCommand: ({ number }) => `glab mr checkout ${number}`, + authorProfileUrl: () => null, + referenceAutolinkRepositoryUrl: () => null, + reviewSummaryRequired: () => false, + isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249f45a453cc..dcd65d4ce34e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1078,6 +1078,27 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../shared + '@t3tools/source-control-azure-devops': + specifier: workspace:* + version: link:../source-control-azure-devops + '@t3tools/source-control-bitbucket': + specifier: workspace:* + version: link:../source-control-bitbucket + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + '@t3tools/source-control-forgejo': + specifier: workspace:* + version: link:../source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../source-control-gitcafe + '@t3tools/source-control-github': + specifier: workspace:* + version: link:../source-control-github + '@t3tools/source-control-gitlab': + specifier: workspace:* + version: link:../source-control-gitlab effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) From c8a603307c2d50f8fb9d302f4b51f20fd9f91b33 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:07:00 -0700 Subject: [PATCH 031/250] refactor(client-runtime): add project clone sources come from host definitions (#17756) Co-authored-by: Claude Opus 5.5 --- .../src/components/SourceControlIcon.tsx | 16 +- .../projects/AddProjectRepositoryRoute.tsx | 14 +- .../features/projects/AddProjectScreen.tsx | 29 ++-- .../src/state/thread-pr-presentation.ts | 6 +- .../src/operations/projects.test.ts | 9 +- .../client-runtime/src/operations/projects.ts | 147 ++++++------------ 6 files changed, 85 insertions(+), 136 deletions(-) diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index 4c7fd9e05baa..0893dca83a12 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,12 +1,11 @@ -import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg"; +import Svg, { Circle, Defs, G, LinearGradient, Path, Rect, Stop } from "react-native-svg"; import { withUniwind } from "uniwind"; const ThemedSvg = withUniwind(Svg); -export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; - +/** Draws a host's glyph by its client definition's `icon` key; null for a key it has no art for. */ export function SourceControlIcon(props: { - readonly kind: SourceControlIconKind; + readonly kind: string; readonly size?: number; readonly color?: string; readonly colorClassName?: string; @@ -252,5 +251,14 @@ export function SourceControlIcon(props: { /> ); + case "gitcafe": + // GitCafe's own mark from https://git.cafe/favicon.svg, inset like the web icon. + return ( + + + + ); + default: + return null; } } diff --git a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx index 17700e6599c9..8812c7347a5b 100644 --- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx @@ -1,6 +1,9 @@ import type { StaticScreenProps } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; +import { + addProjectRemoteSourceLabel, + parseAddProjectRemoteSource, +} from "@t3tools/client-runtime/operations/projects"; import { AddProjectRepositoryScreen } from "./AddProjectScreen"; @@ -14,14 +17,7 @@ export function AddProjectRepositoryRoute({ }: StaticScreenProps) { const params = route.params ?? {}; const source = Array.isArray(params.source) ? params.source[0] : params.source; - const title = - source === "github" || - source === "gitlab" || - source === "forgejo" || - source === "bitbucket" || - source === "azure-devops" - ? addProjectRemoteSourceLabel(source) - : "Git URL"; + const title = addProjectRemoteSourceLabel(parseAddProjectRemoteSource(source)); return ( <> diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 2f58bae6c4d0..a72660f79603 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -1,4 +1,3 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import { MaterialListRow } from "../../components/MaterialListRow"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; @@ -20,6 +19,7 @@ import { getNewProjectGitHubTarget, getNewProjectPathPreview, normalizePastedCloneUrl, + parseAddProjectRemoteSource, resolveAddProjectPath, sortAddProjectProviderSources, type AddProjectRemoteSource, @@ -49,6 +49,7 @@ import { type EnvironmentMachineKind, ProjectId, resolveEnvironmentMachineKind, + SourceControlProviderKind, } from "@t3tools/contracts"; import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; @@ -71,6 +72,7 @@ import { AppText as Text, AppTextInput as TextInput } from "../../components/App import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -123,18 +125,7 @@ function stringParam(value: string | string[] | undefined): string | null { } function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource { - const source = stringParam(value); - if ( - source === "url" || - source === "github" || - source === "gitlab" || - source === "forgejo" || - source === "bitbucket" || - source === "azure-devops" - ) { - return source; - } - return "url"; + return parseAddProjectRemoteSource(stringParam(value)); } function SectionTitle(props: { readonly children: string }) { @@ -499,7 +490,7 @@ function SourceControlRow(props: { /> ) : ( @@ -659,13 +650,13 @@ export function AddProjectSourceScreen() { key={candidate} source={candidate} selectedEnvironmentId={selectedEnvironment.environmentId} - ready={canCloneProject && readiness[candidate].ready} + ready={canCloneProject && readiness(candidate).ready} hint={ !canCloneProject ? "This connection cannot clone projects." - : readiness[candidate].ready + : readiness(candidate).ready ? addProjectRemoteSourcePathHint(candidate) - : (readiness[candidate].hint ?? "") + : (readiness(candidate).hint ?? "") } isFirst={false} /> @@ -813,7 +804,7 @@ export function AddProjectRepositoryScreen(props: { const result = await lookupRepositoryQuery({ environmentId: environment.environmentId, input: { - provider: SourceControlProviderKind.make(provider), + provider, repository: repositoryInput.trim(), }, }); @@ -1153,7 +1144,7 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s } icon={ diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 21234b7ff150..270e7d405c74 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -4,7 +4,7 @@ import type { ThreadPullRequestLink, VcsStatusResult, } from "@t3tools/contracts"; -import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveThreadCurrentPullRequestLink, @@ -39,7 +39,7 @@ export function presentThreadPr( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): ThreadPrPresentation { - const presentation = resolveChangeRequestPresentation(provider); + const { changeRequest } = sourceControlClients.get(provider?.kind); const isDraft = pr.state === "open" && pr.isDraft === true; return { kind: "pull-request", @@ -50,7 +50,7 @@ export function presentThreadPr( updatedAt: pr.updatedAt ?? null, url: pr.url, label: String(pr.number), - accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, + accessibilityLabel: `#${pr.number} ${changeRequest.singular} ${isDraft ? "draft" : pr.state}`, textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 7df9904158b5..daf04d8b94e3 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -219,9 +219,12 @@ describe("add project shared logic", () => { }; const readiness = buildAddProjectRemoteSourceReadiness(discovery); - expect(readiness.url.ready).toBe(true); - expect(readiness.github.ready).toBe(true); - expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" }); + expect(readiness("url").ready).toBe(true); + expect(readiness(SourceControlProviderKind.make("github")).ready).toBe(true); + expect(readiness(SourceControlProviderKind.make("gitlab"))).toEqual({ + ready: false, + hint: "Run glab auth login", + }); expect(sortAddProjectProviderSources(readiness)[0]).toBe("github"); }); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index f3b7bee7ef22..8f46b4426739 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -24,14 +24,10 @@ import { resolveProjectPathForDispatch, } from "../state/projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; +import { sourceControlClients } from "../sourceControlClients.ts"; -export type AddProjectRemoteProviderKind = - | "github" - | "gitlab" - | "forgejo" - | "bitbucket" - | "azure-devops"; -export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; +/** A host to clone from, or `url` for a pasted clone URL. */ +export type AddProjectRemoteSource = SourceControlProviderKind | "url"; export function canCreateProjectInEnvironment( connectionPhase: EnvironmentConnectionPhase | null | undefined, @@ -52,10 +48,15 @@ export function availableScratchWorkspaceRoot( : null; } -export type AddProjectRemoteSourceReadiness = Record< - AddProjectRemoteSource, - { readonly ready: boolean; readonly hint: string | null } ->; +export interface AddProjectRemoteSourceReadinessEntry { + readonly ready: boolean; + readonly hint: string | null; +} + +/** Whether each clone source can be used on an environment, and why not. */ +export type AddProjectRemoteSourceReadiness = ( + source: AddProjectRemoteSource, +) => AddProjectRemoteSourceReadinessEntry; export type AddProjectCloneFlow = | { @@ -72,62 +73,31 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ - "url", - "github", - "gitlab", - "forgejo", - "bitbucket", - "azure-devops", -]; - -const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ - "github", - "gitlab", - "forgejo", - "bitbucket", - "azure-devops", -]; +/** The hosts the clone picker offers, in the order the built-in definitions list them. */ +const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = + sourceControlClients.definitions.map((definition) => definition.kind); export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { - switch (source) { - case "github": - return "GitHub"; - case "forgejo": - return "Forgejo / Gitea"; - case "gitlab": - return "GitLab"; - case "bitbucket": - return "Bitbucket"; - case "azure-devops": - return "Azure DevOps"; - case "url": - return "Git URL"; - } + return source === "url" ? "Git URL" : sourceControlClients.get(source).pickerLabel; } export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { - switch (source) { - case "forgejo": - case "github": - return "owner/repo"; - case "gitlab": - return "group/project"; - case "bitbucket": - return "workspace/repository"; - case "azure-devops": - return "project/repository"; - case "url": - return "URL"; - } + return source === "url" ? "URL" : sourceControlClients.get(source).repositoryPathHint; } export function addProjectRemoteSourceProvider( source: AddProjectRemoteSource, -): AddProjectRemoteProviderKind | null { +): SourceControlProviderKind | null { return source === "url" ? null : source; } +/** A clone source named in a route or link, or `url` for anything this client does not ship. */ +export function parseAddProjectRemoteSource( + value: string | null | undefined, +): AddProjectRemoteSource { + return (value ? sourceControlClients.find(value)?.kind : undefined) ?? "url"; +} + const GITHUB_REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; @@ -139,18 +109,18 @@ export function normalizePastedCloneUrl(input: string): string { return `https://github.com/${repository}`; } -/** GitHub and Forgejo default to HTTPS; other providers retain their existing SSH default. */ +/** The clone URL for the transport the repository's host defaults to. */ export function getDefaultCloneUrl( repository: Pick, ): string { - return repository.provider === "github" || repository.provider === "forgejo" + return sourceControlClients.get(repository.provider).defaultCloneTransport === "https" ? repository.url : repository.sshUrl; } export function sortAddProjectProviderSources( - readinessBySource: AddProjectRemoteSourceReadiness, -): ReadonlyArray { + readiness: AddProjectRemoteSourceReadiness, +): ReadonlyArray { return Arr.sort( ADD_PROJECT_REMOTE_PROVIDER_SOURCES, Order.mapInput( @@ -158,61 +128,41 @@ export function sortAddProjectProviderSources( ready: Order.flip(Order.Boolean), label: Order.String, }), - (source: AddProjectRemoteProviderKind) => ({ - ready: readinessBySource[source].ready, + (source: SourceControlProviderKind) => ({ + ready: readiness(source).ready, label: addProjectRemoteSourceLabel(source), }), ), ); } +const READY: AddProjectRemoteSourceReadinessEntry = { ready: true, hint: null }; +const UNAVAILABLE: AddProjectRemoteSourceReadinessEntry = { + ready: false, + hint: "Provider status unavailable. Open Source Control settings and rescan.", +}; + export function buildAddProjectRemoteSourceReadiness( discovery: SourceControlDiscoveryResult | null, ): AddProjectRemoteSourceReadiness { - const unavailable = { - ready: false, - hint: "Provider status unavailable. Open Source Control settings and rescan.", - } as const; - const readiness: AddProjectRemoteSourceReadiness = { - url: { ready: true, hint: null }, - github: unavailable, - gitlab: unavailable, - forgejo: unavailable, - bitbucket: unavailable, - "azure-devops": unavailable, - }; - - if (!discovery) { - return readiness; - } - const providerByKind = new Map( - discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), + (discovery?.sourceControlProviders ?? []).map((provider) => [provider.kind, provider]), ); - for (const source of ADD_PROJECT_REMOTE_SOURCES) { - const kind = addProjectRemoteSourceProvider(source); - if (!kind) continue; - const provider = providerByKind.get(SourceControlProviderKind.make(kind)); - if (!provider) { - readiness[source] = unavailable; - continue; - } - if (provider.status !== "available") { - readiness[source] = { ready: false, hint: provider.installHint }; - continue; - } + return (source) => { + if (source === "url") return READY; + const provider = providerByKind.get(source); + if (!provider) return UNAVAILABLE; + if (provider.status !== "available") return { ready: false, hint: provider.installHint }; if (provider.auth.status === "unauthenticated") { - readiness[source] = { + return { ready: false, hint: Option.getOrNull(provider.auth.detail) ?? `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, }; - continue; } - readiness[source] = { ready: true, hint: null }; - } - return readiness; + return READY; + }; } export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string { @@ -283,8 +233,9 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string): export function getNewProjectGitHubTarget( discovery: SourceControlDiscoveryResult | null, ): { readonly account: string | null } | null { - if (!buildAddProjectRemoteSourceReadiness(discovery).github.ready) return null; - const github = discovery?.sourceControlProviders.find((provider) => provider.kind === "github"); + const kind = SourceControlProviderKind.make("github"); + if (!buildAddProjectRemoteSourceReadiness(discovery)(kind).ready) return null; + const github = discovery?.sourceControlProviders.find((provider) => provider.kind === kind); return { account: github ? Option.getOrNull(github.auth.account) : null }; } From 579159d6c6e96f9c18358e492fbf0b8af740e80c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:07:00 -0700 Subject: [PATCH 032/250] refactor(web): host presentation and behavior come from client definitions (#17757) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/CommandPalette.tsx | 220 ++++-------------- .../src/components/GitActionsControl.logic.ts | 15 +- apps/web/src/components/GitActionsControl.tsx | 166 ++++--------- .../src/components/ThreadStatusIndicators.tsx | 29 +-- .../LinkPullRequestDialog.logic.test.ts | 7 +- .../pullRequest/LinkPullRequestDialog.tsx | 23 +- .../pullRequest/PullRequestComposer.tsx | 5 +- .../pullRequest/PullRequestDetailPanel.tsx | 26 ++- .../components/pullRequest/PullRequestRow.tsx | 3 +- .../pullRequest/PullRequestStackMenu.tsx | 7 +- .../pullRequest/PullRequestSummaryTab.tsx | 8 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 3 +- .../pullRequest/pullRequestDetail.logic.ts | 59 ++--- .../pullRequest/pullRequestLinkContextMenu.ts | 17 +- .../settings/SourceControlSettings.tsx | 27 +-- apps/web/src/sourceControlPresentation.ts | 107 ++++----- .../src/sourceControlClients.ts | 1 + packages/shared/src/sourceControl.ts | 19 -- .../src/client/definition.ts | 2 +- 19 files changed, 228 insertions(+), 516 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b85b0ca2cdc3..472e15b98c42 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -14,7 +14,15 @@ import { getNewProjectGitHubTarget, getNewProjectPathPreview, normalizePastedCloneUrl, + addProjectRemoteSourceLabel, + addProjectRemoteSourcePathHint, + addProjectRemoteSourceProvider, + buildAddProjectRemoteSourceReadiness, + sortAddProjectProviderSources, + type AddProjectRemoteSource, + type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; @@ -187,14 +195,8 @@ import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { - AzureDevOpsIcon, - BitbucketIcon, - GitCafeIcon, - GitHubIcon, - GitLabIcon, - ForgejoIcon, -} from "./Icons"; +import { GitHubIcon } from "./Icons"; +import { sourceControlIcon } from "~/sourceControlPresentation"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -257,15 +259,6 @@ interface AddProjectEnvironmentOption { readonly status: string; } -type AddProjectRemoteProviderKind = - | "github" - | "gitlab" - | "forgejo" - | "bitbucket" - | "azure-devops" - | "gitcafe"; -type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; - type AddProjectCloneFlow = | { readonly step: "repository"; @@ -281,83 +274,10 @@ type AddProjectCloneFlow = readonly remoteUrl: string; }; -const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ - "url", - "github", - "gitlab", - "forgejo", - "bitbucket", - "azure-devops", - "gitcafe", -]; -const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ - "github", - "gitlab", - "forgejo", - "bitbucket", - "azure-devops", - "gitcafe", -]; - -function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { - switch (source) { - case "github": - return "GitHub"; - case "forgejo": - return "Forgejo / Gitea"; - case "gitlab": - return "GitLab"; - case "bitbucket": - return "Bitbucket"; - case "azure-devops": - return "Azure DevOps"; - case "gitcafe": - return "GitCafe"; - case "url": - return "Git URL"; - } -} - -function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { - switch (source) { - case "forgejo": - case "github": - case "gitcafe": - return "owner/repo"; - case "gitlab": - return "group/project"; - case "bitbucket": - return "workspace/repository"; - case "azure-devops": - return "project/repository"; - case "url": - return "URL"; - } -} - -function remoteProjectSourceProvider( - source: AddProjectRemoteSource, -): AddProjectRemoteProviderKind | null { - return source === "url" ? null : source; -} - function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { - switch (source) { - case "github": - return ; - case "forgejo": - return ; - case "gitlab": - return ; - case "bitbucket": - return ; - case "azure-devops": - return ; - case "gitcafe": - return ; - case "url": - return ; - } + if (source === "url") return ; + const Icon = sourceControlIcon(sourceControlClients.get(source)); + return ; } function projectFaviconIcon(project: Project): ReactNode { @@ -370,82 +290,26 @@ function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string if (flow.source === "url") { return "Enter Git clone URL"; } - return `Enter ${remoteProjectSourceLabel(flow.source)} repository (${remoteProjectSourcePathHint(flow.source)})`; -} - -function sourceProviderKind(source: AddProjectRemoteSource): AddProjectRemoteProviderKind | null { - return source === "url" ? null : source; -} - -function sortAddProjectProviderSources( - readinessBySource: AddProjectRemoteSourceReadiness, -): ReadonlyArray { - return REMOTE_PROJECT_PROVIDER_SOURCES.toSorted((left, right) => { - const leftReady = readinessBySource[left].ready; - const rightReady = readinessBySource[right].ready; - if (leftReady !== rightReady) { - return leftReady ? -1 : 1; - } - return remoteProjectSourceLabel(left).localeCompare(remoteProjectSourceLabel(right)); - }); + return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`; } -type AddProjectRemoteSourceReadiness = Record< - AddProjectRemoteSource, - { readonly ready: boolean; readonly hint: string | null } ->; - -function buildAddProjectRemoteSourceReadiness( +/** The palette's readiness, pointing at Settings by its menu path. */ +function buildPaletteRemoteSourceReadiness( discovery: SourceControlDiscoveryResult | null, ): AddProjectRemoteSourceReadiness { - const unavailable = { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - } as const; - const defaultReadiness: AddProjectRemoteSourceReadiness = { - url: { ready: true, hint: null }, - github: unavailable, - gitlab: unavailable, - forgejo: unavailable, - bitbucket: unavailable, - "azure-devops": unavailable, - gitcafe: unavailable, + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + return (source) => { + const entry = readiness(source); + return entry.hint === null + ? entry + : { + ...entry, + hint: entry.hint.replace( + "Open Source Control settings", + "Open Settings -> Source Control", + ), + }; }; - - if (!discovery) { - return defaultReadiness; - } - - const providerByKind = new Map( - discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), - ); - const readiness = { ...defaultReadiness }; - - for (const source of REMOTE_PROJECT_SOURCES) { - const kind = sourceProviderKind(source); - if (!kind) continue; - const provider = providerByKind.get(SourceControlProviderKind.make(kind)); - if (!provider) { - readiness[source] = unavailable; - continue; - } - if (provider.status !== "available") { - readiness[source] = { ready: false, hint: provider.installHint }; - continue; - } - if (provider.auth.status === "unauthenticated") { - readiness[source] = { - ready: false, - hint: - Option.getOrNull(provider.auth.detail) ?? - `${provider.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - continue; - } - readiness[source] = { ready: true, hint: null }; - } - - return readiness; } function errorMessage(error: unknown): string { @@ -1673,13 +1537,13 @@ function OpenCommandPaletteDialog(props: { ]; for (const source of orderedSources) { - const label = remoteProjectSourceLabel(source); + const label = addProjectRemoteSourceLabel(source); const title = source === "url" ? "Git URL" : `${label} repository`; const description = source === "url" ? "Clone from a remote URL" - : `Clone ${label} ${remoteProjectSourcePathHint(source)}`; - const readiness = readinessBySource[source]; + : `Clone ${label} ${addProjectRemoteSourcePathHint(source)}`; + const readiness = readinessBySource(source); const disabledHint = readiness.hint; const titleTrailingContent = readiness.ready ? undefined : ( @@ -1767,7 +1631,7 @@ function OpenCommandPaletteDialog(props: { addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness( + buildPaletteRemoteSourceReadiness( browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, ), ), @@ -2105,13 +1969,13 @@ function OpenCommandPaletteDialog(props: { "repository", "repo", "git", - "github", - "gitlab", - "forgejo", - "bitbucket", - "azure", - "devops", - "gitcafe", + // Every host's name, spelled as a word: "azure devops" searches as `azure` and `devops`. + ...sourceControlClients.definitions.flatMap((definition) => + definition.pickerLabel + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(Boolean), + ), "url", "environment", ], @@ -2356,7 +2220,7 @@ function OpenCommandPaletteDialog(props: { currentView.groups[0]?.value === sourceSelectionViewValue ? buildAddProjectSourceGroups( addProjectEnvironmentId, - buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), + buildPaletteRemoteSourceReadiness(sourceControlDiscovery.data), ) : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups @@ -2606,7 +2470,7 @@ function OpenCommandPaletteDialog(props: { return; } - const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); + const provider = addProjectRemoteSourceProvider(addProjectCloneFlow.source); if (!provider) { const destinationPath = getCloneDestinationPath( getDefaultCloneParentPath(addProjectCloneFlow.environmentId), @@ -2631,7 +2495,7 @@ function OpenCommandPaletteDialog(props: { const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { - provider: SourceControlProviderKind.make(provider), + provider, repository: rawRepository, }, }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 5422628825a6..af6206625c41 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -4,11 +4,8 @@ import type { VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; -import { - DEFAULT_CHANGE_REQUEST_TERMINOLOGY, - getChangeRequestTerminology, - type ChangeRequestTerminology, -} from "../sourceControlPresentation"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -68,9 +65,7 @@ export function resolveGitActionResultToastTiming( function resolveChangeRequestTerminology( gitStatus: VcsStatusResult | null, ): ChangeRequestTerminology { - return gitStatus?.sourceControlProvider - ? getChangeRequestTerminology(gitStatus.sourceControlProvider) - : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + return sourceControlClients.get(gitStatus?.sourceControlProvider?.kind).changeRequest; } export function resolveGitActionProgressPresentation(input: { @@ -129,7 +124,7 @@ export function buildGitActionProgressStages(input: { shouldPushBeforePr?: boolean; terminology?: ChangeRequestTerminology; }): string[] { - const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest; const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ @@ -406,7 +401,7 @@ export function resolveDefaultBranchActionDialogCopy(input: { }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; - const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 703694ce1651..494a841e8f21 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -44,14 +44,6 @@ import { GlobeIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { - AzureDevOpsIcon, - BitbucketIcon, - GitHubIcon, - GitLabIcon, - ForgejoIcon, - GitCafeIcon, -} from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; @@ -131,7 +123,8 @@ import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; -import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps { @@ -158,14 +151,6 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } -type PublishProviderKind = - | "github" - | "gitlab" - | "forgejo" - | "bitbucket" - | "azure-devops" - | "gitcafe"; - type GitActionToastId = ReturnType; interface RunGitActionWithToastInput { @@ -203,77 +188,25 @@ function requestVcsStatusRefresh( } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; -const PUBLISH_PROVIDER_OPTIONS = [ - { - value: "forgejo", - label: "Forgejo / Gitea", - description: "Your signed-in server", - host: "your server", - pathPlaceholder: "owner/repo", - Icon: ForgejoIcon, - }, - { - value: "github", - label: "GitHub", - description: "github.com", - host: "github.com", - pathPlaceholder: "owner/repo", - Icon: GitHubIcon, - }, - { - value: "gitlab", - label: "GitLab", - description: "gitlab.com", - host: "gitlab.com", - pathPlaceholder: "group/project", - Icon: GitLabIcon, - }, - { - value: "bitbucket", - label: "Bitbucket", - description: "bitbucket.org", - host: "bitbucket.org", - pathPlaceholder: "workspace/repository", - Icon: BitbucketIcon, - }, - { - value: "azure-devops", - label: "Azure DevOps", - description: "dev.azure.com", - host: "dev.azure.com", - pathPlaceholder: "project/repository", - Icon: AzureDevOpsIcon, - }, - { - value: "gitcafe", - label: "GitCafe", - description: "git.cafe", - host: "git.cafe", - pathPlaceholder: "owner/repo", - Icon: GitCafeIcon, - }, -] as const satisfies ReadonlyArray<{ - readonly value: PublishProviderKind; - readonly label: string; - readonly description: string; - readonly host: string; - readonly pathPlaceholder: string; - readonly Icon: typeof GitHubIcon; -}>; - -function publishProviderOption(provider: PublishProviderKind) { +/** Every built-in host, in the order the built-in definitions list them. */ +const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({ + value: definition.kind, + label: definition.pickerLabel, + description: definition.publishDescription, + pathPlaceholder: definition.repositoryPathHint, + definition, + Icon: sourceControlIcon(definition), +})); + +function publishProviderOption(provider: SourceControlProviderKind) { return ( PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? - PUBLISH_PROVIDER_OPTIONS[0] + PUBLISH_PROVIDER_OPTIONS[0]! ); } -function isPublishProviderKind(provider: string): provider is PublishProviderKind { - return PUBLISH_PROVIDER_OPTIONS.some((option) => option.value === provider); -} - function getPublishProviderReadiness(input: { - provider: PublishProviderKind; + provider: SourceControlProviderKind; sourceControlProviders: ReadonlyArray; }): { readonly ready: boolean; readonly hint: string | null } { const discovered = input.sourceControlProviders.find( @@ -553,7 +486,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { }), ); const [selectedPublishProvider, setSelectedPublishProvider] = - useState(null); + useState(null); const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); const [publishVisibility, setPublishVisibility] = useState("private"); @@ -574,43 +507,32 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); const publishAccountByProvider = useMemo(() => { - const accounts: Record = { - github: null, - gitlab: null, - forgejo: null, - bitbucket: null, - "azure-devops": null, - gitcafe: null, - }; + const accounts = new Map(); for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - const kind: string = provider.kind; - if (isPublishProviderKind(kind)) { - accounts[kind] = Option.getOrNull(provider.auth.account); - } + accounts.set(provider.kind, Option.getOrNull(provider.auth.account)); } return accounts; }, [sourceControlDiscovery.data]); const publishProviderReadiness = useMemo(() => { const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; - return Object.fromEntries( + const readiness = new Map( PUBLISH_PROVIDER_OPTIONS.map((option) => [ option.value, - getPublishProviderReadiness({ - provider: option.value, - sourceControlProviders, - }), + getPublishProviderReadiness({ provider: option.value, sourceControlProviders }), ]), - ) as Record; + ); + return (provider: SourceControlProviderKind) => + readiness.get(provider) ?? { ready: false, hint: null }; }, [sourceControlDiscovery.data]); const hasReadyPublishProvider = useMemo( - () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), + () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready), [publishProviderReadiness], ); const sortedPublishProviderOptions = useMemo( () => PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { - const leftReady = publishProviderReadiness[left.value].ready; - const rightReady = publishProviderReadiness[right.value].ready; + const leftReady = publishProviderReadiness(left.value).ready; + const rightReady = publishProviderReadiness(right.value).ready; if (leftReady !== rightReady) { return leftReady ? -1 : 1; } @@ -619,26 +541,26 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { [publishProviderReadiness], ); const firstReadyPublishProvider = sortedPublishProviderOptions.find( - (option) => publishProviderReadiness[option.value].ready, + (option) => publishProviderReadiness(option.value).ready, )?.value; const publishProvider = - selectedPublishProvider !== null && publishProviderReadiness[selectedPublishProvider].ready + selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready ? selectedPublishProvider - : (firstReadyPublishProvider ?? selectedPublishProvider ?? "github"); - const selectedPublishProviderReadiness = publishProviderReadiness[publishProvider]; - const publishRepositoryPrefill = publishAccountByProvider[publishProvider] - ? `${publishAccountByProvider[publishProvider]}/` - : ""; + : (firstReadyPublishProvider ?? + selectedPublishProvider ?? + sourceControlClients.get(undefined).kind); + const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider); + const publishAccount = publishAccountByProvider.get(publishProvider) ?? null; + const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = - publishProvider === "forgejo" - ? (Option.getOrNull( - sourceControlDiscovery.data?.sourceControlProviders.find( - (provider) => provider.kind === "forgejo", - )?.auth.host ?? Option.none(), - ) ?? currentPublishProvider.host) - : currentPublishProvider.host; + const publishHost = currentPublishProvider.definition.publishHost( + Option.getOrNull( + sourceControlDiscovery.data?.sourceControlProviders.find( + (provider) => provider.kind === publishProvider, + )?.auth.host ?? Option.none(), + ), + ); const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; const publishProviderLabel = currentPublishProvider.label; const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; @@ -672,7 +594,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { void (async () => { const result = await publishRepositoryAction.run({ - provider: SourceControlProviderKind.make(publishProvider), + provider: publishProvider, repository: publishRepository.trim(), visibility: publishVisibility, remoteName: publishRemoteName.trim() || "origin", @@ -757,14 +679,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { { - setSelectedPublishProvider(value as PublishProviderKind); + setSelectedPublishProvider(value as SourceControlProviderKind); setPublishRepositoryOverride(null); }} aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2" > {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; + const readiness = publishProviderReadiness(option.value); const isSelected = publishProvider === option.value && readiness.ready; if (!readiness.ready) { return ( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 146fac555c72..cab6f03f1294 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,14 +6,12 @@ import { import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveEnvironmentMachineKind, type EnvironmentId, type ThreadLinkedPullRequest, type ThreadPullRequestLink, type VcsStatusResult, - SourceControlProviderKind, } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; @@ -38,7 +36,7 @@ import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../sta import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; -import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveThreadLastVisitedAt, resolveThreadStatusPill, @@ -131,19 +129,10 @@ export function linkedPullRequestSnapshotStatus( ): LinkedThreadPullRequestStatus | null { const snapshot = link.snapshot; if (snapshot === null) return null; - // GitCafe's `/pulls/` routes look like Forgejo's, so its two hosts are told apart first. - const kind = - detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "gitcafe" - ? "gitcafe" - : link.url.includes("/-/merge_requests/") - ? "gitlab" - : link.url.includes("/pullrequest/") - ? "azure-devops" - : link.url.includes("/pull-requests/") - ? "bitbucket" - : link.url.includes("/pulls/") - ? "forgejo" - : "github"; + // A link records no host kind, so read it from the URL's change request path. + const kind = ( + sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined) + ).kind; return { pr: { number: link.number, @@ -155,7 +144,7 @@ export function linkedPullRequestSnapshotStatus( baseRef: snapshot.baseBranch, ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), }, - sourceControlProvider: { kind: SourceControlProviderKind.make(kind), name: kind, baseUrl: "" }, + sourceControlProvider: { kind, name: kind, baseUrl: "" }, }; } @@ -442,12 +431,12 @@ export function prStatusIndicator( provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { if (!pr) return null; - const presentation = resolveChangeRequestPresentation(provider); + const { shortLabel } = sourceControlClients.get(provider?.kind).changeRequest; const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true }); - const tooltipLead = `${presentation.shortName} #${pr.number} - ${state.label}`; + const tooltipLead = `${shortLabel} #${pr.number} - ${state.label}`; return { - label: `${presentation.shortName} ${state.label.toLowerCase()}`, + label: `${shortLabel} ${state.label.toLowerCase()}`, colorClass: state.toneClassName, Icon: state.Icon, tooltip: `${tooltipLead}: ${pr.title}`, diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts index 6d11ce7cd944..75dbc7ea461a 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; + +import { resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; + +const changeRequestWebUrl = (kind: string, host: string, repository: string, number: number) => + sourceControlClients.get(kind).changeRequestUrl({ host, repository, number }); const project = { host: "github.com", diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx index 5ae5f8abd8bf..dccb1f76e4ef 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -1,10 +1,5 @@ -import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; -export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; -import { - pullRequestHostOf, - type ScopedThreadRef, - type SourceControlProviderKind, -} from "@t3tools/contracts"; +import { pullRequestHostOf, type ScopedThreadRef } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -138,15 +133,19 @@ function LinkPullRequestDialog({ identity.displayName ?? (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); if (repository === null) return null; - const kind = identity.provider as SourceControlProviderKind; - const host = pullRequestHostOf(identity, kind); + const definition = sourceControlClients.get(identity.provider ?? "unknown"); + const host = pullRequestHostOf(identity, definition.kind); return { host, repository, webUrl: (number: number) => - kind === "forgejo" && identity.webUrl - ? `${identity.webUrl.replace(/\/+$/, "")}/pulls/${number}` - : changeRequestWebUrl(kind, host, repository, number, identity.locator.remoteUrl), + definition.changeRequestUrl({ + host, + repository, + number, + remoteUrl: identity.locator.remoteUrl, + webUrl: identity.webUrl, + }), }; }, [environmentProjects, projectId]); const linking = usePullRequestLinking(threadRef.environmentId); diff --git a/apps/web/src/components/pullRequest/PullRequestComposer.tsx b/apps/web/src/components/pullRequest/PullRequestComposer.tsx index e00df846f39e..f341f475e366 100644 --- a/apps/web/src/components/pullRequest/PullRequestComposer.tsx +++ b/apps/web/src/components/pullRequest/PullRequestComposer.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; /** * The single floating control over a pull request. Commenting on the change and submitting the * review that carries the Code tab's line comments used to float as two buttons that crowded @@ -158,7 +159,9 @@ export function PullRequestComposer({ environmentId={environmentId} reference={reference} verdicts={verdicts} - requestChangesSummaryRequired={detail.provider === "forgejo"} + requestChangesSummaryRequired={sourceControlClients + .get(detail.provider) + .reviewSummaryRequired("request-changes")} textareaRef={reviewRef} pending={reviewPending} onPendingChange={setReviewPending} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 137be41c9606..e977c302be34 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -83,6 +83,7 @@ import { } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { PullRequestStackMenu } from "./PullRequestStackMenu"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -302,7 +303,7 @@ const openNumberContextMenu = ( event.stopPropagation(); void showPullRequestLinkContextMenu({ url: detail.url, - openLabel: openOnHostLabel(detail.provider), + openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)), position: { x: event.clientX, y: event.clientY }, }); }; @@ -732,16 +733,18 @@ export function PullRequestDetailPanel({ if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); }, [detail?.autoMergeMethod, pullRequestKey]); const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const hostClient = sourceControlClients.get(detail?.provider); const markdownContext = useMemo( - () => ({ repositoryUrl: detail?.provider === "github" ? repositoryUrl : null, threadRef }), - [detail?.provider, repositoryUrl, threadRef], + () => ({ + repositoryUrl: + repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl), + threadRef, + }), + [hostClient, repositoryUrl, threadRef], ); const authorProfileUrl = - detail?.provider === "github" && - detail.author !== null && - !detail.author.login.endsWith("[bot]") && - repositoryUrl !== null - ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString() + detail?.author && repositoryUrl !== null + ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl) : null; const checkoutCommand = handoffSummary ? pullRequestCheckoutCommand( @@ -1725,7 +1728,7 @@ export function PullRequestDetailPanel({ } /> - {openOnHostLabel(detail.provider)} + {openOnHostLabel(hostClient)} ) : null} @@ -1780,7 +1783,7 @@ export function PullRequestDetailPanel({ } /> - {openOnHostLabel(detail.provider)} + {openOnHostLabel(hostClient)} void readLocalApi()?.shell.openExternal(detail.url)}> - {openOnHostLabel(detail.provider)} + {openOnHostLabel(hostClient)} copyReference(detail.url, "PR link")}> diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 63d249c7acf3..a1467e79f05f 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { SearchIcon } from "lucide-react"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; import { @@ -158,7 +159,7 @@ function PullRequestRowImpl({ event.stopPropagation(); void showPullRequestLinkContextMenu({ url: entry.url, - openLabel: openOnHostLabel(entry.provider), + openLabel: openOnHostLabel(sourceControlClients.get(entry.provider)), position: { x: event.clientX, y: event.clientY }, }); }} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx index ff8e886d14af..125b71f5020a 100644 --- a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx @@ -28,6 +28,7 @@ import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; import { PullRequestGlyph } from "./pullRequestIcons"; export function PullRequestStackMenu({ + hostLabel, stack, reference, environmentId, @@ -39,6 +40,8 @@ export function PullRequestStackMenu({ notice, onRetry, }: { + /** The host's name, which reports what it did with the stack. */ + hostLabel: string; notice?: string | null; onRetry?: (() => void) | undefined; stack: PullRequestStack; @@ -117,7 +120,7 @@ export function PullRequestStackMenu({ title: action === "merge" ? "Stack merge request completed" : "Stack rebased", description: action === "merge" - ? "GitHub merged the stack or added it to its merge queue." + ? `${hostLabel} merged the stack or added it to its merge queue.` : undefined, }); } @@ -232,7 +235,7 @@ export function PullRequestStackMenu({ {confirmation === "merge" - ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.` + ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. ${hostLabel} checks their rules before merging or queueing them and rebases the remaining stack after merging.` : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 9fae094ce564..bfda1209f9cb 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { useAtomCommand } from "~/state/use-atom-command"; import type { EnvironmentId, @@ -83,10 +84,9 @@ function CommentIdentity({ detail: PullRequestDetailView; }) { const actor = comment.author; - const profileUrl = - detail.provider === "github" && actor && !actor.login.endsWith("[bot]") - ? new URL(`/${encodeURIComponent(actor.login)}`, detail.url).toString() - : null; + const profileUrl = actor + ? sourceControlClients.get(detail.provider).authorProfileUrl(actor.login, detail.url) + : null; return (
diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 237528702b4d..62924fe0a9d1 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -1,5 +1,6 @@ import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { resolveThreadPullRequestChains, visibleThreadPullRequests, @@ -210,7 +211,7 @@ function LinkRow({ {stack.kind === "native" - ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.` + ? `${sourceControlClients.hostLabelForChangeRequestUrl(link.url)} stack of ${stack.size}: merging a layer lands the ones below it.` : `${stack.size} pull requests chained by base branch.`} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 0a367768a2de..cd9d25220649 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,3 +1,4 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import * as Schema from "effect/Schema"; import { @@ -68,9 +69,6 @@ export function resolvePullRequestMergeMethod( return allowed[0] ?? "merge"; } -const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; -const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; - export type PullRequestPrimaryControl = | "resolve" | "ready" @@ -117,32 +115,9 @@ export function pullRequestCheckoutCommand( headRepositoryNameWithOwner?: string | null, repositoryUrl?: string | null, ): string | null { - switch (provider) { - case "github": - return `gh pr checkout ${number}`; - case "gitlab": - return `glab mr checkout ${number}`; - case "forgejo": - return repositoryUrl - ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` - : null; - case "azure-devops": - return `az repos pr checkout --id ${number}`; - case "gitcafe": - return `cafe pr checkout ${number}`; - case "bitbucket": { - if ( - !headRepositoryNameWithOwner || - !bitbucketRepositoryName.test(headRepositoryNameWithOwner) || - !safeShellArgument.test(headBranch) - ) { - return null; - } - return `git clone --single-branch --branch ${headBranch} https://bitbucket.org/${headRepositoryNameWithOwner}.git t3code-pr-${number}`; - } - default: - return null; - } + return sourceControlClients + .get(provider) + .checkoutCommand({ number, headBranch, headRepositoryNameWithOwner, repositoryUrl }); } /** Build a checkout command from identity metadata while the detail request is still pending. */ @@ -151,15 +126,23 @@ export function loadingPullRequestCheckoutCommand( identity: RepositoryIdentity | null | undefined, ): string | null { const host = reference.host?.trim().toLowerCase(); - const provider = - identity?.provider ?? - (host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null); - if (provider !== "github" && provider !== "gitlab" && provider !== "azure-devops") return null; - const kind = SourceControlProviderKind.make(provider); - if (identity?.provider !== undefined && host && pullRequestHostOf(identity, kind) !== host) { + // Before the detail loads, only the number is known, so only hosts whose command needs nothing + // else qualify. Without an identity, only a public hostname names the host. + const definition = identity?.provider + ? sourceControlClients.find(identity.provider) + : host + ? sourceControlClients.findByPublicHost(host) + : undefined; + const command = definition?.checkoutCommand({ number: reference.number, headBranch: "" }); + if (!definition || !command) return null; + if ( + identity?.provider !== undefined && + host && + pullRequestHostOf(identity, definition.kind) !== host + ) { return null; } - return pullRequestCheckoutCommand(kind, reference.number, ""); + return command; } /** Activity changes only when the same host resource reports a newer revision. */ @@ -967,7 +950,9 @@ export function buildFixFindingsHandoff(input: { ? ["Failing checks:", ...includedChecks.map((check) => `> ${check}`)] : []), ...(input.commentsTruncated - ? ["The conversation was truncated; more review comments may exist on GitHub."] + ? [ + `The conversation was truncated; more review comments may exist on ${sourceControlClients.hostLabelForChangeRequestUrl(input.url)}.`, + ] : []), ...(omitted > 0 ? [`${omitted} further findings were omitted.`] : []), ...(includedThreads.length === 0 && diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 6b673ad98325..7789f6062cb4 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -1,4 +1,8 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { + type SourceControlClientDefinition, + UNKNOWN_SOURCE_CONTROL_CLIENT, +} from "@t3tools/client-runtime/source-control-clients"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { readLocalApi } from "~/localApi"; @@ -8,17 +12,8 @@ import { toastManager } from "../ui/toast"; export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; /** Named for the host rather than "externally": the point is where you will land. */ -const OPEN_ON_HOST_LABELS: Partial> = { - github: "Open on GitHub", - gitlab: "Open on GitLab", - forgejo: "Open on Forgejo", - bitbucket: "Open on Bitbucket", - gitcafe: "Open on GitCafe", - "azure-devops": "Open on Azure DevOps", -}; - -export const openOnHostLabel = (provider: string): string => - OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; +export const openOnHostLabel = (host: SourceControlClientDefinition): string => + host === UNKNOWN_SOURCE_CONTROL_CLIENT ? "Open on host" : `Open on ${host.label}`; /** Copy first: it is the reason to right-click a number rather than click it. */ function pullRequestLinkContextMenuItems( diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index ec408ad97cdb..db7836054eeb 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -5,7 +5,6 @@ import * as Option from "effect/Option"; import { useEffect, useState, type ReactNode } from "react"; import type { BackgroundActivitySettings, - SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, @@ -46,17 +45,7 @@ import { import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - AzureDevOpsIcon, - BitbucketIcon, - GitCafeIcon, - GitHubIcon, - GitIcon, - GitLabIcon, - ForgejoIcon, - JujutsuIcon, - type Icon, -} from "../Icons"; +import { GitIcon, JujutsuIcon, type Icon } from "../Icons"; import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { GitHubAccountSettings } from "./GitHubAccountSettings"; import { GitHubTokenSettings } from "./GitHubTokenSettings"; @@ -72,21 +61,14 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { sourceControlIcon } from "~/sourceControlPresentation"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], sourceControlProviders: [], }; -const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { - github: GitHubIcon, - gitlab: GitLabIcon, - forgejo: ForgejoIcon, - "azure-devops": AzureDevOpsIcon, - bitbucket: BitbucketIcon, - gitcafe: GitCafeIcon, -}; - const VCS_ICONS: Partial> = { git: GitIcon, jj: JujutsuIcon, @@ -185,8 +167,9 @@ function SourceControlItemMark({ readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; }) { const dotClassName = itemStatusDot(item); + const host = isProviderDiscoveryItem(item) ? sourceControlClients.find(item.kind) : undefined; const Icon = isProviderDiscoveryItem(item) - ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] + ? host && sourceControlIcon(host) : VCS_ICONS[item.kind]; if (!Icon) { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 61a1befd6099..5ac72012c2d0 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -1,17 +1,10 @@ import type { ElementType } from "react"; -import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; -export { - DEFAULT_CHANGE_REQUEST_TERMINOLOGY, - getChangeRequestTerminology, - resolveChangeRequestPresentation, - type ChangeRequestPresentation, - type ChangeRequestTerminology, -} from "@t3tools/shared/sourceControl"; -import { - getChangeRequestTerminology, - resolveChangeRequestPresentation, - type ChangeRequestTerminology, -} from "@t3tools/shared/sourceControl"; +import type { SourceControlProviderInfo } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { + ChangeRequestTerminology, + SourceControlClientDefinition, +} from "@t3tools/client-runtime/source-control-clients"; import { AzureDevOpsIcon, BitbucketIcon, @@ -22,65 +15,53 @@ import { } from "./components/Icons"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +export type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; + +type SourceControlIcon = ElementType<{ className?: string }>; + +/** Web art for each definition `icon` key; a key without art draws the change request glyph. */ +const SOURCE_CONTROL_ICONS: Partial> = { + github: GitHubIcon, + gitlab: GitLabIcon, + forgejo: ForgejoIcon, + bitbucket: BitbucketIcon, + "azure-devops": AzureDevOpsIcon, + gitcafe: GitCafeIcon, +}; + +export function sourceControlIcon(definition: SourceControlClientDefinition): SourceControlIcon { + return SOURCE_CONTROL_ICONS[definition.icon] ?? PullRequestGlyph.pullRequest; +} + export interface SourceControlPresentation { + readonly definition: SourceControlClientDefinition; readonly providerName: string; readonly terminology: ChangeRequestTerminology; - readonly Icon: ElementType<{ className?: string }>; + readonly Icon: SourceControlIcon; } +function presentDefinition( + definition: SourceControlClientDefinition, + providerName: string, +): SourceControlPresentation { + return { + definition, + providerName, + terminology: definition.changeRequest, + Icon: sourceControlIcon(definition), + }; +} + +/** Named as the server reported the host, e.g. "GitHub Self-Hosted", when it reported one. */ export function getSourceControlPresentation( provider: SourceControlProviderInfo | null | undefined, ): SourceControlPresentation { - const presentation = resolveChangeRequestPresentation(provider); - switch (presentation.icon) { - case "github": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: GitHubIcon, - }; - case "forgejo": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: ForgejoIcon, - }; - case "gitlab": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: GitLabIcon, - }; - case "azure-devops": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: AzureDevOpsIcon, - }; - case "bitbucket": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: BitbucketIcon, - }; - case "gitcafe": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: GitCafeIcon, - }; - case "change-request": - return { - providerName: provider?.name || presentation.providerName, - terminology: getChangeRequestTerminology(provider), - Icon: PullRequestGlyph.pullRequest, - }; - } + const definition = sourceControlClients.get(provider?.kind); + return presentDefinition(definition, provider?.name || definition.label); } /** For surfaces that know only the host kind, such as a change request row or filter. */ -export function getSourceControlPresentationForKind( - kind: SourceControlProviderKind, -): SourceControlPresentation { - return getSourceControlPresentation({ kind, name: "", baseUrl: "" }); +export function getSourceControlPresentationForKind(kind: string): SourceControlPresentation { + const definition = sourceControlClients.get(kind); + return presentDefinition(definition, definition.label); } diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 108644a04b4d..52a2b2d4d290 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -12,6 +12,7 @@ import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition" import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; export { + UNKNOWN_SOURCE_CONTROL_CLIENT, type ChangeRequestTerminology, type SourceControlClientDefinition, } from "@t3tools/source-control-core/client/definition"; diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index f8c1f99c5d30..eee0c817cb07 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -24,11 +24,6 @@ export interface ChangeRequestTerminology { readonly singular: string; } -export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { - shortLabel: "PR", - singular: "pull request", -}; - const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "github", providerName: "GitHub", @@ -132,20 +127,6 @@ function resolveChangeRequestPresentationForKind( return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); } -export function getChangeRequestTerminology( - provider: SourceControlProviderInfo | null | undefined, -): ChangeRequestTerminology { - if (!provider) { - return DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - } - - const presentation = resolveChangeRequestPresentation(provider); - return { - shortLabel: presentation.shortName, - singular: presentation.longName, - }; -} - export function getChangeRequestTerminologyForKind( kind: SourceControlProviderKind, ): ChangeRequestTerminology { diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 8db86cfcaf4d..22bb09957085 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -101,7 +101,7 @@ export function defineSourceControlClient Date: Sat, 10 Oct 2026 12:07:01 -0700 Subject: [PATCH 033/250] refactor(source-control): reference parsing and project matching are host resolvers (#17770) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/pullRequestDetail.logic.ts | 11 +- apps/web/src/lib/openPullRequestLink.ts | 87 +------------ apps/web/src/pullRequestReference.ts | 56 +++------ .../src/client/definition.ts | 29 +++++ .../src/client/definition.ts | 15 ++- .../src/client/definition.ts | 116 +++++++++++++++++- .../src/client/definition.ts | 54 +++++++- .../src/client/definition.ts | 15 ++- .../src/client/definition.ts | 21 +++- .../src/client/definition.ts | 18 ++- 10 files changed, 286 insertions(+), 136 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index cd9d25220649..7faf9b651240 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1288,12 +1288,9 @@ export function resolvePullRequestReferenceHost( reference: PullRequestRef, identity: RepositoryIdentity | null | undefined, ): PullRequestRef { - // Other providers may resolve an SSH remote to a different web authority on the server. - if (reference.host !== undefined || identity?.provider !== "github") return reference; - return { - ...reference, - host: pullRequestHostOf(identity, SourceControlProviderKind.make("github")), - }; + if (reference.host !== undefined || !identity?.provider) return reference; + const host = sourceControlClients.get(identity.provider).checkoutChangeRequestHost(identity); + return host === null ? reference : { ...reference, host }; } export interface PullRequestDetailSnapshotRef { @@ -1377,7 +1374,7 @@ export function resolveDisplayedPullRequestDetail(input: { if (input.reference.host === undefined) return input.cached; try { const url = new URL(input.cached.url); - const host = input.cached.provider === "forgejo" ? url.host : url.hostname; + const host = sourceControlClients.get(input.cached.provider).changeRequestUrlHost(url); return (url.protocol === "https:" || url.protocol === "http:") && host.toLowerCase() === input.reference.host.toLowerCase() ? input.cached diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index da67d0b043a4..1c701fb4f0fe 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -3,12 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent, useCallback, useMemo } from "react"; -import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; -import { - canonicalRepositoryKey, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -28,34 +25,6 @@ export { changeRequestRepositoryUrl, } from "@t3tools/shared/changeRequestUrl"; -function resolvedForgejoRepository(project: EnvironmentProject): URL | null { - const identity = project.repositoryIdentity; - if (identity?.provider !== "forgejo" || !identity.webUrl) return null; - try { - const url = new URL(identity.webUrl); - return url.protocol === "http:" || url.protocol === "https:" ? url : null; - } catch { - return null; - } -} - -/** Keep Forgejo servers on different HTTP ports separate when selecting a project. */ -function matchesChangeRequestAuthority( - project: EnvironmentProject, - link: ChangeRequestLink, -): boolean { - if (link.authority === undefined) return true; - try { - const remote = new URL(project.repositoryIdentity?.locator.remoteUrl ?? ""); - if (remote.protocol === "http:" || remote.protocol === "https:") { - return remote.host.toLowerCase() === link.authority; - } - } catch { - // SSH remotes do not specify the server's HTTP port; tea resolves the configured login. - } - return true; -} - /** * The project a link belongs to, or nothing. Matched the way the server matches: the repository * identity is the full path below the host where one was recorded — which is what nested GitLab @@ -68,29 +37,9 @@ export function findProjectForChangeRequest( ): EnvironmentProject | undefined { return projects.find((project) => { const identity = project.repositoryIdentity; - if (!identity || !matchesChangeRequestAuthority(project, link)) return false; - const kind = identity.provider as SourceControlProviderKind | undefined; - if (kind === undefined) return false; - const web = resolvedForgejoRepository(project); - if (web) - return ( - web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && - web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() - ); - if (kind === "azure-devops") { - return ( - canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === - canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()) - ); - } - const repository = - identity.displayName ?? - (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); return ( - repository !== null && - repository.toLowerCase() === link.repository.toLowerCase() && - (pullRequestHostOf(identity, kind) === link.host.toLowerCase() || - pullRequestHostOf(identity, kind) === link.authority) + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).isChangeRequestInRepository(identity, link) ); }); } @@ -151,35 +100,11 @@ export function findProjectOnChangeRequestHost( ): EnvironmentProject | undefined { const own = findProjectForChangeRequest(projects, link); if (own !== undefined) return own; - // Azure CLI reads use the checkout's organization and project, not host-wide credentials. - if ( - canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith( - "dev.azure.com/", - ) - ) - return undefined; return projects.find((project) => { const identity = project.repositoryIdentity; - const kind = identity?.provider as SourceControlProviderKind | undefined; - const web = resolvedForgejoRepository(project); - if (web) { - const mount = web.pathname - .replace(/^\/+|\/+$/g, "") - .split("/") - .slice(0, -2) - .join("/"); - return ( - web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && - (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) - ); - } return ( - identity != null && - kind !== undefined && - kind !== "azure-devops" && - matchesChangeRequestAuthority(project, link) && - (pullRequestHostOf(identity, kind) === link.host.toLowerCase() || - pullRequestHostOf(identity, kind) === link.authority) + identity?.provider !== undefined && + sourceControlClients.get(identity.provider).canReadChangeRequestOnHost(identity, link) ); }); } diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index 421f60f03687..561bf19e86ee 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,57 +1,31 @@ -const FORGEJO_PULL_REQUEST_URL_PATTERN = - /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; -const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; -const GITHUB_PULL_REQUEST_URL_PATTERN = - /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; -const GITLAB_MERGE_REQUEST_URL_PATTERN = - /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; -const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = - /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; -const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; -const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; -const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; -const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -function parseAzureDevOpsCheckoutReference(args: string): string | null { - const parts = args.trim().split(/\s+/).filter(Boolean); - for (const [index, part] of parts.entries()) { - if (part === "--id" || part === "-i") { - return parts[index + 1] ?? null; - } - if (part.startsWith("--id=")) { - return part.slice("--id=".length) || null; - } - } - return parts.find((part) => !part.startsWith("-")) ?? null; -} +const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; +/** + * The change request a pasted reference names: a host's change request URL, returned as is, or + * a bare number. A host's checkout command, such as `gh pr checkout 42`, is read for its + * argument first. Null for anything else, such as a branch name. + */ export function parsePullRequestReference(input: string): string | null { const trimmed = input.trim(); if (trimmed.length === 0) { return null; } - const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); - const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); - const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); const normalizedInput = - FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ?? - ghCliCheckoutMatch?.[1]?.trim() ?? - glabCliCheckoutMatch?.[1]?.trim() ?? - (azureDevOpsCliCheckoutMatch?.[1] - ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) - : null) ?? - trimmed; + sourceControlClients.definitions + .map((definition) => definition.checkoutCommandArgument(trimmed)) + .find((argument) => argument !== null) ?? trimmed; if (normalizedInput.length === 0) { return null; } - const urlMatch = - FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? - AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); - if (urlMatch?.[1]) { + if ( + sourceControlClients.definitions.some((definition) => + definition.isChangeRequestReference(normalizedInput), + ) + ) { return normalizedInput; } diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 5b935c8925dc..57c48b9a1992 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -7,9 +7,24 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { defineSourceControlClient, + isChangeRequestAuthorityOfProject, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; + +/** `az repos pr checkout` names the pull request by `--id`, `-i`, or its first plain argument. */ +function checkoutCommandId(args: string): string | null { + const parts = args.trim().split(/\s+/).filter(Boolean); + for (const [index, part] of parts.entries()) { + if (part === "--id" || part === "-i") return parts[index + 1] ?? null; + if (part.startsWith("--id=")) return part.slice("--id=".length) || null; + } + return parts.find((part) => !part.startsWith("-")) ?? null; +} + export const definition = defineSourceControlClient({ kind: SourceControlProviderKind.make("azure-devops"), label: "Azure DevOps", @@ -29,5 +44,19 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + checkoutCommandArgument: (input) => { + const args = CHECKOUT_COMMAND.exec(input)?.[1]; + return args ? checkoutCommandId(args) : null; + }, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + // The legacy and SSH spellings of one repository all canonicalise to dev.azure.com. + isChangeRequestInRepository: (identity, link) => + isChangeRequestAuthorityOfProject(identity, link) && + canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === + canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), + // `az repos` reads use the checkout's organization and project, not host-wide credentials. + canReadChangeRequestOnHost: () => false, isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), }); diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 25010be99e93..a513d86e1001 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -6,14 +6,18 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const KIND = SourceControlProviderKind.make("bitbucket"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("bitbucket"), + kind: KIND, label: "Bitbucket", pickerLabel: "Bitbucket", icon: "bitbucket", @@ -35,5 +39,14 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + // No checkout CLI, and the reference field never accepted Bitbucket URLs. + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), }); diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 22bb09957085..77d241a8a6ba 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -11,7 +11,13 @@ * * @module source-control-core/client/definition */ -import { type PullRequestReviewVerdict, SourceControlProviderKind } from "@t3tools/contracts"; +import { + pullRequestHostOf, + type PullRequestReviewVerdict, + type RepositoryIdentity, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ export interface ChangeRequestTerminology { @@ -32,6 +38,12 @@ export interface ChangeRequestUrlInput { readonly webUrl?: string | undefined; } +/** A project's repository, as a change request link is matched against it. */ +export type ChangeRequestProjectIdentity = Pick< + RepositoryIdentity, + "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" +>; + /** The change request a checkout command is built for. */ export interface ChangeRequestCheckoutInput { readonly number: number; @@ -76,6 +88,43 @@ export interface SourceControlClientDefinition { readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; + /** + * The change request a pasted command names, such as `gh pr checkout 42`: its argument, which + * is a number or a URL. Null for anything that is not this host's checkout command. + */ + readonly checkoutCommandArgument: (input: string) => string | null; + /** + * Whether a pasted URL is one of this host's change request URLs. Stricter than + * `isChangeRequestUrl`, which only tells hosts apart, because this decides what a reference + * field accepts. + */ + readonly isChangeRequestReference: (url: string) => boolean; + /** + * Whether a change request link names this project's own repository. The link's host is where + * the repository is addressed, `link.authority` the web host and port where they differ. + */ + readonly isChangeRequestInRepository: ( + identity: ChangeRequestProjectIdentity, + link: ChangeRequestLink, + ) => boolean; + /** + * Whether this project can read a change request on the link's host on the link's behalf, for a + * repository nobody has checked out. False where the host's reads are scoped to the checkout. + */ + readonly canReadChangeRequestOnHost: ( + identity: ChangeRequestProjectIdentity, + link: ChangeRequestLink, + ) => boolean; + /** + * The host a change request URL addresses its repository below, as a `PullRequestRef.host` + * names it: the hostname, or the host and port for servers addressed by both. + */ + readonly changeRequestUrlHost: (url: URL) => string; + /** + * The host a reference with none names, read from the checkout alone, or null where only the + * server can tell, such as when an SSH remote resolves to a different web host. + */ + readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ readonly isChangeRequestUrl: (url: string) => boolean; } @@ -100,6 +149,63 @@ export function defineSourceControlClient null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(SourceControlProviderKind.make("unknown"), identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(SourceControlProviderKind.make("unknown"), identity, link), isChangeRequestUrl: () => false, }; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index aab19fcde22d..3ec31d39270d 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -5,12 +5,36 @@ */ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + type ChangeRequestProjectIdentity, defineSourceControlClient, + isChangeRequestAuthorityOfProject, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; + +const KIND = SourceControlProviderKind.make("forgejo"); + +/** + * The repository's browser URL the server resolved from the signed-in account, which is the + * only place a Forgejo mounted below a path says so. + */ +function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null { + if (!identity.webUrl) return null; + try { + const url = new URL(identity.webUrl); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; + } catch { + return null; + } +} + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("forgejo"), + kind: KIND, label: "Forgejo", pickerLabel: "Forgejo / Gitea", icon: "forgejo", @@ -48,5 +72,33 @@ export const definition = defineSourceControlClient({ // Forgejo refuses a change request review without a summary, even with inline comments. referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: (verdict) => verdict === "request-changes", + checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + // Forgejo servers are addressed with their port, so two on one hostname stay apart. + changeRequestUrlHost: (url) => url.host, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => { + if (!isChangeRequestAuthorityOfProject(identity, link)) return false; + const web = resolvedWebUrl(identity); + if (!web) return isChangeRequestInProjectRepository(KIND, identity, link); + return ( + web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && + web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() + ); + }, + // A server mounted below a path serves only repositories under that mount. + canReadChangeRequestOnHost: (identity, link) => { + const web = resolvedWebUrl(identity); + if (!web) return isChangeRequestOnProjectHost(KIND, identity, link); + const mount = web.pathname + .replace(/^\/+|\/+$/g, "") + .split("/") + .slice(0, -2) + .join("/"); + return ( + web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && + (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) + ); + }, isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pulls/"), }); diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 6407d57828d1..38d03014dd3c 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -6,14 +6,18 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; /** GitCafe has no self-hosted installs: production and staging are its only hosts. */ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); +const KIND = SourceControlProviderKind.make("gitcafe"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("gitcafe"), + kind: KIND, label: "GitCafe", pickerLabel: "GitCafe", icon: "gitcafe", @@ -32,6 +36,15 @@ export const definition = defineSourceControlClient({ referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. + // `cafe pr checkout` and git.cafe URLs were never accepted in reference fields. + checkoutCommandArgument: () => null, + isChangeRequestReference: () => false, + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => { try { return ( diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index fe79b7f91cc3..9e6adcff8fbe 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -3,14 +3,22 @@ * * @module source-control-github/client/definition */ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^gh\s+pr\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; + +const KIND = SourceControlProviderKind.make("github"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("github"), + kind: KIND, label: "GitHub", pickerLabel: "GitHub", icon: "github", @@ -29,5 +37,14 @@ export const definition = defineSourceControlClient({ : new URL(`/${encodeURIComponent(login)}`, repositoryUrl).toString(), referenceAutolinkRepositoryUrl: (repositoryUrl) => repositoryUrl, reviewSummaryRequired: () => false, + checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + changeRequestUrlHost: (url) => url.hostname, + // A GitHub web host is the checkout's own host, Enterprise installs included. + checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), }); diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 23c88b1b6d2f..e1d875b56827 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -6,11 +6,19 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, + isChangeRequestInProjectRepository, + isChangeRequestOnProjectHost, isChangeRequestPath, } from "@t3tools/source-control-core/client/definition"; +const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; +const CHANGE_REQUEST_REFERENCE = + /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; + +const KIND = SourceControlProviderKind.make("gitlab"); + export const definition = defineSourceControlClient({ - kind: SourceControlProviderKind.make("gitlab"), + kind: KIND, label: "GitLab", pickerLabel: "GitLab", icon: "gitlab", @@ -26,5 +34,13 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, + checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, + isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), + changeRequestUrlHost: (url) => url.hostname, + checkoutChangeRequestHost: () => null, + isChangeRequestInRepository: (identity, link) => + isChangeRequestInProjectRepository(KIND, identity, link), + canReadChangeRequestOnHost: (identity, link) => + isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/-/merge_requests/"), }); From 9b40d3746388e573370488f73b50e1e869b79a45 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:07:01 -0700 Subject: [PATCH 034/250] feat(pull-requests): quick actions follow each host's capabilities, not GitHub (#17774) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestService.test.ts | 29 ++++++++++++ .../src/pullRequest/PullRequestService.ts | 24 +++++----- .../components/pullRequest/PullRequestRow.tsx | 7 ++- .../pullRequest/PullRequestSpeedActions.tsx | 13 +++--- .../pullRequest/ThreadPullRequestsPanel.tsx | 27 +++++++---- .../pullRequest/pullRequestChecks.test.tsx | 1 + .../pullRequest/pullRequestList.logic.test.ts | 41 ++++++++++++++++- .../pullRequest/pullRequestList.logic.ts | 42 +++++++++++++++++ .../pullRequest/usePullRequestActions.ts | 4 +- apps/web/src/routes/_chat.pull-requests.tsx | 45 +++++++++++-------- packages/contracts/src/pullRequest.ts | 5 +++ .../src/client/definition.ts | 9 ++++ .../server/AzureDevOpsPullRequestProvider.ts | 11 +---- .../src/client/definition.ts | 3 ++ .../server/BitbucketPullRequestProvider.ts | 5 +-- .../src/client/definition.ts | 8 ++++ .../src/client/definition.ts | 1 + .../src/server/ForgejoPullRequestProvider.ts | 3 +- .../src/client/definition.ts | 8 ++++ .../src/server/GitCafePullRequestProvider.ts | 3 +- .../src/client/definition.ts | 12 +++++ .../src/server/GitHubPullRequestProvider.ts | 14 +----- .../src/client/definition.ts | 10 +++++ .../src/server/GitLabPullRequestProvider.ts | 12 +---- 24 files changed, 254 insertions(+), 83 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index cb544d4898ac..bd0a0cd42e58 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -572,6 +572,35 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ); +it.effect("reports each host's actions with its summary, so rows can offer them unread", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/repo", + repository: "acme/web", + provider: SourceControlProviderKind.make("bitbucket"), + host: "bitbucket.org", + }), + ], + providers: [ + fakeProvider(SourceControlProviderKind.make("bitbucket"), { + capabilities: { + ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities, + actions: ["merge", "close"], + }, + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]); + }), +); + it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => Effect.gen(function* () { let refinementCalls = 0; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index e4d8178dbbe9..db6b8424a8fe 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1252,20 +1252,24 @@ export const make = Effect.gen(function* () { // One summary per host, which is what the viewer lookup already answers for: two GitHub // hosts sign in separately, so collapsing them by kind would report one as the other. const providers: ReadonlyArray = [ - ...viewerResults.map((result) => ({ - host: result.host, - kind: result.kind, - searchesOnHost: - projects.find((project) => project.host === result.host)?.api.capabilities.search ?? - false, - projectCount: projectCounts.get(result.host) ?? 1, - configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), - })), + ...viewerResults.map((result) => { + const capabilities = projects.find((project) => project.host === result.host)?.api + .capabilities; + return { + host: result.host, + kind: result.kind, + searchesOnHost: capabilities?.search ?? false, + actions: capabilities?.actions ?? [], + projectCount: projectCounts.get(result.host) ?? 1, + configured: result.viewer !== null, + detail: result.error === null ? null : providerDetail(result.error), + }; + }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ host, kind, searchesOnHost: false, + actions: [], projectCount, configured: false, detail: "This host cannot be browsed here yet.", diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index a1467e79f05f..dfdd0f36890f 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,3 +1,4 @@ +import type { PullRequestAction } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { SearchIcon } from "lucide-react"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; @@ -91,6 +92,7 @@ function PullRequestRowImpl({ statsRef, onSelect, speedMode, + hostActions, onActed, closing = false, sweeping = false, @@ -113,6 +115,8 @@ function PullRequestRowImpl({ statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; speedMode: boolean; + /** What this row's host can do, which is what speed mode offers. */ + hostActions: ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closing?: boolean; sweeping?: boolean; @@ -256,9 +260,10 @@ function PullRequestRowImpl({ updatedAt={entry.updatedAt} /> - {entry.state !== "merged" && entry.provider === "github" ? ( + {hostActions.size > 0 ? ( ({ entry, + hostActions, visible, onActed, closing = false, @@ -33,6 +34,8 @@ export function PullRequestSpeedActions; visible: boolean; onActed?: (result: PullRequestSpeedActionResult) => void; closing?: boolean; @@ -70,13 +73,9 @@ export function PullRequestSpeedActions = { @@ -105,11 +108,12 @@ function LinkRow({ const snapshot = link.snapshot; const open = snapshot === null || snapshot.state === "open"; const watching = link.watch !== undefined; + // A link carries no host summary, so the host's own definition says what it can do. + const linkHostActions = ( + sourceControlClients.findByChangeRequestUrl(link.url) ?? UNKNOWN_SOURCE_CONTROL_CLIENT + ).changeRequestActions; const actionEntry = - projectId !== null && - snapshot !== null && - snapshot.state !== "merged" && - detectSourceControlProviderFromRemoteUrl(link.url)?.kind === "github" + projectId !== null && snapshot !== null && snapshot.state !== "merged" ? { environmentId: threadRef.environmentId, projectId, @@ -121,6 +125,9 @@ function LinkRow({ ...(link.stack === null ? {} : { stack: link.stack }), } : null; + // Speed mode swaps the menu for quick actions only where a row has some to offer. + const hasQuickActions = + actionEntry !== null && pullRequestQuickActions(actionEntry, linkHostActions).length > 0; 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, @@ -261,7 +272,7 @@ function LinkRow({ "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", + speedMode && hasQuickActions && "hidden", )} > diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index b0e8c69517bc..24c49bc4a6ac 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -103,6 +103,7 @@ function row(overrides: Partial): ReactNode { showProvider: false, onSelect: () => {}, speedMode: false, + hostActions: new Set(), onActed: () => {}, }); } diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index ebaa045a66a7..4e4f04f2ec54 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1,8 +1,15 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts"; +import type { + EnvironmentId, + ProjectId, + PullRequestAction, + PullRequestListEntry, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + pullRequestHostActions, + pullRequestQuickActions, filterPullRequestsByInvolvement, findScopedProject, mergePullRequestLists, @@ -1720,3 +1727,35 @@ describe("pull request list override settlement", () => { expect(swapped.map((row) => row.number)).toEqual([2, 1]); }); }); + +describe("pull request quick actions", () => { + const summary = (host: string, kind: string, actions?: ReadonlyArray) => ({ + host, + kind: SourceControlProviderKind.make(kind), + searchesOnHost: true, + projectCount: 1, + configured: true, + detail: null, + ...(actions === undefined ? {} : { actions }), + }); + + it("offers what each host reports, and GitHub's legacy set when a server reports none", () => { + const actionsOf = pullRequestHostActions([ + summary("bitbucket.org", "bitbucket", ["merge", "close"]), + summary("github.com", "github"), + ]); + const bitbucket = actionsOf({ + host: "bitbucket.org", + provider: SourceControlProviderKind.make("bitbucket"), + }); + expect(pullRequestQuickActions({ state: "closed", isDraft: false }, bitbucket)).toEqual([]); + const github = actionsOf({ + host: "github.com", + provider: SourceControlProviderKind.make("github"), + }); + expect(pullRequestQuickActions({ state: "open", isDraft: true }, github)).toEqual([ + "close", + "ready", + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 04f4711aeaa8..f56929c5dbc8 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -1262,3 +1262,45 @@ export function settlePullRequestOverrides( } return kept.size === overrides.size ? overrides : kept; } + +/** The row actions speed mode offers, in the order they are drawn. */ +export type PullRequestQuickAction = "close" | "merge" | "ready" | "reopen"; + +/** + * Which actions each host can take on a listed row, before any row's detail is read. Hosts are + * matched by the host summary; a server that does not report actions per host only ever offered + * these on GitHub, so that is what it keeps getting. + */ +export function pullRequestHostActions( + providers: ReadonlyArray, +): ( + entry: Pick, +) => ReadonlySet { + const byHost = new Map( + providers.flatMap((provider) => + provider.actions === undefined + ? [] + : [[provider.host.toLowerCase(), new Set(provider.actions)] as const], + ), + ); + const legacy = new Set(["close", "merge", "ready", "reopen"]); + const none = new Set(); + return (entry) => + byHost.get(entry.host.toLowerCase()) ?? (entry.provider === "github" ? legacy : none); +} + +/** The quick actions a row offers: what its state allows, narrowed to what its host can do. */ +export function pullRequestQuickActions( + entry: Pick, + hostActions: ReadonlySet, +): ReadonlyArray { + const forState: ReadonlyArray = + entry.state === "merged" + ? [] + : entry.state === "closed" + ? ["reopen"] + : entry.isDraft + ? ["close", "ready"] + : ["close", "merge"]; + return forState.filter((action) => hostActions.has(action)); +} diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index 59c1416aecc9..3ca753df2b82 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -186,8 +186,8 @@ export function usePullRequestCloseBatch(onClosed: (entry: EnvironmentPullReques async (entries: readonly EnvironmentPullRequestEntry[]) => { const batch = entries.filter((entry) => { const key = pullRequestEntryKey(entry); - if (entry.state !== "open" || entry.provider !== "github" || pending.current.has(key)) - return false; + // The sweep only gathers rows whose host can close them, so state is all that is left. + if (entry.state !== "open" || pending.current.has(key)) return false; pending.current.add(key); return true; }); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 02e0e9e26d46..86fcbe400bc9 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -88,6 +88,7 @@ import { pullRequestOverrideAfterAction, reusePullRequestEntries, settlePullRequestOverrides, + pullRequestHostActions, } from "../components/pullRequest/pullRequestList.logic"; import { pullRequestListPreferences, @@ -1592,6 +1593,22 @@ function PullRequestsRouteView() { ]); /** What is actually on screen once the reader's pending answers are on the rows. */ const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0); + // The provider list is the workspace's hosts, not the filtered ones, so switching to a host + // cannot make the switcher that got you there disappear. + const [hosts, setHosts] = useState([]); + useEffect(() => { + // Only from an answer to these filters. Rows carried over from the previous ones bring the + // host summaries of the question they answered, and coming back from one host to all of them + // would take the switcher's other hosts out of it on the strength of the narrowed answer. + if (answered === null) return; + // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher + // when there is nothing to seed it with, which is a link that arrived already scoped. + setHosts((previous) => + search.host === undefined || previous.length === 0 ? answered.providers : previous, + ); + }, [answered, search.host]); + const showProvider = hosts.length > 1; + const hostActionsOf = useMemo(() => pullRequestHostActions(hosts), [hosts]); const [closeSweepKeys, setCloseSweepKeys] = useState>(() => new Set()); const closeSensorRef = useRef(null); const closeSweepRows = useMemo( @@ -1605,8 +1622,14 @@ function PullRequestsRouteView() { ), [displayGroups], ); - const closeSweepRef = useRef({ displayGroups, closeSweepRows, closingKeys, closeBatch }); - closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch }; + const closeSweepRef = useRef({ + displayGroups, + closeSweepRows, + closingKeys, + closeBatch, + hostActionsOf, + }); + closeSweepRef.current = { displayGroups, closeSweepRows, closingKeys, closeBatch, hostActionsOf }; useEffect(() => () => closeSensorRef.current?.cancel(), [filterKey, search.q, sort]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -1632,7 +1655,7 @@ function PullRequestsRouteView() { return ( row?.groupKey === group.key && row.entry.state === "open" && - row.entry.provider === "github" && + closeSweepRef.current.hostActionsOf(row.entry).has("close") && !closeSweepRef.current.closingKeys.has(key) && !scrollRef.current?.querySelector( `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`, @@ -1766,21 +1789,6 @@ function PullRequestsRouteView() { selectSurfaceInUrl(selectedPullRequestSurface); }; - // The provider list is the workspace's hosts, not the filtered ones, so switching to a host - // cannot make the switcher that got you there disappear. - const [hosts, setHosts] = useState([]); - useEffect(() => { - // Only from an answer to these filters. Rows carried over from the previous ones bring the - // host summaries of the question they answered, and coming back from one host to all of them - // would take the switcher's other hosts out of it on the strength of the narrowed answer. - if (answered === null) return; - // An unfiltered response is the full set of hosts. A filtered one only seeds the switcher - // when there is nothing to seed it with, which is a link that arrived already scoped. - setHosts((previous) => - search.host === undefined || previous.length === 0 ? answered.providers : previous, - ); - }, [answered, search.host]); - const showProvider = hosts.length > 1; // The workspace's own projects already name their hosts, so the row's shape is known before // the list is. Only its shape: which hosts can actually be read still comes from the server. const expectedHosts = useMemo(() => { @@ -1942,6 +1950,7 @@ function PullRequestsRouteView() { } onSelect={selectEntry} speedMode={speedMode} + hostActions={hostActionsOf(entry)} onActed={onSpeedAction} closing={closingKeys.has(entryKey)} sweeping={closeSweepKeys.has(entryKey)} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 031439f9a293..2ff64a56e6e2 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -634,6 +634,11 @@ export const PullRequestProviderSummary = Schema.Struct({ kind: SourceControlProviderKind, /** False where a search has to be applied to the rows after they arrive. */ searchesOnHost: Schema.Boolean, + /** + * The actions this host can carry out, so a row can offer them before its detail is read. + * Absent from servers older than this field; clients then offer what they always did. + */ + actions: Schema.optional(Schema.Array(PullRequestAction)), projectCount: PositiveInt, /** False when the provider's CLI or credentials are missing, with `detail` saying which. */ configured: Schema.Boolean, diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index 57c48b9a1992..c0c1722cf07b 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -40,6 +40,15 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, + changeRequestActions: new Set([ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ] as const), checkoutCommand: ({ number }) => `az repos pr checkout --id ${number}`, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index 965726c223b1..78d31da360f5 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -33,6 +33,7 @@ import type { AzureDevOpsPullRequest, AzureDevOpsRepositoryLocation, } from "./azureDevOpsPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; /** * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a @@ -60,15 +61,7 @@ const CAPABILITIES: PullRequestCapabilities = { // Reading a conversation is a plain REST read, but posting one is not something this can // claim without having run it, so the composer stays hidden. comment: false, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], + actions: [...definition.changeRequestActions], // Azure squashes as a completion option; it has no rebase strategy of its own. mergeMethods: ["merge", "squash"], // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index a513d86e1001..dc02f6351fed 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -29,6 +29,9 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pull-requests/${number}`, + // No endpoint reopens a declined pull request, and nothing documented moves one in or out of + // draft, so neither is offered rather than failing when pressed. + changeRequestActions: new Set(["merge", "close"] as const), // Bitbucket has no checkout CLI, so clone the head branch from its own repository. checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => headRepositoryNameWithOwner && diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index d09ec748b3ef..3d26d5401602 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -12,13 +12,12 @@ import { type PullRequestProviderApi, } from "@t3tools/source-control-core/server/PullRequestProvider"; import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that - // moves one in or out of draft, so neither is offered rather than failing when pressed. - actions: ["merge", "close"], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], search: true, // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 77d241a8a6ba..886573d67761 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -12,6 +12,7 @@ * @module source-control-core/client/definition */ import { + type PullRequestAction, pullRequestHostOf, type PullRequestReviewVerdict, type RepositoryIdentity, @@ -77,6 +78,12 @@ export interface SourceControlClientDefinition { readonly defaultCloneTransport: "https" | "ssh"; /** The web URL of a change request, or null when this client cannot build one for the host. */ readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + /** + * The actions this host can take on a change request, the same list its server provider + * declares in `capabilities.actions`. Surfaces with no server answer to hand, such as a thread's + * linked pull requests, offer actions from this. + */ + readonly changeRequestActions: ReadonlySet; /** A shell command that checks the change request out, or null when it cannot be built. */ readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; /** A comment author's profile page, or null where the host has none clients can link. */ @@ -219,6 +226,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { publishHost: (signedInHost) => signedInHost ?? "your server", defaultCloneTransport: "ssh", changeRequestUrl: () => null, + changeRequestActions: new Set(), checkoutCommand: () => null, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 3ec31d39270d..d3425fe64650 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -63,6 +63,7 @@ export const definition = defineSourceControlClient({ } return `https://${host}/${repository}/pulls/${number}`; }, + changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. checkoutCommand: ({ number, repositoryUrl }) => repositoryUrl diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 51b3974a6bd2..d55c205240dc 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -33,12 +33,13 @@ import { forgejoChecks, forgejoReactions, } from "./forgejoPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, viewedFiles: "environment", comment: true, - actions: ["merge", "close", "reopen", "update-branch"], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], search: false, diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 38d03014dd3c..dffd90f8577a 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -31,6 +31,14 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pulls/${number}`, + changeRequestActions: new Set([ + "ready", + "draft", + "close", + "reopen", + "merge", + "update-branch", + ] as const), checkoutCommand: ({ number }) => `cafe pr checkout ${number}`, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 70d8c107ea0b..4bbd54b73cf9 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -18,11 +18,12 @@ import { import * as GitCafeApi from "./GitCafeApi.ts"; import * as GitCafeHosts from "./gitCafeHosts.ts"; import * as Json from "./gitCafePullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], // Only through a stack: GitCafe's one branch update restacks every layer above it. updateMethods: ["rebase"], diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 9e6adcff8fbe..b1345d41f5a4 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -30,6 +30,18 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "https", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pull/${number}`, + changeRequestActions: new Set([ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows", + ] as const), checkoutCommand: ({ number }) => `gh pr checkout ${number}`, authorProfileUrl: (login, repositoryUrl) => login.endsWith("[bot]") diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index b99972d58af0..baa40936aca1 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -20,22 +20,12 @@ import { type ProviderRepositoryRef, } from "@t3tools/source-control-core/server/PullRequestProvider"; import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - "revert", - "approve-workflows", - ], + actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], search: true, diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index e1d875b56827..e7040cfe3cc4 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -30,6 +30,16 @@ export const definition = defineSourceControlClient({ defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/-/merge_requests/${number}`, + changeRequestActions: new Set([ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index db6548eb18f3..89307f696d7a 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -14,20 +14,12 @@ import { type ProviderChangeRequestDetail, type PullRequestProviderApi, } from "@t3tools/source-control-core/server/PullRequestProvider"; +import { definition } from "../client/definition.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - ], + actions: [...definition.changeRequestActions], // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. mergeMethods: ["merge", "squash", "rebase"], // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing From a4c8745abee3f73f960c914800974dee530dbc3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:07:02 -0700 Subject: [PATCH 035/250] feat(projects): new projects can be published to any ready host (#17860) Co-authored-by: Claude Opus 5.5 --- .../features/projects/AddProjectScreen.tsx | 69 ++++++++--- apps/web/src/components/CommandPalette.tsx | 116 +++++++++++++++--- apps/web/src/components/ui/checkbox.tsx | 7 +- apps/web/src/hooks/useNewProject.ts | 33 ++--- .../src/operations/projects.test.ts | 35 ++++++ .../client-runtime/src/operations/projects.ts | 49 ++++++-- .../src/client/definition.ts | 2 + .../src/client/definition.ts | 2 + .../src/client/definition.ts | 7 ++ .../src/client/definition.ts | 1 + .../src/client/definition.ts | 1 + .../src/client/definition.ts | 1 + .../src/client/definition.ts | 1 + 13 files changed, 257 insertions(+), 67 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a72660f79603..cc02851cb6ef 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -15,9 +15,10 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, - getNewProjectGitHubRepository, - getNewProjectGitHubTarget, getNewProjectPathPreview, + getNewProjectPublishTarget, + getNewProjectPublishTargets, + getNewProjectRepository, normalizePastedCloneUrl, parseAddProjectRemoteSource, resolveAddProjectPath, @@ -995,9 +996,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s input: {}, }), ); - const githubTarget = getNewProjectGitHubTarget(discoveryState.data); + const publishTargets = getNewProjectPublishTargets(discoveryState.data); const [name, setName] = useState(""); - const [publishesToGitHub, setPublishesToGitHub] = useState(false); + const [publishes, setPublishes] = useState(false); + // Null takes the first ready host, GitHub when it is ready. + const [publishKind, setPublishKind] = useState(null); + const publishTarget = getNewProjectPublishTarget(publishTargets, publishKind); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const trimmedName = name.trim(); @@ -1066,19 +1070,19 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s if (commitError !== undefined) { Alert.alert("Created without a first commit", commitError); } - if (publishesToGitHub && githubTarget !== null) { + if (publishes && publishTarget !== null) { void publishRepository({ environmentId: environment.environmentId, input: { cwd: workspaceRoot, - provider: SourceControlProviderKind.make("github"), - repository: getNewProjectGitHubRepository(githubTarget, workspaceRoot), + provider: publishTarget.definition.kind, + repository: getNewProjectRepository(publishTarget, workspaceRoot), visibility: "private", }, }).then((publishResult) => { if (AsyncResult.isFailure(publishResult)) { Alert.alert( - "Could not create the GitHub repository", + `Could not create the ${publishTarget.definition.label} repository`, errorMessage(Cause.squash(publishResult.cause)), ); } @@ -1133,18 +1137,18 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ) : null} {machineRows} - {githubTarget !== null ? ( + {publishTarget !== null ? ( 0 && pathPreview !== null - ? getNewProjectGitHubRepository(githubTarget, pathPreview) - : githubTarget.account + ? getNewProjectRepository(publishTarget, pathPreview) + : publishTarget.owner } icon={ @@ -1152,13 +1156,44 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s isFirst right={ } - onPress={() => setPublishesToGitHub((publishes) => !publishes)} + onPress={() => setPublishes((current) => !current)} /> + {publishes && publishTargets.length > 1 + ? publishTargets.map((target) => { + const selected = target.definition.kind === publishTarget.definition.kind; + return ( + + } + selected={selected} + disabled={isSubmitting} + right={ + selected ? ( + + ) : null + } + onPress={() => setPublishKind(target.definition.kind)} + /> + ); + }) + : null} ) : null} ; } +function sourceControlHostIcon( + definition: NewProjectPublishTarget["definition"], + className: string, +): ReactNode { + const Icon = sourceControlIcon(definition); + return ; +} + function projectFaviconIcon(project: Project): ReactNode { return ; } @@ -762,7 +772,11 @@ function OpenCommandPaletteDialog(props: { /** Machine of the Add project sources view under this step; null from the palette root. */ readonly sourcesEnvironmentId: EnvironmentId | null; } | null>(null); - const [newProjectPublishesToGitHub, setNewProjectPublishesToGitHub] = useState(false); + const [newProjectPublishes, setNewProjectPublishes] = useState(false); + // The host picked beside the publish toggle; null takes the first ready host, GitHub when ready. + const [newProjectPublishKind, setNewProjectPublishKind] = + useState(null); + const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false); const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); // State lags a render behind, so a repeated Enter could start a second create. const newProjectSubmittingRef = useRef(false); @@ -1482,7 +1496,8 @@ function OpenCommandPaletteDialog(props: { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); setNewProjectFlow({ environmentId, sourcesEnvironmentId }); - setNewProjectPublishesToGitHub(false); + setNewProjectPublishes(false); + setNewProjectPublishKind(null); pushPaletteView({ addonIcon: , groups: [], @@ -2417,8 +2432,12 @@ function OpenCommandPaletteDialog(props: { ], ); - const newProjectGitHubTarget = - newProjectFlow === null ? null : getNewProjectGitHubTarget(sourceControlDiscovery.data ?? null); + const newProjectPublishTargets = + newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null); + const newProjectPublishTarget = getNewProjectPublishTarget( + newProjectPublishTargets, + newProjectPublishKind, + ); const newProjectName = query.trim(); const canSubmitNewProject = newProjectFlow !== null && @@ -2436,7 +2455,7 @@ function OpenCommandPaletteDialog(props: { const created = await createNewProject({ environmentId: newProjectFlow.environmentId, name: newProjectName, - github: newProjectPublishesToGitHub ? newProjectGitHubTarget : null, + publishTo: newProjectPublishes ? newProjectPublishTarget : null, }); if (created) setOpen(false); } finally { @@ -2742,7 +2761,7 @@ function OpenCommandPaletteDialog(props: { const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; const newProjectPathPreview = newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); - const newProjectGitHubToggleValue = "new-project:github"; + const newProjectPublishToggleValue = "new-project:publish"; // The name step's way out to folders and clones, for the selected machine. // It replaces the name step (and a sources view for another machine under // it), so Back returns to wherever New project was opened from. @@ -2829,7 +2848,7 @@ function OpenCommandPaletteDialog(props: { ], }; const newProjectOptionGroups: CommandPaletteView["groups"] = - newProjectGitHubTarget === null || newProjectPathPreview === null + newProjectPublishTarget === null || newProjectPathPreview === null ? [] : [ { @@ -2838,22 +2857,81 @@ function OpenCommandPaletteDialog(props: { items: [ { kind: "action", - value: newProjectGitHubToggleValue, + value: newProjectPublishToggleValue, searchTerms: [], - title: "Create private repository on GitHub", + title: + newProjectPublishTargets.length > 1 + ? "Create private repository" + : `Create private repository on ${newProjectPublishTarget.definition.label}`, description: newProjectName.length > 0 - ? getNewProjectGitHubRepository(newProjectGitHubTarget, newProjectPathPreview) - : (newProjectGitHubTarget.account ?? "Your GitHub account"), - icon: , + ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview) + : (newProjectPublishTarget.owner ?? + `Your ${newProjectPublishTarget.definition.label} account`), + icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS), titleTrailingContent: ( - - + + {newProjectPublishTargets.length > 1 ? ( + // The row toggles on click, so the picker keeps its clicks to itself. The + // row also cancels pointerdown to keep focus in the input, which drops the + // mousedown Select opens on, so the picker opens on click instead. + { + event.stopPropagation(); + // React bubbles the popup's clicks here through its portal; only the + // trigger opens the picker. + if (!event.currentTarget.contains(event.target as Node)) return; + if (!isCreatingNewProject) setNewProjectHostPickerOpen(true); + }} + > + + + ) : null} + + + ), + // The create in flight keeps the choice it started with. + ...(isCreatingNewProject ? { disabled: true } : {}), keepOpen: true, run: async () => { - setNewProjectPublishesToGitHub((publishes) => !publishes); + setNewProjectPublishes((publishes) => !publishes); }, }, ], @@ -3327,7 +3405,7 @@ function OpenCommandPaletteDialog(props: { newProjectFlow !== null ? highlightedItemValue === null ? "Create" - : highlightedItemValue === newProjectGitHubToggleValue + : highlightedItemValue === newProjectPublishToggleValue ? "Toggle" : "Select" : addProjectCloneFlow?.step === "repository" diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index c4a02ee4bb88..f3f6a039aa88 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -14,7 +14,8 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { data-slot="checkbox" {...props} > - {/* Leave a contrasting inner gap inside the 2px focus outline. */} + {/* Leave a contrasting inner gap inside the 2px focus outline. The glyphs name their color so + rows that mute stray icons, such as list items, leave the indicator's color alone. */} {state.indeterminate ? ( ) : ( { + const { label } = input.target.definition; const result = await publishRepository({ environmentId: input.environmentId, input: { cwd: input.workspaceRoot, - provider: SourceControlProviderKind.make("github"), - repository: getNewProjectGitHubRepository(input, input.workspaceRoot), + provider: input.target.definition.kind, + repository: getNewProjectRepository(input.target, input.workspaceRoot), visibility: "private", }, }); @@ -56,7 +59,7 @@ export function useNewProject() { toastManager.add( stackedThreadToast({ type: "error", - title: "Could not create the GitHub repository", + title: `Could not create the ${label} repository`, description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`, }), ); @@ -66,7 +69,7 @@ export function useNewProject() { toastManager.add( stackedThreadToast({ type: "success", - title: "Published to GitHub", + title: `Published to ${label}`, description: result.value.repository.nameWithOwner, }), ); @@ -78,7 +81,7 @@ export function useNewProject() { async (input: { readonly environmentId: EnvironmentId; readonly name: string; - readonly github: { readonly account: string | null } | null; + readonly publishTo: NewProjectPublishTarget | null; }): Promise => { const result = await createNew({ environmentId: input.environmentId, @@ -110,11 +113,11 @@ export function useNewProject() { }, ), ); - if (input.github) { - void publishToGitHub({ + if (input.publishTo) { + void publishNewRepository({ environmentId: input.environmentId, workspaceRoot, - account: input.github.account, + target: input.publishTo, }); } @@ -143,6 +146,6 @@ export function useNewProject() { }); return true; }, - [createNew, handleNewThread, publishToGitHub], + [createNew, handleNewThread, publishNewRepository], ); } diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index daf04d8b94e3..346e8cd6092f 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -18,6 +18,8 @@ import { getCloneDestinationPath, getCloneDirectoryName, getDefaultCloneUrl, + getNewProjectPublishTarget, + getNewProjectPublishTargets, normalizePastedCloneUrl, resolveAddProjectPath, sortAddProjectProviderSources, @@ -279,3 +281,36 @@ describe("add project shared logic", () => { }); }); }); + +describe("new project publish targets", () => { + const readyHost = (kind: string, account: string | null) => ({ + kind: SourceControlProviderKind.make(kind), + label: kind, + status: "available" as const, + installHint: "", + version: Option.none(), + detail: Option.none(), + auth: { + status: "authenticated" as const, + account: Option.fromNullOr(account), + host: Option.none(), + detail: Option.none(), + }, + }); + + it("offers ready hosts that can place the repository, GitHub first, and keeps the pick", () => { + const targets = getNewProjectPublishTargets({ + versionControlSystems: [], + sourceControlProviders: [ + readyHost("gitlab", "group"), + readyHost("github", null), + readyHost("azure-devops", "me"), + ], + }); + expect(targets.map((target) => target.definition.kind)).toEqual(["github", "gitlab"]); + expect(getNewProjectPublishTarget(targets, null)?.definition.kind).toBe("github"); + expect( + getNewProjectPublishTarget(targets, SourceControlProviderKind.make("gitlab"))?.owner, + ).toBe("group"); + }); +}); diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 8f46b4426739..a7013c668e6f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -25,6 +25,7 @@ import { } from "../state/projects.ts"; import type { EnvironmentProject } from "../state/models.ts"; import { sourceControlClients } from "../sourceControlClients.ts"; +import type { SourceControlClientDefinition } from "../sourceControlClients.ts"; /** A host to clone from, or `url` for a pasted clone URL. */ export type AddProjectRemoteSource = SourceControlProviderKind | "url"; @@ -225,27 +226,49 @@ export function getNewProjectPathPreview(newProjectsRoot: string, name: string): return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); } +/** A host a new project can be published to, and who the repository goes under there. */ +export interface NewProjectPublishTarget { + readonly definition: SourceControlClientDefinition; + /** Null lets the host's CLI place it under the signed-in user. */ + readonly owner: string | null; +} + /** - * The GitHub account a new project would be published under, or null when - * GitHub is not ready on that environment. A ready GitHub with an unknown - * account still publishes; `gh` picks the signed-in user. + * The hosts a new project can be published to on that environment, in definition order so + * GitHub leads. A host is offered when it is ready and its account names where the repository + * goes. */ -export function getNewProjectGitHubTarget( +export function getNewProjectPublishTargets( discovery: SourceControlDiscoveryResult | null, -): { readonly account: string | null } | null { - const kind = SourceControlProviderKind.make("github"); - if (!buildAddProjectRemoteSourceReadiness(discovery)(kind).ready) return null; - const github = discovery?.sourceControlProviders.find((provider) => provider.kind === kind); - return { account: github ? Option.getOrNull(github.auth.account) : null }; +): ReadonlyArray { + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + return sourceControlClients.definitions.flatMap((definition) => { + if (!readiness(definition.kind).ready) return []; + const provider = discovery?.sourceControlProviders.find( + (candidate) => candidate.kind === definition.kind, + ); + const placement = definition.newRepositoryOwner( + provider ? Option.getOrNull(provider.auth.account) : null, + ); + return placement === null ? [] : [{ definition, owner: placement.owner }]; + }); +} + +/** The chosen host's target, or the first one when the choice is not on offer. */ +export function getNewProjectPublishTarget( + targets: ReadonlyArray, + kind: SourceControlProviderKind | null, +): NewProjectPublishTarget | null { + return targets.find((target) => target.definition.kind === kind) ?? targets[0] ?? null; } -/** `owner/folder` for publishing a new project, or just the folder for `gh` to place. */ -export function getNewProjectGitHubRepository( - target: { readonly account: string | null }, +/** `owner/folder` for publishing a new project, or just the folder for the host to place. */ +export function getNewProjectRepository( + target: Pick, workspaceRoot: string, ): string { const folderName = workspaceRoot.split(/[\\/]/).filter(Boolean).at(-1) ?? ""; - return target.account ? `${target.account}/${folderName}` : folderName; + return target.owner ? `${target.owner}/${folderName}` : folderName; } /** diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index c0c1722cf07b..f53e4c3e7cd7 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -37,6 +37,8 @@ export const definition = defineSourceControlClient({ publicHost: null, publishDescription: "dev.azure.com", publishHost: () => "dev.azure.com", + // A new repository belongs to a project, which the account does not name. + newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${canonicalRepositoryKey(`${host}/${repository}`.toLowerCase())}/pullrequest/${number}`, diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index dc02f6351fed..26f5c5d6f97e 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -26,6 +26,8 @@ export const definition = defineSourceControlClient({ publicHost: "bitbucket.org", publishDescription: "bitbucket.org", publishHost: () => "bitbucket.org", + // A new repository belongs to a workspace, which the signed-in account does not name. + newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pull-requests/${number}`, diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 886573d67761..40d2f80783ee 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -74,6 +74,12 @@ export interface SourceControlClientDefinition { readonly publishDescription: string; /** Where a publish lands, given the host the server is signed in to, if it reported one. */ readonly publishHost: (signedInHost: string | null) => string; + /** + * Who a new project's repository is published under, given the signed-in account: an owner to + * name, `null` owner to let the host's CLI pick the signed-in user, or null when the host needs + * a choice an account cannot make, such as Azure DevOps' project. + */ + readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; /** Which of a repository's clone URLs a new clone uses. */ readonly defaultCloneTransport: "https" | "ssh"; /** The web URL of a change request, or null when this client cannot build one for the host. */ @@ -224,6 +230,7 @@ export const UNKNOWN_SOURCE_CONTROL_CLIENT: SourceControlClientDefinition = { publicHost: null, publishDescription: "Your signed-in server", publishHost: (signedInHost) => signedInHost ?? "your server", + newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: () => null, changeRequestActions: new Set(), diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index d3425fe64650..7504e32e6a78 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -44,6 +44,7 @@ export const definition = defineSourceControlClient({ publicHost: null, publishDescription: "Your signed-in server", publishHost: (signedInHost) => signedInHost ?? "your server", + newRepositoryOwner: (account) => (account ? { owner: account } : null), defaultCloneTransport: "https", // The server's resolved web URL wins; otherwise an HTTP remote on the same host names the // origin, which may carry a port. SSH remotes say nothing about the web origin. diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index dffd90f8577a..202f297a9e17 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -28,6 +28,7 @@ export const definition = defineSourceControlClient({ publicHost: null, publishDescription: "git.cafe", publishHost: () => "git.cafe", + newRepositoryOwner: (account) => (account ? { owner: account } : null), defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pulls/${number}`, diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index b1345d41f5a4..0c18ff63331a 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({ publicHost: "github.com", publishDescription: "github.com", publishHost: () => "github.com", + newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "https", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/pull/${number}`, diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index e7040cfe3cc4..4555cb6abd6c 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -27,6 +27,7 @@ export const definition = defineSourceControlClient({ publicHost: "gitlab.com", publishDescription: "gitlab.com", publishHost: () => "gitlab.com", + newRepositoryOwner: (account) => ({ owner: account }), defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => `https://${host}/${repository}/-/merge_requests/${number}`, From f82796a16abb707c1e2c41301ad8d6e3153668c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:30:29 -0700 Subject: [PATCH 036/250] fix(server): send Claude MCP servers over the control channel (#17898) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 138 ++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 107 ++++++++++---- 2 files changed, 198 insertions(+), 47 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 83d5b11aef6a..95a6b453c45e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,10 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams. +import * as NodeEvents from "node:events"; import * as NodeOS from "node:os"; +import * as NodeStream from "node:stream"; import type { Query as ClaudeQuery, SDKMessage, SDKResultMessage, SDKUserMessage, + SpawnedProcess, + SpawnOptions, } from "@anthropic-ai/claude-agent-sdk"; import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -63,7 +68,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; -import type * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; @@ -499,14 +504,11 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { type: "http", url: "http://127.0.0.1:43123/mcp", headers: { - Authorization: "${T3_CODE_MCP_AUTHORIZATION}", + Authorization: "Bearer secret-claude-token", }, timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, } as const; - const T3_MCP_ENVIRONMENT = { - T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token", - } as const; const mcpSessionFor = ( threadId: ThreadId, @@ -551,7 +553,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { assert.deepEqual(overrides, { allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], mcpServers: T3_MCP_SERVERS, - mcpEnvironment: T3_MCP_ENVIRONMENT, }); }); @@ -567,7 +568,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { assert.deepEqual(overrides, { allowedTools: ["Read", "mcp__t3-code__*"], mcpServers: T3_MCP_SERVERS, - mcpEnvironment: T3_MCP_ENVIRONMENT, }); }); @@ -586,7 +586,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, ], mcpServers: T3_MCP_SERVERS, - mcpEnvironment: T3_MCP_ENVIRONMENT, }); assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD)); }); @@ -706,12 +705,11 @@ describe("ClaudeAdapterV2 native protocol logging", () => { type: "http", url: "http://127.0.0.1:43123/mcp", headers: { - Authorization: "${T3_CODE_MCP_AUTHORIZATION}", + Authorization: "Bearer secret-claude-token", }, timeout: ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, }, - mcpEnvironment: { T3_CODE_MCP_AUTHORIZATION: "Bearer secret-claude-token" }, }); const options = ClaudeAdapterV2.makeClaudeQueryOptions({ @@ -724,12 +722,8 @@ describe("ClaudeAdapterV2 native protocol logging", () => { cwd: "/workspace", allowedTools: overrides.allowedTools ?? [], mcpServers: overrides.mcpServers ?? {}, - environment: { ...overrides.mcpEnvironment }, + environment: {}, }); - // mcpServers becomes a CLI argument, readable by every local user; the - // credential may only travel in the child's environment. - assert.notInclude(JSON.stringify(options.mcpServers), "secret-claude-token"); - assert.equal(options.env?.T3_CODE_MCP_AUTHORIZATION, "Bearer secret-claude-token"); assert.isObject(options.systemPrompt); const systemPrompt = options.systemPrompt as { readonly type: string; @@ -1850,6 +1844,120 @@ describe("ClaudeAdapterV2 native fork", () => { ); }); +// Stands in for the Claude CLI behind the SDK's spawn hook: records how it was +// started and answers stdin control requests, except mcp_set_servers if asked. +function makeFakeClaudeCli(answerSetServers = true) { + const spawns: Array = []; + const controlRequests: Array = []; + const stdins: Array = []; + const spawn = (options: SpawnOptions): SpawnedProcess => { + spawns.push(options); + const stdin = new NodeStream.PassThrough(); + const stdout = new NodeStream.PassThrough(); + stdins.push(stdin); + const child = Object.assign(new NodeEvents.EventEmitter(), { + stdin, + stdout, + killed: false, + exitCode: null as number | null, + kill: () => { + stdout.end(); + child.emit("exit", null, "SIGTERM"); + return true; + }, + }); + let pending = ""; + stdin.on("data", (chunk: Buffer) => { + pending += chunk.toString("utf8"); + for (let end = pending.indexOf("\n"); end >= 0; end = pending.indexOf("\n")) { + const frame = JSON.parse(pending.slice(0, end)); + pending = pending.slice(end + 1); + if (frame.type !== "control_request") continue; + controlRequests.push(frame.request); + const setServers = frame.request.subtype === "mcp_set_servers"; + if (setServers && !answerSetServers) continue; + const response = { + subtype: "success", + request_id: frame.request_id, + response: setServers ? { added: [], removed: [], errors: {} } : {}, + }; + stdout.write(`${JSON.stringify({ type: "control_response", response })}\n`); + } + }); + stdin.on("end", () => { + stdout.end(); + child.emit("exit", 0, null); + }); + return child; + }; + // The SDK closes the CLI by ending its stdin. + const closed = () => stdins.length > 0 && stdins.every((stdin) => stdin.writableEnded); + return { spawn, spawns, controlRequests, closed }; +} + +describe("ClaudeAdapterV2 MCP credential channel", () => { + const t3McpServer = { + type: "http" as const, + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer dummy-mcp-credential" }, + }; + const openWith = (cli: ReturnType) => + Effect.gen(function* () { + const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner; + return yield* runner.open({ + threadId: ThreadId.make("thread-claude-mcp-channel"), + providerSessionId: ProviderSessionId.make("provider-session-claude-mcp-channel"), + options: { + ...ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-thread-claude-mcp-channel", + resume: false, + cwd: null, + environment: { PATH: "/usr/bin" }, + mcpServers: { "t3-code": t3McpServer }, + }), + pathToClaudeCodeExecutable: "/opt/claude/cli.js", + spawnClaudeCodeProcess: cli.spawn, + }, + }); + }).pipe( + Effect.provide(ClaudeAdapterV2.layerQueryRunner), + Effect.provideService( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + Effect.provide(NodeServices.layer), + ); + + it.effect("sends MCP servers over stdin, not argv or the environment", () => + Effect.gen(function* () { + const cli = makeFakeClaudeCli(); + const session = yield* openWith(cli); + yield* session.close; + + assert.deepInclude(cli.controlRequests, { + subtype: "mcp_set_servers", + servers: { "t3-code": t3McpServer }, + }); + const spawned = JSON.stringify(cli.spawns); + assert.notInclude(spawned, "--mcp-config"); + assert.notInclude(spawned, "dummy-mcp-credential"); + }), + ); + + it.effect("closes the CLI when it never answers the MCP registration", () => + Effect.gen(function* () { + const cli = makeFakeClaudeCli(false); + const opening = yield* openWith(cli).pipe(Effect.result, Effect.forkChild); + yield* TestClock.adjust("90 seconds"); + const opened = yield* Fiber.join(opening); + + assert.equal(opened._tag, "Failure"); + assert.isTrue(cli.closed()); + }), + ); +}); + describe("ClaudeAdapterV2 native session identity", () => { const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6251e698b2ad..aadf28a618f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -185,6 +185,9 @@ export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROV const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); // Entry extensions the Claude SDK runs through node instead of executing. const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; +// The SDK puts no deadline on control requests. The CLI answers +// mcp_set_servers once each server connected or hit its own 30 s timeout. +const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; export const ClaudeProviderCapabilitiesV2 = { sessions: { @@ -636,11 +639,16 @@ export const layerQueryRunner: Layer.Layer< ), Stream.toAsyncIterable, ); + // The SDK would pass `mcpServers` as an inline `--mcp-config` argument, + // which every local user can read. They go over the CLI's stdin control + // channel instead, so header credentials stay out of its argv and out + // of the environment every command the agent runs inherits. + const { mcpServers, ...queryOptions } = input.options; // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, // which lets the agent scope wrapper go in front of the CLI while the // SDK keeps its own stderr capture and exit reporting. The SDK runs a // script entry through node instead, so those launch unwrapped. - const binaryPath = input.options.pathToClaudeCodeExecutable; + const binaryPath = queryOptions.pathToClaudeCodeExecutable; const launch = binaryPath === undefined || CLAUDE_SCRIPT_EXTENSIONS.some((ext) => binaryPath.endsWith(ext)) @@ -650,32 +658,74 @@ export const layerQueryRunner: Layer.Layer< args: [], name: "claude", threadId: input.threadId, - env: input.options.env, + env: queryOptions.env, }); const options = launch === undefined || launch.args.length === 0 - ? input.options + ? queryOptions : { - ...input.options, + ...queryOptions, pathToClaudeCodeExecutable: launch.command, - executableArgs: [...launch.args, ...(input.options.executableArgs ?? [])], + executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], }; - const queryRuntime = yield* Effect.try({ - try: () => - query({ - prompt, - options, - }), - catch: (cause) => queryRunnerError(cause, "query"), - }); - yield* logProtocolEvent({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.open", - options: loggedClaudeQueryOptions(input.options), - }, - }); + // The CLI is running once query() returns, so it is closed if anything + // before the session is handed out fails or is interrupted. + const queryRuntime = yield* Effect.uninterruptibleMask((restore) => + Effect.try({ + try: () => query({ prompt, options }), + catch: (cause) => queryRunnerError(cause, "query"), + }).pipe( + Effect.tap((queryRuntime) => + restore( + Effect.gen(function* () { + if (mcpServers !== undefined) { + // A rejected request or a server that failed to connect + // leaves the turn without those tools, as `--mcp-config` + // did. Error texts and the CLI's mcp_status response can + // echo the headers, so only server names are logged. + const failed = yield* Effect.tryPromise(() => + queryRuntime.setMcpServers(mcpServers), + ).pipe( + Effect.map((result) => Object.keys(result.errors)), + Effect.orElseSucceed(() => Object.keys(mcpServers)), + Effect.timeoutOrElse({ + duration: CLAUDE_MCP_REGISTRATION_TIMEOUT, + orElse: () => + Effect.fail( + queryRunnerError( + "Claude Code did not answer the MCP server registration.", + "setMcpServers", + ), + ), + }), + ); + if (failed.length > 0) { + yield* Effect.logWarning("orchestration-v2.claude-mcp-servers-unavailable", { + threadId: input.threadId, + servers: failed, + }); + } + } + yield* logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.open", + options: loggedClaudeQueryOptions(input.options), + }, + }); + }), + ).pipe( + Effect.onError(() => + Queue.shutdown(promptQueue).pipe( + Effect.andThen(closeClaudeQuery(queryRuntime)), + Effect.ignore, + ), + ), + ), + ), + ), + ); return { messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) => @@ -1005,11 +1055,8 @@ export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; // read-only orchestrator tools so a read-only session cannot silently spawn // threads or scheduled tasks. // -// The SDK passes `mcpServers` to the CLI as an inline `--mcp-config` argument, -// and process arguments are readable by every local user. The credential -// therefore travels in the child's environment, which only its owner can read, -// and the CLI expands the `${VAR}` reference when it connects. -const CLAUDE_T3_MCP_AUTHORIZATION_ENV = "T3_CODE_MCP_AUTHORIZATION"; +// The header carries the credential itself: the query runner sends +// `mcpServers` over the CLI's stdin, never its argv or environment. export function claudeMcpQueryOverrides(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; @@ -1018,7 +1065,6 @@ export function claudeMcpQueryOverrides(input: { }): { readonly allowedTools?: ReadonlyArray; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; - readonly mcpEnvironment?: Readonly>; } { const session = input.mcpSession; if (session === undefined) { @@ -1034,12 +1080,11 @@ export function claudeMcpQueryOverrides(input: { type: "http", url: session.endpoint, headers: { - Authorization: `\${${CLAUDE_T3_MCP_AUTHORIZATION_ENV}}`, + Authorization: session.authorizationHeader, }, timeout: CLAUDE_T3_MCP_TOOL_TIMEOUT_MS, }, }, - mcpEnvironment: { [CLAUDE_T3_MCP_AUTHORIZATION_ENV]: session.authorizationHeader }, }; } @@ -1670,7 +1715,6 @@ export function claudeEffectiveQueryPolicyKey( mcpOverrides: { readonly allowedTools?: ReadonlyArray; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; - readonly mcpEnvironment?: Readonly>; }, ): string { return JSON.stringify({ @@ -1681,7 +1725,6 @@ export function claudeEffectiveQueryPolicyKey( : { allowedTools: mcpOverrides.allowedTools }), }), mcpServers: mcpOverrides.mcpServers, - mcpEnvironment: mcpOverrides.mcpEnvironment, }); } @@ -7488,7 +7531,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( cwd: turnInput.runtimePolicy.cwd, attachmentsDir, settings: adapterOptions.settings, - environment: { ...adapterOptions.environment, ...mcpOverrides.mcpEnvironment }, + environment: adapterOptions.environment, tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, ...(mcpOverrides.allowedTools === undefined ? {} From ec4378614046ab58864ff5e640de2b46f6704505 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:31:06 -0700 Subject: [PATCH 037/250] fix(web): a finished reply replaced by a steer is no longer labeled partial (#17761) Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 130 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 64 ++++++++- .../components/chat/MessagesTimeline.test.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 2 +- apps/web/src/session-logic.ts | 2 +- 5 files changed, 197 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 8ba1552452a5..45613d72aac5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -973,6 +973,7 @@ describe("deriveMessagesTimelineRows", () => { status: "superseded" as const, attemptOrdinal: 0, rootNodeId: "old-root" as never, + completedAt: null, }, } : {}), @@ -3819,6 +3820,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 1, rootNodeId: "node-attempt-1" as never, status: "superseded" as const, + completedAt: null, }; const activeAttempt = { id: activeAttemptId, @@ -3826,6 +3828,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 2, rootNodeId: "node-attempt-2" as never, status: "running" as const, + completedAt: null, }; const timelineEntries = [ { @@ -3957,6 +3960,131 @@ describe("v2 run and attempt history", () => { "active-assistant-entry", ]); }); + + describe("a steer that supersedes an attempt", () => { + const runId = RunId.make("run-steered"); + const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`; + // The steer stamps the superseded attempt at 0:10. + const supersededAttempt = { + id: RunAttemptId.make("attempt-1"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("node-attempt-1"), + status: "superseded" as const, + completedAt: DateTime.makeUnsafe(at(10)), + }; + const nextAttempt = { + id: RunAttemptId.make("attempt-2"), + runId, + attemptOrdinal: 2, + rootNodeId: NodeId.make("node-attempt-2"), + status: "completed" as const, + completedAt: DateTime.makeUnsafe(at(14)), + }; + const assistantEntry = ( + id: string, + attempt: typeof supersededAttempt | typeof nextAttempt, + completedSecond: number, + ): TimelineEntry => ({ + id, + kind: "message", + createdAt: at(2), + attempt, + message: { + id: MessageId.make(id), + role: "assistant", + text: `${id} text`, + runId, + createdAt: at(2), + updatedAt: at(completedSecond), + streaming: false, + }, + projectedItem: { + item: { + type: "assistant_message", + status: "completed", + completedAt: DateTime.makeUnsafe(at(completedSecond)), + }, + } as never, + }); + const rowsFor = ( + replyCompletedSecond: number, + runStatus: "running" | "completed" = "completed", + ) => + deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "initial-user-entry", + kind: "message", + createdAt: at(0), + attempt: supersededAttempt, + message: { + id: MessageId.make("initial-user"), + role: "user", + text: "Build it", + runId, + inputIntent: "turn_start", + createdAt: at(0), + updatedAt: at(0), + streaming: false, + }, + }, + { + id: "old-work-entry", + kind: "work", + createdAt: at(1), + attempt: supersededAttempt, + entry: { id: "old-work", createdAt: at(1), runId, label: "Read", tone: "tool" }, + }, + assistantEntry("old-reply-entry", supersededAttempt, replyCompletedSecond), + { + id: "steer-user-entry", + kind: "message", + createdAt: at(10), + attempt: nextAttempt, + message: { + id: MessageId.make("steer-user"), + role: "user", + text: "What are you working on?", + runId, + inputIntent: "steer", + createdAt: at(10), + updatedAt: at(10), + streaming: false, + }, + }, + assistantEntry("new-reply-entry", nextAttempt, 14), + ], + latestRun: { + runId, + status: runStatus, + startedAt: at(0), + completedAt: runStatus === "completed" ? at(14) : null, + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + it.each(["running", "completed"] as const)( + "keeps a reply that finished before the steer visible while the run is %s", + (runStatus) => { + const rows = rowsFor(4, runStatus); + expect(rows.some((row) => row.kind === "attempt-fold")).toBe(false); + expect(rows.map((row) => row.id)).toContain("old-reply-entry"); + }, + ); + + it("folds a reply the steer cut off as partial output", () => { + const rows = rowsFor(11, "running"); + expect(rows.find((row) => row.kind === "attempt-fold")).toMatchObject({ + attemptId: supersededAttempt.id, + label: "Superseded attempt", + }); + expect(rows.map((row) => row.id)).not.toContain("old-reply-entry"); + }); + }); + it("hides the interruption request while keeping intervening work and the result", () => { const runId = "turn-1" as never; const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({ @@ -4962,6 +5090,7 @@ describe("failed turn transcript", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + completedAt: null, }, })), latestRun: { @@ -5160,6 +5289,7 @@ describe("live subagents after their parent turn settles", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + completedAt: null, }; const entries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b4f879e9a995..684b0e641f83 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,7 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; +import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; import { assistantCitationLabel, @@ -752,7 +753,52 @@ interface SupersededAttemptFold { } /** - * Groups only provider output owned by an explicitly superseded V2 attempt. + * Final replies of superseded attempts that finished before the steer replaced + * them. A steer stamps the attempt's completedAt when it is dispatched, and the + * provider closes a reply it cut off only after that, so a reply whose item + * completed earlier was whole. Item status alone cannot tell: an interrupted + * stream also closes as `completed`. The reply must be the attempt's last + * output; trailing tool or reasoning work means the attempt was still going. + */ +function completeSupersededReplyEntryIds( + timelineEntries: ReadonlyArray, +): ReadonlySet { + const lastOutputByAttemptId = new Map(); + for (const entry of timelineEntries) { + if ( + entry.attempt?.status !== "superseded" || + (entry.kind === "message" && entry.message.role === "user") || + (entry.kind === "work" && + (entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification")) || + (entry.kind !== "message" && entry.kind !== "work") + ) { + continue; + } + lastOutputByAttemptId.set(entry.attempt.id, entry); + } + const replyEntryIds = new Set(); + for (const entry of lastOutputByAttemptId.values()) { + const supersededAt = entry.attempt?.completedAt ?? null; + const item = entry.kind === "message" ? entry.projectedItem?.item : undefined; + if ( + entry.kind === "message" && + !entry.message.streaming && + item?.type === "assistant_message" && + item.status === "completed" && + item.completedAt !== null && + supersededAt !== null && + DateTime.isLessThan(item.completedAt, supersededAt) + ) { + replyEntryIds.add(entry.id); + } + } + return replyEntryIds; +} + +/** + * Groups only provider output owned by an explicitly superseded V2 attempt + * that the steer cut off. An attempt that had already finished its reply is + * left in the timeline like any other turn output. * User messages remain visible because they are inputs to the logical run, * including the steer message that started the replacement attempt. */ @@ -760,11 +806,18 @@ function deriveSupersededAttemptFolds( timelineEntries: ReadonlyArray, unfoldedRunIds: ReadonlySet, liveSubagentEntryIds: ReadonlySet, + completeReplyEntryIds: ReadonlySet, ): ReadonlyMap { + const completeAttemptIds = new Set( + timelineEntries.flatMap((entry) => + entry.attempt !== undefined && completeReplyEntryIds.has(entry.id) ? [entry.attempt.id] : [], + ), + ); const entriesByAttemptId = new Map(); for (const entry of timelineEntries) { if ( entry.attempt?.status !== "superseded" || + completeAttemptIds.has(entry.attempt.id) || unfoldedRunIds.has(entry.attempt.runId) || (entry.kind === "message" && entry.message.role === "user") || // A published page stays visible, as it does when its turn folds. @@ -966,6 +1019,8 @@ function deriveTurnFolds(input: { /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ runlessWorkActive: boolean; liveSubagentEntryIds: ReadonlySet; + /** Finished replies a steer superseded; they answer their prompt, so they stay out. */ + completeSupersededReplyEntryIds: ReadonlySet; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -1082,7 +1137,8 @@ function deriveTurnFolds(input: { // launching run and stay visible after the surrounding work folds. if ( timelineEntryIsPersistentResourceCard(entry) || - input.liveSubagentEntryIds.has(entry.id) + input.liveSubagentEntryIds.has(entry.id) || + input.completeSupersededReplyEntryIds.has(entry.id) ) { continue; } @@ -1320,6 +1376,7 @@ function deriveTimelineTurnFolds( unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive: input.isWorking && input.runlessWorkActive === true, liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries), + completeSupersededReplyEntryIds: completeSupersededReplyEntryIds(timelineEntries), }); } @@ -1365,10 +1422,12 @@ export function deriveMessagesTimelineRows(input: { const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); + const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( timelineEntries, failedRunIds, liveSubagentEntryIds, + completeSupersededReplyIds, ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, @@ -1383,6 +1442,7 @@ export function deriveMessagesTimelineRows(input: { unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive, liveSubagentEntryIds, + completeSupersededReplyEntryIds: completeSupersededReplyIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index bb5170390a1a..199cdfcbdf62 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1471,6 +1471,7 @@ describe("MessagesTimeline", () => { attemptOrdinal: 1, rootNodeId: "node-attempt-1" as never, status: "superseded" as const, + completedAt: null, }; const activeAttempt = { id: "attempt-2" as never, @@ -1478,6 +1479,7 @@ describe("MessagesTimeline", () => { attemptOrdinal: 2, rootNodeId: "node-attempt-2" as never, status: "running" as const, + completedAt: null, }; const markup = renderToStaticMarkup( { expect(markup).toContain('data-superseded-attempt-id="attempt-1"'); expect(markup).toContain('aria-expanded="false"'); expect(markup).toContain("Superseded attempt"); - expect(markup).toContain("Partial output retained"); + expect(markup).toContain("Cut off by a steer"); expect(markup).toContain("Current response remains visible"); expect(markup).not.toContain("Partial response from the old attempt"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0518c96afc58..052ebedd9a24 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2744,7 +2744,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract {row.label} - Partial output retained + Cut off by a steer ); } diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9ce8167251b8..a1554683a564 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -119,7 +119,7 @@ export interface LatestProposedPlanState { export type TimelineAttempt = Pick< OrchestrationV2RunAttempt, - "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" + "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt" >; export type TimelineEntry = ( From 61eb4af69c38c1f39e58ed76aa695b751f9f9baf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 12:31:57 -0700 Subject: [PATCH 038/250] fix(client-runtime): queued runs that start after a steer show up in the thread (#17764) Co-authored-by: Claude Opus 5.5 --- .../environment-cache-store.test.ts | 3 +- .../src/connection/environment-cache-store.ts | 11 +- apps/web/src/connection/storage.ts | 3 +- .../src/platform/orchestrationCache.test.ts | 22 ++- .../src/platform/orchestrationCache.ts | 7 +- .../state/orchestrationV2Projection.test.ts | 127 ++++++++++++++++++ .../src/state/orchestrationV2Projection.ts | 40 ++++++ 7 files changed, 206 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 5548a0fc7c88..4514c20e8564 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -1,5 +1,6 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, + ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationShellSnapshot, } from "@t3tools/client-runtime/platform"; import { @@ -251,7 +252,7 @@ describe("mobile SQLite environment cache store", () => { ORCHESTRATION_CACHE_SCHEMA_VERSION, ); expect(memory.schemaVersions.get(cacheId(ENVIRONMENT_ID, "thread", THREAD_ID))).toBe( - ORCHESTRATION_CACHE_SCHEMA_VERSION, + ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, ); }), ); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index d59b80dbc7e2..653aded1657d 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,5 +1,6 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, + ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationThreadSnapshot, Persistence, } from "@t3tools/client-runtime/platform"; @@ -148,13 +149,19 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) { const threadId = snapshot.projection.thread.id; const payload = yield* encodeStoredThreadSnapshot({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, threadId, snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); yield* database - .saveCache(environmentId, "thread", threadId, ORCHESTRATION_CACHE_SCHEMA_VERSION, payload) + .saveCache( + environmentId, + "thread", + threadId, + ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, + payload, + ) .pipe(Effect.mapError(mapDatabaseError("save-thread"))); }), removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) => diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 15fca39e174e..8be169fccd45 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -3,6 +3,7 @@ import { type ConnectionCatalogDocument as ConnectionCatalogDocumentType, EMPTY_CONNECTION_CATALOG_DOCUMENT, ORCHESTRATION_CACHE_SCHEMA_VERSION, + ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationShellSnapshot, StoredOrchestrationThreadSnapshot, decodeOrDiscardOrchestrationCache, @@ -810,7 +811,7 @@ export const layer = Layer.effectContext( saveThread: (environmentId, snapshot) => Effect.gen(function* () { const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, threadId: snapshot.projection.thread.id, snapshot, diff --git a/packages/client-runtime/src/platform/orchestrationCache.test.ts b/packages/client-runtime/src/platform/orchestrationCache.test.ts index 39170fe8cb81..06f4987c557b 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.test.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.test.ts @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema"; import { ORCHESTRATION_CACHE_SCHEMA_VERSION, + ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, StoredOrchestrationShellSnapshot, StoredOrchestrationThreadSnapshot, decodeOrDiscardOrchestrationCache, @@ -102,7 +103,7 @@ describe("orchestration cache envelopes", () => { expect(projection).not.toBeNull(); if (projection === null) throw new Error("Expected live notice projection"); const encoded = encodeStoredThreadSnapshotJson({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, threadId: v2ThreadId, snapshot: { snapshotSequence: 5, projection }, @@ -121,7 +122,7 @@ describe("orchestration cache envelopes", () => { }); const shell = decodeStoredShellSnapshotJson(encodedShell); const encodedThread = encodeStoredThreadSnapshotJson({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, threadId: v2ThreadId, snapshot: { snapshotSequence: 4, projection: v2Projection }, @@ -159,6 +160,23 @@ describe("orchestration cache envelopes", () => { }), ); + it("rejects thread snapshots saved before queued-run items were kept, but not shells", () => { + // Version 3 thread windows may have dropped a queued run's items (#16987). + const staleThread = encodeStoredThreadSnapshotJson({ + schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, + environmentId, + threadId: v2ThreadId, + snapshot: { snapshotSequence: 4, projection: v2Projection }, + }).replace(`"schemaVersion":${ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION}`, `"schemaVersion":3`); + expect(() => decodeStoredThreadSnapshotJson(staleThread)).toThrow(); + const shell = encodeStoredShellSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: v2ShellSnapshot, + }); + expect(decodeStoredShellSnapshotSync(shell).schemaVersion).toBe(3); + }); + it.effect("discards V1-versioned cache envelopes after a decode failure", () => Effect.gen(function* () { let discardCount = 0; diff --git a/packages/client-runtime/src/platform/orchestrationCache.ts b/packages/client-runtime/src/platform/orchestrationCache.ts index e39fe6c1dfe6..4f1f910827f1 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.ts @@ -10,6 +10,11 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 3 as const; +/** + * Thread snapshots version on their own so a thread-only invalidation keeps + * shell caches warm. 4 drops bounded windows that lost queued runs' items (#16987). + */ +export const ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION = 4 as const; export const StoredOrchestrationShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), @@ -18,7 +23,7 @@ export const StoredOrchestrationShellSnapshot = Schema.Struct({ }); export const StoredOrchestrationThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), + schemaVersion: Schema.Literal(ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION), environmentId: EnvironmentId, threadId: ThreadId, snapshot: OrchestrationV2ThreadDetailSnapshot.mapFields((fields) => ({ diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index 1a53afb8d7e8..e41eb81257f9 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -275,6 +275,133 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.visibleTurnItems.map((row) => row.position)).toEqual([0, 1]); }); + describe("partial timeline", () => { + const queuedRunId = RunId.make("run-queued"); + const steerRunId = RunId.make("run-steer"); + const queuedRun = { ...run, id: queuedRunId, ordinal: 2, status: "queued" } as const; + const steerRun = { ...run, id: steerRunId, ordinal: 3, status: "completed" } as const; + const runItem = (id: string, itemRunId: RunId, ordinal: number) => ({ + ...commandItem(id, id, ordinal), + runId: itemRunId, + }); + const windowOf = ( + runs: ReadonlyArray, + items: ReadonlyArray, + ): OrchestrationV2ThreadProjection => ({ + ...emptyProjection, + runs, + turnItems: items, + visibleTurnItems: items.map((item, position) => ({ + position, + visibility: "local" as const, + sourceThreadId: threadId, + sourceItemId: item.id, + item, + })), + }); + const itemEvent = (payload: OrchestrationV2TurnItem) => + ({ + id: `event-${payload.id}`, + type: "turn-item.updated", + threadId, + occurredAt: now, + payload, + }) as OrchestrationV2DomainEvent; + const runEvent = (payload: OrchestrationV2Run) => + ({ + id: `event-${payload.id}-${payload.status}`, + type: "run.updated", + threadId, + occurredAt: now, + payload, + }) as OrchestrationV2DomainEvent; + + it("shows a queued run that starts after a newer steer raised the watermark", () => { + // Run 2 was queued first, run 3 was steered in and ran first. Its items + // sit in run 3's band and already raised the watermark past run 2's band. + const steerItems = [ + runItem("item-steer-user", steerRunId, 3_000_001), + runItem("item-steer-reply", steerRunId, 3_000_002), + ]; + let projection: OrchestrationV2ThreadProjection | null = windowOf( + [run, queuedRun, steerRun], + steerItems, + ); + let latestLocalTurnOrdinal = 3_000_002; + // Same order the orchestrator commits a queued start: the user message + // lands while the run is still queued, then the run starts and replies. + const queuedUser = runItem("item-queued-user", queuedRunId, 2_000_001); + const queuedReply = runItem("item-queued-reply", queuedRunId, 2_000_002); + for (const event of [ + itemEvent(queuedUser), + runEvent({ ...queuedRun, status: "running" }), + itemEvent(queuedReply), + runEvent({ ...queuedRun, status: "completed" }), + ]) { + projection = applyOrchestrationV2ProjectionEvent(projection, event, { + partialTimeline: true, + latestLocalTurnOrdinal, + }); + if (event.type === "turn-item.updated") { + latestLocalTurnOrdinal = Math.max(latestLocalTurnOrdinal, event.payload.ordinal); + } + } + + expect(projection?.visibleTurnItems.map((row) => row.item.id)).toEqual([ + queuedUser.id, + queuedReply.id, + ...steerItems.map((item) => item.id), + ]); + expect(projection?.turnItems.map((item) => item.id)).toContain(queuedReply.id); + }); + + it("keeps a running run's item when the window starts after its earlier items", () => { + // The bounded window begins inside run 3; run 2 started and kept going, + // but none of its rows made it into the window. + const runningRun = { ...queuedRun, status: "running" } as const; + const steerItems = [ + runItem("item-steer-user", steerRunId, 3_000_001), + runItem("item-steer-reply", steerRunId, 3_000_002), + ]; + const projection = windowOf([run, runningRun, steerRun], steerItems); + const reply = runItem("item-running-reply", queuedRunId, 2_000_003); + + const next = applyOrchestrationV2ProjectionEvent(projection, itemEvent(reply), { + partialTimeline: true, + latestLocalTurnOrdinal: 3_000_002, + }); + + expect(next?.visibleTurnItems.map((row) => row.item.id)).toEqual([ + reply.id, + ...steerItems.map((item) => item.id), + ]); + }); + + it.each([ + { name: "keeps an item of a run already in the window", itemRunId: steerRunId, kept: true }, + { name: "drops an item of a finished run outside the window", itemRunId: runId, kept: false }, + ])("$name that arrives below the watermark", ({ itemRunId, kept }) => { + const first = runItem("item-first", steerRunId, 3_000_001); + const later = runItem("item-later", steerRunId, 3_000_003); + const late = runItem("item-late", itemRunId, 3_000_002); + const projection = windowOf([run, steerRun], [first, later]); + + const next = applyOrchestrationV2ProjectionEvent(projection, itemEvent(late), { + partialTimeline: true, + latestLocalTurnOrdinal: later.ordinal, + }); + if (kept) { + expect(next?.visibleTurnItems.map((row) => row.item.id)).toEqual([ + first.id, + late.id, + later.id, + ]); + } else { + expect(next).toBe(projection); + } + }); + }); + it("removes only hidden local items while preserving inherited rows", () => { const inherited = commandItem("item-inherited"); const local = commandItem("item-local"); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 7e96a3039353..4587221c07df 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -1,5 +1,6 @@ import type { OrchestrationV2DomainEvent, + OrchestrationV2Run, OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, } from "@t3tools/contracts"; @@ -86,6 +87,14 @@ function oldestLocalTurnOrdinal( return oldest; } +const UNFINISHED_RUN_STATUSES: ReadonlySet = new Set([ + "preparing", + "queued", + "starting", + "running", + "waiting", +]); + function shouldDropMissingPartialTurnItem( projection: OrchestrationV2ThreadProjection, item: OrchestrationV2TurnItem, @@ -94,6 +103,37 @@ function shouldDropMissingPartialTurnItem( if (projection.visibleTurnItems.some((row) => row.sourceItemId === item.id)) { return false; } + if (item.runId === null) { + return isBelowPartialWindow(projection, item, latestLocalTurnOrdinal); + } + // A queued run keeps the ordinal it got when queued, so a steer sent later + // can raise the watermark past it. Items of a run that has not finished are + // new, not unloaded history: the first lands while the run is still queued, + // and a bounded window can start after the ones before it. + if ( + projection.runs.some((run) => run.id === item.runId && UNFINISHED_RUN_STATUSES.has(run.status)) + ) { + return false; + } + // Later items of a run already in the window can raise the watermark first. + if ( + projection.visibleTurnItems.some( + (row) => + row.visibility === "local" && + row.item.runId === item.runId && + row.item.ordinal <= item.ordinal, + ) + ) { + return false; + } + return isBelowPartialWindow(projection, item, latestLocalTurnOrdinal); +} + +function isBelowPartialWindow( + projection: OrchestrationV2ThreadProjection, + item: OrchestrationV2TurnItem, + latestLocalTurnOrdinal: number | null | undefined, +): boolean { if ( latestLocalTurnOrdinal !== null && latestLocalTurnOrdinal !== undefined && From 2a4ee339827e480da6ad0139585cf634a46ff73e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 13:04:09 -0700 Subject: [PATCH 039/250] feat(mobile): choose the microphone order for voice input (#17896) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/Stack.tsx | 8 + apps/mobile/src/components/AppSymbol.tsx | 7 + .../settings/EnvironmentRoutesSection.tsx | 65 +--- .../SettingsMicrophoneRouteScreen.tsx | 323 ++++++++++++++++++ .../features/settings/SettingsRouteScreen.tsx | 5 +- .../components/SettingsDragHandle.tsx | 64 ++++ .../components/settings-sheet-targets.ts | 1 + .../voice-input/VoiceInputProvider.tsx | 27 ++ .../voice-input/lazyVoiceRecorder.test.ts | 35 +- .../features/voice-input/lazyVoiceRecorder.ts | 23 +- .../mobile/src/lib/microphonePriority.test.ts | 93 +++++ apps/mobile/src/lib/microphonePriority.ts | 132 +++++++ .../src/persistence/mobile-preferences.ts | 13 + docs/user/composer.md | 7 + 14 files changed, 737 insertions(+), 66 deletions(-) create mode 100644 apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx create mode 100644 apps/mobile/src/features/settings/components/SettingsDragHandle.tsx create mode 100644 apps/mobile/src/lib/microphonePriority.test.ts create mode 100644 apps/mobile/src/lib/microphonePriority.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ec4b2565e174..c089945ccc8f 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -102,6 +102,7 @@ import { import { ScheduledTaskEditorProvider } from "./features/settings/scheduled-task-editor"; import { SettingsKeyboardRouteScreen } from "./features/settings/SettingsKeyboardRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; +import { SettingsMicrophoneRouteScreen } from "./features/settings/SettingsMicrophoneRouteScreen"; import { SettingsOpenSourceLicenseRouteScreen, SettingsOpenSourceLicensesRouteScreen, @@ -310,6 +311,13 @@ const SettingsContentStack = createV5SheetStackNavigator({ title: "Keyboard", }, }), + SettingsMicrophone: createNativeStackScreen({ + screen: SettingsMicrophoneRouteScreen, + linking: "microphone", + options: { + title: "Microphone", + }, + }), SettingsFollowUp: createNativeStackScreen({ screen: SettingsFollowUpRouteScreen, linking: "follow-ups", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 122ad1658e27..c9457a55c83a 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -28,6 +28,7 @@ import IconBolt from "@tabler/icons-react-native/IconBolt"; import IconBox from "@tabler/icons-react-native/IconBox"; import IconBrain from "@tabler/icons-react-native/IconBrain"; import IconCamera from "@tabler/icons-react-native/IconCamera"; +import IconCar from "@tabler/icons-react-native/IconCar"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; import IconCloud from "@tabler/icons-react-native/IconCloud"; @@ -58,6 +59,7 @@ import IconFilter from "@tabler/icons-react-native/IconFilter"; import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; import IconFolder from "@tabler/icons-react-native/IconFolder"; import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; +import IconHeadphones from "@tabler/icons-react-native/IconHeadphones"; import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; @@ -83,6 +85,7 @@ import IconMoon from "@tabler/icons-react-native/IconMoon"; import IconNetwork from "@tabler/icons-react-native/IconNetwork"; import IconPalette from "@tabler/icons-react-native/IconPalette"; import IconPencil from "@tabler/icons-react-native/IconPencil"; +import IconPlug from "@tabler/icons-react-native/IconPlug"; import IconPhoto from "@tabler/icons-react-native/IconPhoto"; import IconPin from "@tabler/icons-react-native/IconPin"; import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; @@ -146,7 +149,9 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, brain: IconBrain, + "cable.connector": IconPlug, camera: IconCamera, + car: IconCar, "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, @@ -178,9 +183,11 @@ const ANDROID_ICON_BY_SF_SYMBOL = { gearshape: IconSettings, globe: IconWorld, hammer: IconHammer, + headphones: IconHeadphones, house: IconHome, "info.circle": IconInfoCircle, internaldrive: IconDatabase, + iphone: IconDeviceMobile, keyboard: IconKeyboard, laptopcomputer: IconDeviceLaptop, link: IconLink, diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index b20699ecb26b..17b6bcadd880 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -13,9 +13,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; import { AsyncResult } from "effect/reactivity"; import * as Option from "effect/Option"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useState } from "react"; import { Alert, Platform, Pressable, View } from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; @@ -27,6 +26,7 @@ import { environmentSession } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { connectionTone } from "../connection/connectionTone"; import { SettingsActionRow } from "./components/SettingsActionRow"; +import { SettingsDragHandle } from "./components/SettingsDragHandle"; import { SettingsSection } from "./components/SettingsSection"; const ICON_SIZE = Platform.OS === "android" ? 24 : 22; @@ -324,7 +324,7 @@ function RouteRow(props: { {props.editing ? ( - 1} canMoveDown={props.position < props.count} @@ -337,62 +337,3 @@ function RouteRow(props: { ); } - -/** Pan recognition wins over the settings scroll view only inside the handle. */ -function DragHandle(props: { - readonly title: string; - readonly canMoveUp: boolean; - readonly canMoveDown: boolean; - readonly onStart: () => void; - readonly onMove: (translation: number) => void; - readonly onEnd: (translation: number, cancelled: boolean) => void; - readonly onStep: (direction: "up" | "down") => void; -}) { - const latest = useRef(props); - useEffect(() => { - latest.current = props; - }); - const translation = useRef(0); - const gesture = useMemo( - () => - Gesture.Pan() - .minDistance(0) - .shouldCancelWhenOutside(false) - .runOnJS(true) - .onStart(() => { - translation.current = 0; - latest.current.onStart(); - }) - .onUpdate((event) => { - translation.current = event.translationY; - latest.current.onMove(event.translationY); - }) - .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)), - [], - ); - return ( - - { - if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); - if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); - }} - style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }} - > - - - - ); -} diff --git a/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx new file mode 100644 index 000000000000..9013258f3282 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx @@ -0,0 +1,323 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/reactivity"; +import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio"; +import { useCallback, useEffect, useState } from "react"; +import { AppState, Linking, Pressable, ScrollView, View } from "react-native"; +import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { + isMicrophoneDevice, + type MicrophoneEntry, + type MicrophoneKind, + microphoneEntryKey, + microphoneOrder, +} from "../../lib/microphonePriority"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { SettingsActionRow } from "./components/SettingsActionRow"; +import { SettingsDragHandle } from "./components/SettingsDragHandle"; +import { SettingsSection } from "./components/SettingsSection"; + +const REMOVE_SIZE = 20; + +type MicrophonePermission = "checking" | "granted" | "ask" | "denied"; + +/** Re-reads microphone access whenever the app returns from system Settings. */ +function useMicrophonePermission() { + const [permission, setPermission] = useState("checking"); + const apply = useCallback( + (response: { readonly granted: boolean; readonly canAskAgain: boolean }) => + setPermission(response.granted ? "granted" : response.canAskAgain ? "ask" : "denied"), + [], + ); + const refresh = useCallback( + () => void getRecordingPermissionsAsync().then(apply, () => setPermission("ask")), + [apply], + ); + useEffect(() => { + refresh(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + return () => subscription.remove(); + }, [refresh]); + const request = useCallback( + () => void requestRecordingPermissionsAsync().then(apply, refresh), + [apply, refresh], + ); + return { permission, request }; +} + +const KINDS: Record< + MicrophoneKind, + { + /** Shown under a remembered device's name. */ + readonly label: string; + readonly row: string; + readonly rowDescription: string; + readonly icon: AppSymbolName; + } +> = { + wired: { + label: "Wired", + row: "Other wired", + rowDescription: "Headsets and USB microphones not listed", + icon: "cable.connector", + }, + bluetooth: { + label: "Bluetooth", + row: "Other Bluetooth", + rowDescription: "AirPods, headsets, and cars not listed", + icon: "headphones", + }, + builtIn: { + label: "Built-in", + row: "Built-in microphone", + rowDescription: "The microphone on this device", + icon: "iphone", + }, + carPlay: { + label: "CarPlay", + row: "CarPlay", + rowDescription: "The car’s microphone", + icon: "car", + }, +}; + +/** + * Microphone kinds and remembered devices, in the order voice input prefers + * them. iOS only. A device joins the list the first time it is connected + * during dictation; until then its kind's row ranks it. + */ +export function SettingsMicrophoneRouteScreen() { + const insets = useSafeAreaInsets(); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const ready = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const entries = microphoneOrder( + AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined, + ); + const hasDevices = entries.some(isMicrophoneDevice); + const { permission, request: requestPermission } = useMicrophonePermission(); + const [editing, setEditing] = useState(false); + const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>( + null, + ); + const [rowHeight, setRowHeight] = useState(0); + // Each drop remounts the rows so the new order and the cleared offsets land in one frame. + const [drops, setDrops] = useState(0); + + // Transforms apply to the stored order, so a device recorded meanwhile is kept. + const move = (key: string, steps: number) => + savePreferences({ + transform: (current) => { + const next = [...microphoneOrder(current.microphones)]; + const from = next.findIndex((entry) => microphoneEntryKey(entry) === key); + const to = Math.min(next.length - 1, Math.max(0, from + steps)); + if (from === -1 || from === to) return {}; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved!); + return { microphones: next }; + }, + }); + const forget = (key: string) => + savePreferences({ + transform: (current) => ({ + microphones: microphoneOrder(current.microphones).filter( + (entry) => microphoneEntryKey(entry) !== key, + ), + }), + }); + // A lifted row takes a neighbour's slot once it has moved past half of that row. + const dropSteps = (from: number, translation: number) => + rowHeight === 0 + ? 0 + : Math.min(entries.length - 1, Math.max(0, from + Math.round(translation / rowHeight))) - + from; + + const dragFrom = + drag === null ? -1 : entries.findIndex((entry) => microphoneEntryKey(entry) === drag.key); + const dragTo = drag === null ? -1 : dragFrom + dropSteps(dragFrom, drag.translation); + + return ( + + + {permission === "ask" || permission === "denied" ? ( + <> + + void Linking.openSettings() + } + /> + + + {permission === "ask" + ? "Voice input needs microphone access before it can record or list your microphones." + : "Microphone access is off for T3 Code. Turn it on in Settings to use voice input."} + + + ) : null} + setEditing((value) => !value)} + className="px-2 py-1 active:opacity-70" + > + + {editing ? "Done" : "Edit"} + + + ) : undefined + } + > + {entries.map((entry, index) => { + const key = microphoneEntryKey(entry); + const shift = + dragFrom === -1 || index === dragFrom + ? 0 + : dragFrom < dragTo && index > dragFrom && index <= dragTo + ? -rowHeight + : dragFrom > dragTo && index < dragFrom && index >= dragTo + ? rowHeight + : 0; + return ( + setDrag({ key, translation: 0 })} + onDragMove={(translation) => setDrag({ key, translation })} + onDragEnd={(translation, cancelled) => { + setDrag(null); + setDrops((count) => count + 1); + if (!cancelled) move(key, dropSteps(index, translation)); + }} + onStep={(direction) => move(key, direction === "up" ? -1 : 1)} + onForget={isMicrophoneDevice(entry) ? () => forget(key) : undefined} + /> + ); + })} + + + Voice input records from the first connected microphone in this list. Wired and Bluetooth + devices are added by name after you use voice input with them connected; until then, the + Other row ranks them. + + + + ); +} + +function MicrophoneRow(props: { + readonly entry: MicrophoneEntry; + readonly position: number; + readonly count: number; + readonly reorderable: boolean; + readonly editing: boolean; + readonly offset: number; + readonly lifted: boolean; + readonly onHeight: (height: number) => void; + readonly onDragStart: () => void; + readonly onDragMove: (translation: number) => void; + readonly onDragEnd: (translation: number, cancelled: boolean) => void; + readonly onStep: (direction: "up" | "down") => void; + readonly onForget: (() => void) | undefined; +}) { + const { lifted, offset, entry } = props; + const kind = KINDS[entry.kind]; + const title = isMicrophoneDevice(entry) ? entry.name : kind.row; + const style = useAnimatedStyle(() => ({ + transform: [ + { + translateY: lifted + ? offset + : withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }), + }, + ], + zIndex: lifted ? 1 : 0, + })); + return ( + props.onHeight(event.nativeEvent.layout.height)} + className={cn( + "flex-row items-center gap-4 pl-4", + props.position > 1 && !lifted && "border-t border-border-subtle", + lifted && "bg-grouped-card shadow-md", + !props.reorderable && "pr-4", + )} + > + {props.editing && props.onForget ? ( + + + + ) : props.editing ? ( + + ) : null} + + + + + {title} + + + {isMicrophoneDevice(entry) ? kind.label : kind.rowDescription} + + + + {props.reorderable ? ( + 1} + canMoveDown={props.position < props.count} + onStart={props.onDragStart} + onMove={props.onDragMove} + onEnd={props.onDragEnd} + onStep={props.onStep} + /> + ) : null} + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 1f474a9d56b8..adb6551cf5fa 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -154,7 +154,10 @@ function SettingsIndexSections() { {Platform.OS === "ios" ? ( - + <> + + + ) : null} diff --git a/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx b/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx new file mode 100644 index 000000000000..84b9260074ed --- /dev/null +++ b/apps/mobile/src/features/settings/components/SettingsDragHandle.tsx @@ -0,0 +1,64 @@ +import { useEffect, useMemo, useRef } from "react"; +import { View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; + +import { SymbolView } from "../../../components/AppSymbol"; + +/** Pan recognition wins over the settings scroll view only inside the handle. */ +export function SettingsDragHandle(props: { + readonly title: string; + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; + readonly onStart: () => void; + readonly onMove: (translation: number) => void; + readonly onEnd: (translation: number, cancelled: boolean) => void; + readonly onStep: (direction: "up" | "down") => void; +}) { + const latest = useRef(props); + useEffect(() => { + latest.current = props; + }); + const translation = useRef(0); + const gesture = useMemo( + () => + Gesture.Pan() + .minDistance(0) + .shouldCancelWhenOutside(false) + .runOnJS(true) + .onStart(() => { + translation.current = 0; + latest.current.onStart(); + }) + .onUpdate((event) => { + translation.current = event.translationY; + latest.current.onMove(event.translationY); + }) + .onFinalize((_, success) => latest.current.onEnd(translation.current, !success)), + [], + ); + return ( + + { + if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up"); + if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down"); + }} + style={{ width: 48, alignSelf: "stretch", alignItems: "center", justifyContent: "center" }} + > + + + + ); +} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index ad0197d634d8..611a1b3410c7 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -13,6 +13,7 @@ export type SettingsSheetTarget = | "SettingsEnvironmentMaintenance" | "SettingsProviderAccounts" | "SettingsKeyboard" + | "SettingsMicrophone" | "SettingsFollowUp" | "SettingsScheduledTasks" | "SettingsProjectGrouping" diff --git a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx index ebd628e4bad4..6dce91180700 100644 --- a/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx +++ b/apps/mobile/src/features/voice-input/VoiceInputProvider.tsx @@ -1,3 +1,4 @@ +import { RegistryContext, useAtomMount } from "@effect/atom-react"; import { AudioModule, RecordingPresets, @@ -20,9 +21,16 @@ import { type ReactNode, } from "react"; import { AppState, Platform } from "react-native"; +import { AsyncResult } from "effect/reactivity"; import { useSharedValue } from "react-native-reanimated"; +import { + microphoneOrder, + preferredMicrophoneInput, + rememberMicrophones, +} from "../../lib/microphonePriority"; import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { getNativeShowcaseScene } from "../showcase/nativeShowcaseScene"; import { VOICE_RECORDING_LIMIT_SECONDS, @@ -91,6 +99,10 @@ export function useGlobalVoiceInput() { } function useVoiceInputRuntime() { + const registry = use(RegistryContext); + // Dictation reads and records microphones without subscribing the provider to preferences. + useAtomMount(mobilePreferencesAtom); + useAtomMount(updateMobilePreferencesAtom); const [{ state, ownerKey, label }, setState] = useState({ state: INITIAL_STATE, ownerKey: null as string | null, @@ -126,6 +138,21 @@ function useVoiceInputRuntime() { url: status.url, }); }, + // Read when recording starts so preference changes do not re-render every composer. + selectInput: (inputs) => { + const preferences = registry.get(mobilePreferencesAtom); + if (!AsyncResult.isSuccess(preferences)) return null; + const known = microphoneOrder(preferences.value.microphones); + const order = rememberMicrophones(known, inputs); + if (order !== known) { + registry.set(updateMobilePreferencesAtom, { + transform: (current) => ({ + microphones: rememberMicrophones(microphoneOrder(current.microphones), inputs), + }), + }); + } + return preferredMicrophoneInput(inputs, order); + }, }); recorderRef.current = recorder; sessionRef.current = new VoiceInputSession({ diff --git a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts index ffba8faefffd..d0e3832fd94d 100644 --- a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts +++ b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { VoiceInputController } from "@t3tools/client-runtime/voice-input"; -import { createLazyVoiceRecorder } from "./lazyVoiceRecorder"; +import { createLazyVoiceRecorder, type VoiceRecordingInput } from "./lazyVoiceRecorder"; type Status = { readonly isFinished: boolean }; type State = { readonly isRecording: boolean }; @@ -30,12 +30,19 @@ function createNativeRecorder(uri: string) { listeners.add(listener); return { remove: () => listeners.delete(listener) }; }), + getAvailableInputs: vi.fn((): ReadonlyArray => [ + { uid: "car", name: "CarPlay", type: "CarAudio" }, + { uid: "built-in", name: "iPhone Microphone", type: "MicrophoneBuiltIn" }, + ]), + setInput: vi.fn((_uid: string) => undefined), release: vi.fn(), emit: (status: Status) => listeners.forEach((listener) => listener(status)), }; } -function createHarness() { +function createHarness( + selectInput?: (inputs: ReadonlyArray) => VoiceRecordingInput | null, +) { const created: Array> = []; const statuses: Status[] = []; const recorder = createLazyVoiceRecorder({ @@ -45,6 +52,7 @@ function createHarness() { return native; }, onStatus: (status) => statuses.push(status), + ...(selectInput ? { selectInput } : {}), }); return { recorder, created, statuses }; } @@ -79,6 +87,29 @@ describe("createLazyVoiceRecorder", () => { expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2); }); + it("selects the preferred input after preparing, when the session lists inputs", async () => { + const { recorder, created } = createHarness( + (inputs) => inputs.find((input) => input.type === "MicrophoneBuiltIn") ?? null, + ); + + await recorder.prepareToRecordAsync(); + + expect(created[0]!.setInput).toHaveBeenCalledWith("built-in"); + }); + + it("keeps the system input when none is preferred or the preferred one disconnects", async () => { + const unpreferred = createHarness(() => null); + await unpreferred.recorder.prepareToRecordAsync(); + expect(unpreferred.created[0]!.setInput).not.toHaveBeenCalled(); + + const disconnected = createHarness((inputs) => inputs[1] ?? null); + await disconnected.recorder.prepareToRecordAsync(); + disconnected.created[0]!.setInput.mockImplementationOnce(() => { + throw new Error("Preferred input not found"); + }); + await expect(disconnected.recorder.prepareToRecordAsync()).resolves.toBeUndefined(); + }); + it("forwards status events until the recorder is released", async () => { const { recorder, created, statuses } = createHarness(); await recorder.prepareToRecordAsync(); diff --git a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts index d6deb2163c63..678673e9910d 100644 --- a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts +++ b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.ts @@ -2,6 +2,12 @@ import type { VoiceRecorder } from "@t3tools/client-runtime/voice-input"; type Subscription = { remove(): void }; +export interface VoiceRecordingInput { + readonly uid: string; + readonly name: string; + readonly type: string; +} + // The subset of expo-audio's native `AudioRecorder` the voice input hook uses. export interface NativeVoiceRecorder { readonly uri: string | null; @@ -13,6 +19,8 @@ export interface NativeVoiceRecorder { eventName: "recordingStatusUpdate", listener: (status: TStatus) => void, ): Subscription; + getAvailableInputs(): ReadonlyArray; + setInput(uid: string): void; release(): void; } @@ -34,6 +42,8 @@ export interface LazyVoiceRecorder extends VoiceRecorder { export function createLazyVoiceRecorder(input: { readonly create: () => NativeVoiceRecorder; readonly onStatus: (status: TStatus) => void; + /** Picks the input to record from once the session lists them, or null to keep the system's. */ + readonly selectInput?: (inputs: ReadonlyArray) => VoiceRecordingInput | null; }): LazyVoiceRecorder { let recorder: NativeVoiceRecorder | null = null; let subscription: Subscription | null = null; @@ -57,7 +67,18 @@ export function createLazyVoiceRecorder(input: { recorder = input.create(); subscription = recorder.addListener("recordingStatusUpdate", input.onStatus); } - return track(recorder.prepareToRecordAsync()); + const prepared = recorder; + return track( + prepared.prepareToRecordAsync().then(() => { + if (!input.selectInput) return; + try { + const selected = input.selectInput(prepared.getAvailableInputs()); + if (selected) prepared.setInput(selected.uid); + } catch { + // The input can disconnect in between. The system's route still records. + } + }), + ); }, record(options) { if (!recorder) throw new Error("Voice recorder is not prepared."); diff --git a/apps/mobile/src/lib/microphonePriority.test.ts b/apps/mobile/src/lib/microphonePriority.test.ts new file mode 100644 index 000000000000..e92459cb314e --- /dev/null +++ b/apps/mobile/src/lib/microphonePriority.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + type MicrophoneEntry, + microphoneEntryKey, + microphoneOrder, + preferredMicrophoneInput, + rememberMicrophones, +} from "./microphonePriority"; + +const iPhone = { uid: "Built-In Microphone", name: "iPhone Microphone", type: "MicrophoneBuiltIn" }; +const airPods = { uid: "airpods", name: "Julius’s AirPods Pro", type: "BluetoothHFP" }; +const bose = { uid: "bose", name: "Julius’s Bose QC", type: "BluetoothHFP" }; +const civic = { uid: "civic", name: "Honda Civic", type: "BluetoothHFP" }; +const carPlay = { uid: "carplay", name: "CarPlay", type: "CarAudio" }; +const usb = { uid: "usb", name: "Shure MV7", type: "USBAudio" }; + +const keys = (order: ReadonlyArray) => order.map(microphoneEntryKey); + +describe("microphoneOrder", () => { + it("lists every kind once, CarPlay below the built-in microphone by default", () => { + expect(keys(microphoneOrder())).toEqual([ + "kind:wired", + "kind:bluetooth", + "kind:builtIn", + "kind:carPlay", + ]); + }); + + it("keeps a saved order and files missing kinds after their devices", () => { + const saved: MicrophoneEntry[] = [ + { kind: "builtIn" }, + { kind: "bluetooth", uid: "airpods", name: "AirPods" }, + { kind: "builtIn" }, + ]; + expect(keys(microphoneOrder(saved))).toEqual([ + "kind:wired", + "kind:builtIn", + "airpods", + "kind:bluetooth", + "kind:carPlay", + ]); + }); +}); + +describe("rememberMicrophones", () => { + it("lists a new device just above its kind and leaves built-in and CarPlay as kinds", () => { + const order = microphoneOrder(); + expect(keys(rememberMicrophones(order, [iPhone, carPlay, airPods]))).toEqual([ + "kind:wired", + "airpods", + "kind:bluetooth", + "kind:builtIn", + "kind:carPlay", + ]); + }); + + it("refreshes renamed devices and returns the same order when nothing changed", () => { + const order = rememberMicrophones(microphoneOrder(), [airPods]); + expect(rememberMicrophones(order, [airPods, iPhone])).toBe(order); + + const renamed = rememberMicrophones(order, [{ ...airPods, name: "AirPods Max" }]); + expect(renamed.find((entry) => microphoneEntryKey(entry) === "airpods")).toEqual({ + kind: "bluetooth", + uid: "airpods", + name: "AirPods Max", + }); + }); +}); + +describe("preferredMicrophoneInput", () => { + // Built-in, Bose, Wired, other Bluetooth, AirPods, CarPlay. + const order: MicrophoneEntry[] = [ + { kind: "builtIn" }, + { kind: "bluetooth", uid: "bose", name: bose.name }, + { kind: "wired" }, + { kind: "bluetooth" }, + { kind: "bluetooth", uid: "airpods", name: airPods.name }, + { kind: "carPlay" }, + ]; + + it("ranks unlisted devices of a kind at that kind, ahead of a device listed below it", () => { + expect(preferredMicrophoneInput([airPods, civic], order)).toBe(civic); + expect(preferredMicrophoneInput([airPods, bose], order)).toBe(bose); + expect(preferredMicrophoneInput([airPods, usb], order)).toBe(usb); + expect(preferredMicrophoneInput([airPods, carPlay], order)).toBe(airPods); + expect(preferredMicrophoneInput([airPods, iPhone], order)).toBe(iPhone); + }); + + it("leaves inputs of unknown kinds to iOS", () => { + expect(preferredMicrophoneInput([{ uid: "x", name: "x", type: "Virtual" }], order)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/microphonePriority.ts b/apps/mobile/src/lib/microphonePriority.ts new file mode 100644 index 000000000000..46acb36d2f33 --- /dev/null +++ b/apps/mobile/src/lib/microphonePriority.ts @@ -0,0 +1,132 @@ +/** + * The order voice input picks a microphone in. An entry is either a kind, + * which covers every connected microphone of that kind not listed by name, or + * a device remembered the first time dictation saw it connected. iOS lists + * only connected inputs, and only while a recording session is set up, so a + * device cannot be listed before then. + */ +export const MICROPHONE_KINDS = ["wired", "bluetooth", "builtIn", "carPlay"] as const; + +export type MicrophoneKind = (typeof MICROPHONE_KINDS)[number]; + +export interface MicrophoneDevice { + readonly kind: MicrophoneKind; + /** iOS's port identifier, stable across reconnects of the same device. */ + readonly uid: string; + readonly name: string; +} + +export type MicrophoneEntry = { readonly kind: MicrophoneKind } | MicrophoneDevice; + +interface MicrophoneInput { + readonly uid: string; + readonly name: string; + readonly type: string; +} + +// Raw values of AVAudioSession.Port. A car without CarPlay connects as a +// Bluetooth headset, so it is a Bluetooth device. +const KIND_BY_PORT_TYPE: Readonly> = { + MicrophoneWired: "wired", + USBAudio: "wired", + LineIn: "wired", + BluetoothHFP: "bluetooth", + BluetoothLE: "bluetooth", + MicrophoneBuiltIn: "builtIn", + CarAudio: "carPlay", +}; + +// Built-in and CarPlay are listed only as kinds: a phone has one built-in +// microphone, and CarPlay is the car. +const DEVICE_KINDS: ReadonlySet = new Set(["wired", "bluetooth"]); + +const rank = (kind: MicrophoneKind) => MICROPHONE_KINDS.indexOf(kind); + +export function isMicrophoneKind(value: unknown): value is MicrophoneKind { + return (MICROPHONE_KINDS as ReadonlyArray).includes(value); +} + +export function isMicrophoneDevice(entry: MicrophoneEntry): entry is MicrophoneDevice { + return "uid" in entry; +} + +export function microphoneEntryKey(entry: MicrophoneEntry): string { + return isMicrophoneDevice(entry) ? entry.uid : `kind:${entry.kind}`; +} + +/** + * The saved order with each kind listed once. A missing kind goes after the + * devices of that kind, else above the first kind ranked below it by default, + * which puts CarPlay below the built-in microphone. + */ +export function microphoneOrder( + saved: ReadonlyArray = [], +): ReadonlyArray { + const order: MicrophoneEntry[] = []; + const keys = new Set(); + for (const entry of saved) { + const row = + isMicrophoneDevice(entry) && DEVICE_KINDS.has(entry.kind) ? entry : { kind: entry.kind }; + const key = microphoneEntryKey(row); + if (keys.has(key)) continue; + keys.add(key); + order.push(row); + } + for (const kind of MICROPHONE_KINDS) { + if (keys.has(`kind:${kind}`)) continue; + const lastDevice = order.map((entry) => entry.kind).lastIndexOf(kind); + const below = order.findIndex( + (entry) => !isMicrophoneDevice(entry) && rank(entry.kind) > rank(kind), + ); + order.splice(lastDevice !== -1 ? lastDevice + 1 : below !== -1 ? below : order.length, 0, { + kind, + }); + } + return order; +} + +/** + * Lists connected wired and Bluetooth microphones by name and refreshes + * renamed ones. A new device goes just above its kind, keeping the rank it had + * while unlisted. Returns `order` itself when nothing changed. + */ +export function rememberMicrophones( + order: ReadonlyArray, + inputs: ReadonlyArray, +): ReadonlyArray { + let next: MicrophoneEntry[] | null = null; + for (const input of inputs) { + const kind = KIND_BY_PORT_TYPE[input.type]; + if (!kind || !DEVICE_KINDS.has(kind)) continue; + const current = next ?? order; + const index = current.findIndex((entry) => microphoneEntryKey(entry) === input.uid); + const existing = current[index]; + if (existing && isMicrophoneDevice(existing) && existing.name === input.name) continue; + next ??= [...order]; + const device = { kind, uid: input.uid, name: input.name }; + if (index !== -1) { + next[index] = device; + continue; + } + const kindRow = next.findIndex((entry) => microphoneEntryKey(entry) === `kind:${kind}`); + next.splice(kindRow === -1 ? next.length : kindRow, 0, device); + } + return next ?? order; +} + +/** The connected input of the first entry that has one, or null to leave the route to iOS. */ +export function preferredMicrophoneInput( + inputs: ReadonlyArray, + order: ReadonlyArray, +): Input | null { + const listed = new Set(order.filter(isMicrophoneDevice).map((device) => device.uid)); + for (const entry of order) { + const input = inputs.find((candidate) => + isMicrophoneDevice(entry) + ? candidate.uid === entry.uid + : KIND_BY_PORT_TYPE[candidate.type] === entry.kind && !listed.has(candidate.uid), + ); + if (input) return input; + } + return null; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index ee96bce2d5ce..98f6b3d1e8d1 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -8,6 +8,7 @@ import * as Semaphore from "effect/Semaphore"; import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import type { FollowUpBehavior } from "../lib/followUpBehavior"; +import { isMicrophoneKind, type MicrophoneEntry } from "../lib/microphonePriority"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; @@ -36,6 +37,8 @@ export interface Preferences { * message sent during a running turn queues behind it or steers it. */ readonly followUpBehavior?: FollowUpBehavior; + /** Microphone kinds and remembered devices, most preferred first. iOS only. */ + readonly microphones?: ReadonlyArray; /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; @@ -107,6 +110,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; composerEnterBehavior?: ComposerEnterBehavior; followUpBehavior?: FollowUpBehavior; + microphones?: ReadonlyArray; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; planModeEnabled?: boolean; @@ -172,6 +176,15 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.followUpBehavior === "queue" || parsed.followUpBehavior === "steer") { preferences.followUpBehavior = parsed.followUpBehavior; } + if (Array.isArray(parsed.microphones)) { + preferences.microphones = parsed.microphones.flatMap((entry): MicrophoneEntry[] => { + if (typeof entry !== "object" || entry === null || !isMicrophoneKind(entry.kind)) return []; + if (!("uid" in entry)) return [{ kind: entry.kind }]; + return typeof entry.uid === "string" && typeof entry.name === "string" + ? [{ kind: entry.kind, uid: entry.uid, name: entry.name }] + : []; + }); + } if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } diff --git a/docs/user/composer.md b/docs/user/composer.md index ba8d26fd81c2..831a8ac9792d 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -164,6 +164,13 @@ minutes long. Canceling, leaving the screen, or an audio interruption discards t recording and preserves your existing draft. While recording, the screen stays awake; it can sleep normally once recording stops. +Voice input records from the first connected microphone in **Settings → Microphone**. +The list mixes kinds (built-in, other wired, other Bluetooth, CarPlay) with wired and +Bluetooth devices you have dictated with, listed by name. Drag to rank them together: +for example, put **Other Bluetooth** above your AirPods so a new headset is preferred +while the AirPods microphone stays low. By default, CarPlay comes after the built-in +microphone. + Transcription runs on your device. T3 Code deletes the temporary audio after transcription or cancellation; only the message text is sent when you submit. From 4f63275ac7885281d245022e65f1e9e8816b1382 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 13:23:20 -0700 Subject: [PATCH 040/250] feat(source-control): host settings live on each host's definition, with a GitCafe token (#17901) Co-authored-by: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 202 +++++------ apps/server/src/serverSettings.ts | 329 +++++++++++------- .../src/sourceControl/BitbucketApi.test.ts | 20 +- .../SourceControlDiscovery.test.ts | 4 + .../SourceControlProviderRegistry.test.ts | 4 + .../sourceControl/sourceControlHostSecrets.ts | 76 ++++ .../settings/BitbucketCredentialsSettings.tsx | 223 ------------ .../settings/GitHubAccountSettings.logic.ts | 3 +- .../settings/GitHubAccountSettings.tsx | 10 +- .../settings/GitHubTokenSettings.tsx | 8 +- .../settings/ProviderSettingsForm.tsx | 36 +- .../settings/SourceControlHostSettings.tsx | 84 +++++ .../settings/SourceControlSettings.tsx | 38 +- .../src/components/settings/settingsSearch.ts | 33 +- docs/user/source-control.md | 18 +- .../src/sourceControlClients.ts | 16 +- packages/contracts/src/settings.test.ts | 7 - packages/contracts/src/settings.ts | 78 ++--- packages/shared/src/serverSettings.test.ts | 19 +- packages/shared/src/serverSettings.ts | 17 +- .../src/client/definition.ts | 44 ++- .../src/server/BitbucketApi.ts | 23 +- .../src/client/definition.ts | 43 +++ .../src/client/definition.ts | 23 +- .../src/server/GitCafeCredentials.test.ts | 38 ++ .../src/server/GitCafeCredentials.ts | 60 +++- .../GitCafeSourceControlProvider.test.ts | 18 +- .../server/GitCafeSourceControlProvider.ts | 57 ++- .../src/server/driver.ts | 2 +- .../src/client/definition.ts | 36 +- .../src/server/GitHubCredentials.test.ts | 12 +- .../src/server/GitHubCredentials.ts | 14 +- .../src/server/GitHubSourceControlProvider.ts | 16 +- .../src/server/gitHubAuthStatus.ts | 2 +- 34 files changed, 979 insertions(+), 634 deletions(-) create mode 100644 apps/server/src/sourceControl/sourceControlHostSecrets.ts delete mode 100644 apps/web/src/components/settings/BitbucketCredentialsSettings.tsx create mode 100644 apps/web/src/components/settings/SourceControlHostSettings.tsx diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4444a7851dff..d29db34e0664 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -26,6 +26,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; import * as ServerConfig from "./config.ts"; import * as SqlitePersistence from "./persistence/Sqlite.ts"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; @@ -1418,7 +1419,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); it.effect( - "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + "keeps source control host secrets in the secret store and tells clients only that one is set", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1427,139 +1428,134 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; const saved = yield* serverSettings.updateSettings({ - bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + sourceControlHosts: { + bitbucket: { email: "me@example.com", accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); - assert.deepEqual(saved.bitbucket, { + assert.deepEqual(saved.sourceControlHosts.bitbucket, { email: "me@example.com", accessToken: "bb-access", - apiToken: "bb-api", }); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "bb-access"); - assert.notInclude(raw, "bb-api"); + for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret); assert.include(raw, "me@example.com"); - const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; - assert.equal(forClient.email, "me@example.com"); - assert.notInclude(forClient.accessToken, "bb-access"); - assert.notInclude(forClient.apiToken, "bb-api"); - assert.isAbove(forClient.accessToken.length, 0); - assert.isAbove(forClient.apiToken.length, 0); - - // A client echoing the redacted values back, or omitting them, keeps the saved tokens. - yield* serverSettings.updateSettings({ bitbucket: forClient }); - yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); - assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { - email: "other@example.com", - accessToken: "bb-access", - apiToken: "bb-api", - }); - - const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); - assert.equal(cleared.bitbucket.accessToken, ""); - assert.equal(cleared.bitbucket.apiToken, "bb-api"); - assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); - assert.equal( - ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, - "", - ); - }).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 forClient = + ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts; + assert.equal(forClient.bitbucket?.email, "me@example.com"); + assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access"); + assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0); - 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 } }); + // Echoing the redacted values back keeps them; other fields change on their own. + yield* serverSettings.updateSettings({ sourceControlHosts: forClient }); yield* serverSettings.updateSettings({ - github: { hosts: { "github.com": { enabled: true, account: "work" } } }, + sourceControlHosts: { bitbucket: { email: "other@example.com" } }, }); - assert.deepEqual((yield* serverSettings.getSettings).github.tokens, { + const kept = (yield* serverSettings.getSettings).sourceControlHosts; + assert.deepEqual(kept.bitbucket, { email: "other@example.com", accessToken: "bb-access" }); + assert.deepEqual(kept.github?.tokens, { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe", }); - // An empty token removes that host's token and nothing else. + // An empty value removes that secret, and one per server host leaves the others. const cleared = yield* serverSettings.updateSettings({ - github: { tokens: { "github.com": "" } }, + sourceControlHosts: { + bitbucket: { accessToken: "" }, + github: { + tokens: { + "github.com": "", + "ghe.acme.test": (forClient.github!.tokens as Record)[ + "ghe.acme.test" + ], + }, + }, + }, }); - 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]!)); + assert.equal(cleared.sourceControlHosts.bitbucket?.accessToken, ""); + const stored = (kind: string, field: string, serverHost: string | null) => + secrets.get(sourceControlHostSecretName({ kind, field, serverHost })); + assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); - it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => - Effect.gen(function* () { + it.effect("keeps the retired keys for another load when a token cannot be copied", () => { + const cause = new ServerSecretStore.SecretStoreReadError({ + resource: "bitbucket-access-token", + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "bitbucket-access-token", + description: "Secret backend unavailable.", + }), + }); + const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(layerFailingSecretStore(cause)), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-legacy-failure-test-", + }), + ), + ), + ); + return Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; - const secrets = yield* ServerSecretStore.ServerSecretStore; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - // A token was saved, then the user deleted it from settings.json directly. - yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); - yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + const legacy = JSON.stringify({ + bitbucket: { email: "me@example.com", accessToken: "\u2022\u2022\u2022\u2022\u2022\u2022" }, + }); + yield* fileSystem.writeFileString(serverConfig.settingsPath, legacy); - yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + yield* Effect.exit(serverSettings.getSettings); - assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); - }).pipe(Effect.provide(layerServerSettingsWithSecrets())), - ); + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy); + }).pipe(Effect.provide(layerSettings)); + }); - it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => + it.effect("moves the retired bitbucket and github keys and their secrets on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const secrets = yield* ServerSecretStore.ServerSecretStore; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const marker = "\u2022\u2022\u2022\u2022\u2022\u2022"; + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("bb-saved")); + yield* secrets.set( + `github-token-${Buffer.from("github.com", "utf8").toString("base64url")}`, + new TextEncoder().encode("ghp_saved"), + ); yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"bitbucket":{"accessToken":"hand-edited-token"}}', + JSON.stringify({ + bitbucket: { email: "me@example.com", accessToken: marker }, + github: { + hosts: { "github.com": { account: "work" } }, + tokens: { "github.com": marker }, + }, + }), ); - // Loading alone moves it: no settings update is needed. - const loaded = yield* serverSettings.getSettings; + const loaded = (yield* serverSettings.getSettings).sourceControlHosts; - assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); - assert.notInclude( - yield* fileSystem.readFileString(serverConfig.settingsPath), - "hand-edited-token", - ); - const stored = yield* secrets.get("bitbucket-access-token"); - assert.equal( - Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, - "hand-edited-token", - ); + assert.deepEqual(loaded.bitbucket, { email: "me@example.com", accessToken: "bb-saved" }); + assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" }); + assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, '"bitbucket":{"email"'); + assert.include(raw, "sourceControlHosts"); + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); it.effect( - "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + "moves a hand-edited source control token into the secret store when settings load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1567,24 +1563,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + '{"sourceControlHosts":{"bitbucket":{"accessToken":"hand-edited-token"}}}', ); - // The form resends the redacted token when only the email changes. - const forClient = ServerSettingsModule.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ).bitbucket; - const updated = yield* serverSettings.updateSettings({ - bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, - }); + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; - assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); - assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.equal(loaded.sourceControlHosts.bitbucket?.accessToken, "hand-edited-token"); assert.notInclude( yield* fileSystem.readFileString(serverConfig.settingsPath), "hand-edited-token", ); - }).pipe(Effect.provide(layerServerSettings())), + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); it.effect("materializes provider secrets for terminal environment resolution", () => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 7536a74d6e02..5a36f77e4220 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -64,6 +64,13 @@ import { isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import { + readSecretFieldValues, + SOURCE_CONTROL_HOST_SECRET_FIELDS, + type SourceControlHostSecretSlot, + sourceControlHostSecretName, + writeSecretFieldValues, +} from "./sourceControl/sourceControlHostSecrets.ts"; export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; @@ -157,18 +164,47 @@ function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } -const BITBUCKET_SECRET_NAMES = { - accessToken: "bitbucket-access-token", - apiToken: "bitbucket-api-token", -} as const; -const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; +const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); -/** 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")}`; +/** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */ +function mapSlotValues( + hosts: ServerSettings["sourceControlHosts"], + map: (slot: SourceControlHostSecretSlot, value: string) => string, +): ServerSettings["sourceControlHosts"] { + return Object.fromEntries( + Object.entries(hosts).map(([kind, fields]) => { + const secretFields = SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []; + if (secretFields.length === 0) return [kind, fields]; + const next = { ...fields }; + for (const field of secretFields) { + if (!(field in next)) continue; + next[field] = writeSecretFieldValues( + next[field], + readSecretFieldValues(next[field]).map( + ([serverHost, value]) => [serverHost, map({ kind, field, serverHost }, value)] as const, + ), + ); + } + return [kind, next]; + }), + ); } -const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); +/** Every saved secret slot in `hosts`, with its current value. */ +function sourceControlHostSecretSlots( + hosts: ServerSettings["sourceControlHosts"], +): ReadonlyArray { + return Object.entries(hosts).flatMap(([kind, fields]) => + (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => + readSecretFieldValues(fields[field]).map(([serverHost, value]) => ({ + kind, + field, + serverHost, + value, + })), + ), + ); +} function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, @@ -206,18 +242,10 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS }, ]), ); - const bitbucket = { - ...settings.bitbucket, - accessToken: redactSecret(settings.bitbucket.accessToken), - apiToken: redactSecret(settings.bitbucket.apiToken), - }; - const github = { - ...settings.github, - tokens: Object.fromEntries( - Object.entries(settings.github.tokens).map(([host, token]) => [host, redactSecret(token)]), - ), - }; - return { ...settings, providerInstances, usageLimitSources, bitbucket, github }; + const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) => + redactSecret(value), + ); + return { ...settings, providerInstances, usageLimitSources, sourceControlHosts }; } export function applyProviderInstanceMutation( @@ -394,10 +422,60 @@ const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + // Retired for `sourceControlHosts`; read only by `migrateLegacySourceControlSettings`. + bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), }), ); const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); +/** The secret store names the retired `bitbucket` and `github` keys kept their tokens under. */ +const LEGACY_BITBUCKET_SECRET_NAMES: Readonly> = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +}; +const legacyGitHubTokenSecretName = (host: string) => + `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`; + +/** + * Folds the retired `bitbucket` and `github` keys into `sourceControlHosts`, unless the new key + * already holds that host. Returns the secret store entries to copy to their new names. + */ +function migrateLegacySourceControlSettings( + settings: ServerSettings, + legacy: { + readonly bitbucket?: Readonly> | undefined; + readonly github?: Readonly> | undefined; + }, +): { + readonly settings: ServerSettings; + readonly secretMoves: ReadonlyArray<{ readonly from: string; readonly to: string }>; +} { + const sourceControlHosts = { ...settings.sourceControlHosts }; + const secretMoves: Array<{ readonly from: string; readonly to: string }> = []; + if (legacy.bitbucket !== undefined && sourceControlHosts.bitbucket === undefined) { + sourceControlHosts.bitbucket = legacy.bitbucket; + for (const [field, from] of Object.entries(LEGACY_BITBUCKET_SECRET_NAMES)) { + if (legacy.bitbucket[field] !== SECRET_REDACTED) continue; + secretMoves.push({ + from, + to: sourceControlHostSecretName({ kind: "bitbucket", field, serverHost: null }), + }); + } + } + if (legacy.github !== undefined && sourceControlHosts.github === undefined) { + sourceControlHosts.github = legacy.github; + for (const [serverHost, value] of readSecretFieldValues(legacy.github.tokens)) { + if (serverHost === null || value !== SECRET_REDACTED) continue; + secretMoves.push({ + from: legacyGitHubTokenSecretName(serverHost), + to: sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost }), + }); + } + } + return { settings: { ...settings, sourceControlHosts }, secretMoves }; +} + // Drivers that start disabled, so a session in the history means the user // turned them on before the instance kept an explicit flag. const HISTORY_RESTORED_DRIVERS: ReadonlySet = new Set( @@ -724,54 +802,41 @@ const make = Effect.gen(function* () { ); /** - * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, - * so plaintext does not stay on disk. If the store is unavailable, the token keeps working - * from the file and the move is retried on the next load. + * Moves source control host secrets hand-edited into settings.json into the secret store as + * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps + * working from the file and the move is retried on the next load. */ - const moveInlineBitbucketTokens = (settings: ServerSettings) => + const moveInlineSourceControlHostSecrets = (settings: ServerSettings) => Effect.gen(function* () { - const bitbucket = { ...settings.bitbucket }; - let moved = false; - for (const field of BITBUCKET_SECRET_FIELDS) { - const value = bitbucket[field]; - if (value.length === 0 || value === SECRET_REDACTED) continue; + const moved = new Set(); + for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { + if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue; const stored = yield* secretStore - .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value)) .pipe( Effect.as(true), Effect.catch(() => - Effect.logWarning("failed to move a Bitbucket token into the secret store", { - field, + Effect.logWarning("failed to move a source control secret into the secret store", { + kind: slot.kind, + field: slot.field, }).pipe(Effect.as(false)), ), ); - if (!stored) continue; - bitbucket[field] = SECRET_REDACTED; - moved = true; + if (stored) moved.add(sourceControlHostSecretName(slot)); } - 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; + if (moved.size === 0) return settings; + return { + ...settings, + sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => + moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value, + ), + }; }); const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let legacyProviders: Readonly> | undefined; + let legacySourceControl: Parameters[1] = {}; // A file that failed to decode must stay on disk for the user to repair; // the fold below only writes when it started from the file's real contents. let settingsFileTrusted = true; @@ -782,6 +847,10 @@ const make = Effect.gen(function* () { const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { legacyProviders = legacySettings.value.providers; + legacySourceControl = { + bitbucket: legacySettings.value.bitbucket, + github: legacySettings.value.github, + }; } if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : legacySettings; @@ -855,12 +924,48 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; + // Copied, not moved: the old entry is removed only after the new file is written. A token + // that cannot be read or copied leaves the retired keys in place, so the migration runs again + // on the next load instead of dropping it. + const legacySecretsCopied = yield* Effect.forEach( + settingsFileTrusted + ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves + : [], + (move) => + secretStore.get(move.from).pipe( + Effect.flatMap((secret) => + Option.isNone(secret) ? Effect.void : secretStore.set(move.to, secret.value), + ), + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to copy a source control secret to its new name", { + secretName: move.to, + }).pipe(Effect.as(false)), + ), + ), + ).pipe(Effect.map((copied) => copied.every(Boolean))); + const hasLegacySourceControl = + settingsFileTrusted && + legacySecretsCopied && + (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined); + const sourceControlMigration = hasLegacySourceControl + ? migrateLegacySourceControlSettings(folded, legacySourceControl) + : { settings: folded, secretMoves: [] }; // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. - const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; - // A file still carrying the retired `providers` map is rewritten once so - // the migrated instances persist and the old key disappears. - if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) { + const migrated = settingsFileTrusted + ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings) + : folded; + // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten + // once so the migrated values persist and the old keys disappear. + if ( + migrated !== loaded || + (settingsFileTrusted && legacyProviders !== undefined) || + hasLegacySourceControl + ) { yield* writeSettingsAtomically(migrated); + for (const move of sourceControlMigration.secretMoves) { + yield* secretStore.remove(move.from).pipe(Effect.ignore); + } } return migrated; }); @@ -933,39 +1038,30 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } - const bitbucket = { ...settings.bitbucket }; - for (const field of BITBUCKET_SECRET_FIELDS) { - if (bitbucket[field] !== SECRET_REDACTED) continue; - const secret = yield* secretStore - .get(BITBUCKET_SECRET_NAMES[field]) - .pipe( - Effect.mapError( - (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), - ), - ); - 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 storedSecrets = new Map(); + for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) { + if (slot.value !== SECRET_REDACTED) continue; const secret = yield* secretStore - .get(gitHubTokenSecretName(host)) + .get(sourceControlHostSecretName(slot)) .pipe( Effect.mapError( (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), ), ); - tokens[host] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + storedSecrets.set( + sourceControlHostSecretName(slot), + Option.isSome(secret) ? textDecoder.decode(secret.value) : "", + ); } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], - bitbucket, - github: { ...settings.github, tokens }, + sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) => + value === SECRET_REDACTED + ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "") + : value, + ), }; }); @@ -1107,56 +1203,36 @@ const make = Effect.gen(function* () { }); } - const bitbucket = { ...next.bitbucket }; - for (const field of BITBUCKET_SECRET_FIELDS) { - let value = bitbucket[field]; - if (value === SECRET_REDACTED) { - // The marker keeps what is saved. A plaintext value hand-edited into settings.json - // is not in the secret store yet, so move it there instead of dropping it. - const inline = current.bitbucket[field]; - if (inline === SECRET_REDACTED || inline.length === 0) continue; - value = inline; - } - const secretName = BITBUCKET_SECRET_NAMES[field]; - if (value.length === 0) { - changes.push({ kind: "remove", secretName, operation: "remove-secret" }); - continue; - } - changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); - 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; + // Hand-edited plaintext not yet in the store, so the redaction marker can move it there. + const inlineSecrets = new Map( + sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) => + slot.value.length > 0 && slot.value !== SECRET_REDACTED + ? [[sourceControlHostSecretName(slot), slot.value] as const] + : [], + ), + ); + const nextSecretNames = new Set(); + const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => { + const secretName = sourceControlHostSecretName(slot); + // The marker keeps what is saved; a hand-edited plaintext value moves into the store. + const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw; + if (value === undefined) { + nextSecretNames.add(secretName); + return SECRET_REDACTED; } - const secretName = gitHubTokenSecretName(host); if (value.length === 0) { changes.push({ kind: "remove", secretName, operation: "remove-secret" }); - continue; + return ""; } + nextSecretNames.add(secretName); 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 SECRET_REDACTED; + }); + for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) { + const secretName = sourceControlHostSecretName(slot); + if (nextSecretNames.has(secretName)) continue; + if (changes.some((change) => change.secretName === secretName)) continue; + changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" }); } return { @@ -1164,8 +1240,7 @@ const make = Effect.gen(function* () { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], - bitbucket, - github: { ...next.github, tokens }, + sourceControlHosts, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index f879d57665ba..22abb952990b 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -632,16 +632,22 @@ it.effect("prefers credentials saved in settings over the environment, without a assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); yield* settings.updateSettings({ - bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + sourceControlHosts: { + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }, }); yield* bitbucket.probeAuth; assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); - yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, + }); yield* bitbucket.probeAuth; assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); - yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "", apiToken: "" } }, + }); yield* bitbucket.probeAuth; assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); }).pipe(Effect.provide(layer)); @@ -658,7 +664,9 @@ it.effect("never puts a saved token that is unsafe for an HTTP header on the wir // Fetch would reject this header with an error quoting the token, and that error reaches // clients. The unusable token is ignored, so the environment credential is used instead. - yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "saved\ntoken" } }, + }); yield* bitbucket.probeAuth; assert.strictEqual( execute.mock.calls.at(-1)?.[0].headers.authorization, @@ -679,7 +687,9 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); - yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* settings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, + }); assert.deepStrictEqual(yield* bitbucket.probeAuth, { status: "unknown", account: Option.none(), diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index ff3933a98ed3..cc79c19571ea 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -27,6 +27,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -56,6 +57,9 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(GitCafeApi.GitCafeApi)({}), + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + cliEnv: () => Effect.succeed({}), + }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index d174ed19eeab..2ff7fdb0d702 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -26,6 +26,7 @@ import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -129,6 +130,9 @@ function makeRegistry(input: { Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(GitCafeApi.GitCafeApi)({}), + Layer.mock(GitCafeCredentials.GitCafeCredentials)({ + cliEnv: () => Effect.succeed({}), + }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts new file mode 100644 index 000000000000..7790c1dbc033 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -0,0 +1,76 @@ +/** + * Which source control host settings fields hold secrets, read from each built-in host's client + * definition. `serverSettings` keeps those values in the secret store and redacts them for + * clients, the same way for every host. + * + * A secret field holds a string, or a string per server host such as GitHub's `tokens`. + * + * @module sourceControl/sourceControlHostSecrets + */ +import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; +import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; +import { + secretSourceControlHostSettingsFields, + type SourceControlClientDefinition, +} from "@t3tools/source-control-core/client/definition"; +import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; +import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; +import * as GitHub from "@t3tools/source-control-github/client/definition"; +import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; + +const definitions: ReadonlyArray = [ + GitHub.definition, + GitLab.definition, + AzureDevOps.definition, + Bitbucket.definition, + Forgejo.definition, + GitCafe.definition, +]; + +/** Secret field names per host kind; a host with none is absent. */ +export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< + string, + ReadonlyArray +> = new Map( + definitions.flatMap((definition) => { + const fields = definition.settings + ? secretSourceControlHostSettingsFields(definition.settings) + : []; + return fields.length > 0 ? [[definition.kind, fields] as const] : []; + }), +); + +/** One stored secret: a host's field, and the server host it is for when the field is per host. */ +export interface SourceControlHostSecretSlot { + readonly kind: string; + readonly field: string; + readonly serverHost: string | null; +} + +/** The secret store entry for one slot; names stay stable across releases. */ +export function sourceControlHostSecretName(slot: SourceControlHostSecretSlot): string { + const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); + return slot.serverHost === null + ? `source-control-${encode(slot.kind)}-${encode(slot.field)}` + : `source-control-${encode(slot.kind)}-${encode(slot.field)}-${encode(slot.serverHost.trim().toLowerCase())}`; +} + +/** The string values a secret field holds, keyed by server host, or `null` for a plain string. */ +export function readSecretFieldValues( + value: unknown, +): ReadonlyArray { + if (typeof value === "string") return [[null, value]]; + if (value === null || typeof value !== "object" || Array.isArray(value)) return []; + return Object.entries(value).flatMap(([serverHost, entry]) => + typeof entry === "string" ? [[serverHost.trim().toLowerCase(), entry] as const] : [], + ); +} + +/** A secret field's value rebuilt from per-slot values, in the shape it was read from. */ +export function writeSecretFieldValues( + original: unknown, + values: ReadonlyArray, +): unknown { + if (typeof original === "string") return values[0]?.[1] ?? ""; + return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); +} diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx deleted file mode 100644 index a423334d1c7f..000000000000 --- a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import type { BitbucketSettings, 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"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; - -type CredentialMethod = "access-token" | "api-token"; - -const METHODS: Record< - CredentialMethod, - { - readonly label: string; - readonly description: string; - readonly link: string; - readonly linkLabel: string; - } -> = { - "access-token": { - label: "Access token", - description: - "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", - link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/", - linkLabel: "Learn more", - }, - "api-token": { - label: "API token", - description: - "Uses your Atlassian account, so it reaches every repository you can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", - link: "https://id.atlassian.com/manage-profile/security/api-tokens", - linkLabel: "Create an API token", - }, -}; - -function savedMethod(saved: BitbucketSettings): CredentialMethod | null { - if (saved.accessToken.length > 0) return "access-token"; - if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; - return null; -} - -/** A write-only token field. It never shows the saved token; typing a new one replaces it. */ -function TokenInput({ - id, - isSaved, - draft, - onDraftChange, -}: { - readonly id: string; - readonly isSaved: boolean; - readonly draft: string; - readonly onDraftChange: (draft: string) => void; -}) { - return ( - onDraftChange(event.target.value)} - /> - ); -} - -/** - * Bitbucket credentials for one environment: an access token or an Atlassian - * account email + API token, never both. Tokens are write-only: the server - * keeps them in its secret store and only reports whether each one is set. - */ -export function BitbucketCredentialsSettings({ - environmentId, - onSaved, -}: { - readonly environmentId: EnvironmentId; - readonly onSaved: () => void; -}) { - const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket); - const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "save Bitbucket credentials", - }); - const [methodChoice, setMethodChoice] = useState(null); - const [accessToken, setAccessToken] = useState(""); - const [emailDraft, setEmailDraft] = useState(null); - const [apiToken, setApiToken] = useState(""); - const [saving, setSaving] = useState(false); - const current = savedMethod(saved); - const method = methodChoice ?? current ?? "access-token"; - const methodIsSaved = current === method; - const email = (emailDraft ?? saved.email).trim(); - const newAccessToken = accessToken.trim(); - const newApiToken = apiToken.trim(); - const info = METHODS[method]; - - // Saving one method clears the other, so a hidden credential never wins over the visible one. - const patch: BitbucketSettings | null = - method === "access-token" - ? newAccessToken - ? { accessToken: newAccessToken, email: "", apiToken: "" } - : null - : email && (newApiToken || saved.apiToken) - ? // Resending the saved token's redacted value keeps it. - { accessToken: "", email, apiToken: newApiToken || saved.apiToken } - : null; - const canSave = - patch !== null && - (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email); - - const save = async (next: BitbucketSettings) => { - setSaving(true); - try { - const result = await updateSettings({ - environmentId, - input: { patch: { bitbucket: next } }, - }); - if (result._tag === "Success") { - setAccessToken(""); - setApiToken(""); - setEmailDraft(null); - onSaved(); - } - } finally { - setSaving(false); - } - }; - - return ( -
{ - event.preventDefault(); - if (canSave && patch) void save(patch); - }} - > - {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */} -
- { - const value = next[0]; - if (value === "access-token" || value === "api-token") setMethodChoice(value); - }} - > - {METHODS["access-token"].label} - {METHODS["api-token"].label} - -

- {info.description}{" "} - }> - {info.linkLabel} - - -

- {method === "access-token" ? ( -
- - -
- ) : ( - <> -
- - setEmailDraft(event.target.value)} - /> -
-
- - -
- - )} -
-

- {current === null - ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables." - : methodIsSaved - ? null - : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`} -

-
- {current !== null ? ( - - ) : null} - -
-
-
-
- ); -} diff --git a/apps/web/src/components/settings/GitHubAccountSettings.logic.ts b/apps/web/src/components/settings/GitHubAccountSettings.logic.ts index 466372f7699d..0a3655cc6bb7 100644 --- a/apps/web/src/components/settings/GitHubAccountSettings.logic.ts +++ b/apps/web/src/components/settings/GitHubAccountSettings.logic.ts @@ -1,4 +1,5 @@ -import type { GitHubSettings, SourceControlProviderAuth } from "@t3tools/contracts"; +import type { SourceControlProviderAuth } from "@t3tools/contracts"; +import type { GitHubSettings } from "@t3tools/client-runtime/source-control-clients"; export type GitHubDiscoveredAccount = NonNullable[number]; diff --git a/apps/web/src/components/settings/GitHubAccountSettings.tsx b/apps/web/src/components/settings/GitHubAccountSettings.tsx index 98e7289b260f..9ca6caa031ef 100644 --- a/apps/web/src/components/settings/GitHubAccountSettings.tsx +++ b/apps/web/src/components/settings/GitHubAccountSettings.tsx @@ -9,6 +9,7 @@ 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 { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients"; import { redactedPlaceholder } from "./RedactedSensitiveText"; /** Sentinel select value for "follow gh's active login"; logins never contain spaces. */ @@ -49,7 +50,8 @@ export function GitHubAccountSettings({ readonly auth: SourceControlProviderAuth; readonly onSaved: () => void; }) { - const hosts = useEnvironmentSettings(environmentId, (settings) => settings.github.hosts); + const github = useEnvironmentSettings(environmentId, readGitHubSettings); + const hosts = github.hosts; const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "save GitHub account settings", }); @@ -65,7 +67,11 @@ export function GitHubAccountSettings({ try { const result = await updateSettings({ environmentId, - input: { patch: { github: { hosts: nextGitHubHosts(hosts, host, change) } } }, + input: { + patch: { + sourceControlHosts: { github: { hosts: nextGitHubHosts(hosts, host, change) } }, + }, + }, }); if (result._tag === "Success") onSaved(); } finally { diff --git a/apps/web/src/components/settings/GitHubTokenSettings.tsx b/apps/web/src/components/settings/GitHubTokenSettings.tsx index 80ad552f15b2..9516d448b73a 100644 --- a/apps/web/src/components/settings/GitHubTokenSettings.tsx +++ b/apps/web/src/components/settings/GitHubTokenSettings.tsx @@ -3,6 +3,7 @@ import { ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; @@ -28,7 +29,7 @@ export function GitHubTokenSettings({ readonly environmentId: EnvironmentId; readonly onSaved: () => void; }) { - const tokens = useEnvironmentSettings(environmentId, (settings) => settings.github.tokens); + const { tokens } = useEnvironmentSettings(environmentId, readGitHubSettings); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "save GitHub token", }); @@ -46,7 +47,10 @@ export function GitHubTokenSettings({ try { const result = await updateSettings({ environmentId, - input: { patch: { github: { tokens: { [target]: token } } } }, + // The map travels whole: every other saved token as its redaction marker, which keeps it. + input: { + patch: { sourceControlHosts: { github: { tokens: { ...tokens, [target]: token } } } }, + }, }); if (result._tag === "Success") { setDraft(""); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index d772585c74f3..3671f72912ce 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -19,6 +19,11 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; + +/** What the form reads from a definition: an agent provider's, or a source control host's. */ +export type SettingsFormDefinition = Pick & { + readonly driverKind?: ProviderClientDefinition["driverKind"]; +}; import { SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { @@ -28,6 +33,8 @@ export interface ProviderSettingsFieldModel { readonly description?: string | undefined; readonly placeholder?: string | undefined; readonly clearWhenEmpty: "omit" | "persist"; + /** Kept in the server's secret store: a saved value arrives as `SECRET_REDACTED` and never shows. */ + readonly secret?: boolean | undefined; readonly defaultBooleanValue?: boolean | undefined; /** Choices for a `select` control. The first entry is the default. */ readonly options?: ReadonlyArray | undefined; @@ -63,7 +70,7 @@ function readProviderSettingsFormAnnotation( } function readProviderSettingsFormSchemaAnnotation( - definition: ProviderClientDefinition, + definition: SettingsFormDefinition, ): ProviderSettingsFormSchemaAnnotation { return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {}; } @@ -77,7 +84,7 @@ function readFieldBooleanDefault( } export function deriveProviderSettingsFields( - definition: ProviderClientDefinition, + definition: SettingsFormDefinition, value?: unknown, ): ReadonlyArray { const isLocalAcp = @@ -124,6 +131,7 @@ export function deriveProviderSettingsFields( ? { placeholder: formAnnotation.placeholder } : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", + ...(formAnnotation.secret ? { secret: true } : {}), ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), @@ -247,6 +255,19 @@ function readProviderConfigString(config: unknown, key: string): string { return typeof value === "string" ? value : ""; } +/** + * A secret field never shows its saved value: it starts empty, and its placeholder says whether + * one is saved. Saving one is typing a new value; clearing it is the host form's job. + */ +function secretFieldInputProps(field: ProviderSettingsFieldModel, config: unknown) { + const saved = readProviderConfigString(config, field.key); + if (!field.secret) return { value: saved, placeholder: field.placeholder }; + return { + value: "", + placeholder: saved.length > 0 ? "Stored secret, enter a new value to replace" : "Not set", + }; +} + function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; @@ -281,7 +302,7 @@ export function nextProviderConfigWithFieldValue( } interface ProviderSettingsFormProps { - readonly definition: ProviderClientDefinition; + readonly definition: SettingsFormDefinition; readonly value: unknown; readonly idPrefix: string; /** @@ -410,9 +431,12 @@ function ProviderSettingsFieldRow({ className="w-full max-w-full @min-[32rem]/settings-row:w-56" type={field.control === "password" ? "password" : undefined} autoComplete={field.control === "password" ? "off" : undefined} - value={readProviderConfigString(value, field.key)} - onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} - placeholder={field.placeholder} + {...secretFieldInputProps(field, value)} + onCommit={(next) => { + // An untouched saved secret commits empty; only a typed value replaces it. + if (field.secret && next === "") return; + onChange(nextProviderConfigWithFieldValue(value, field, next)); + }} spellCheck={false} /> ); diff --git a/apps/web/src/components/settings/SourceControlHostSettings.tsx b/apps/web/src/components/settings/SourceControlHostSettings.tsx new file mode 100644 index 000000000000..353027c95958 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlHostSettings.tsx @@ -0,0 +1,84 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { SourceControlClientDefinition } from "@t3tools/client-runtime/source-control-clients"; +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 { ProviderSettingsForm } from "./ProviderSettingsForm"; + +/** + * One host's settings, drawn from its definition's settings schema and saved under + * `sourceControlHosts[kind]`. Secret fields are write-only: the server keeps them in its secret + * store and only reports whether each one is set. + */ +export function SourceControlHostSettings({ + environmentId, + definition, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly definition: SourceControlClientDefinition & { + readonly settings: NonNullable; + }; + readonly onSaved: () => void; +}) { + const saved = useEnvironmentSettings( + environmentId, + (settings) => settings.sourceControlHosts[definition.kind], + ); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: `save ${definition.label} settings`, + }); + const [saving, setSaving] = useState(false); + const formDefinition = { settingsSchema: definition.settings }; + const hasSaved = Object.values(saved ?? {}).some((value) => value !== ""); + + const save = async (fields: Record) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { sourceControlHosts: { [definition.kind]: fields } } }, + }); + if (result._tag === "Success") onSaved(); + } finally { + setSaving(false); + } + }; + + return ( + // Locked while saving, so an edit made mid-request is not overwritten by the saved value. +
+ { + const fields = next ?? {}; + // Only what changed: each saved secret travels as its redaction marker anyway. + const changed = Object.fromEntries( + Object.entries(fields).filter(([key, value]) => saved?.[key] !== value), + ); + const cleared = Object.keys(saved ?? {}).filter((key) => !(key in fields)); + void save({ ...changed, ...Object.fromEntries(cleared.map((key) => [key, ""])) }); + }} + /> + {hasSaved ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index db7836054eeb..61414a01c2fc 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -46,7 +46,7 @@ import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { GitIcon, JujutsuIcon, type Icon } from "../Icons"; -import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; +import { SourceControlHostSettings } from "./SourceControlHostSettings"; import { GitHubAccountSettings } from "./GitHubAccountSettings"; import { GitHubTokenSettings } from "./GitHubTokenSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; @@ -59,7 +59,7 @@ import { SettingsSection, useSettingsSearchTargetId, } from "./settingsLayout"; -import { searchableSetting } from "./settingsSearch"; +import { searchableSetting, sourceControlHostSettingsSearchId } from "./settingsSearch"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { sourceControlIcon } from "~/sourceControlPresentation"; @@ -278,8 +278,7 @@ function DiscoveryItemRow({ useEffect(() => { if ( (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || - (item.kind === "bitbucket" && - searchTargetId === searchableSetting("bitbucket-credentials").id) || + searchTargetId === sourceControlHostSettingsSearchId(item.kind) || (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id) ) { setIsExpanded(true); @@ -527,6 +526,22 @@ export function SourceControlSettingsPanel() { const handleScan = () => { discovery.refresh(); }; + /** The host's own settings form, for a host whose definition declares settings. */ + const hostSettingsPanel = (kind: string) => { + const definition = sourceControlClients.find(kind); + if (!definition?.settings || environmentId === null) return undefined; + return ( + + + + ); + }; const scanButton = ( {result.sourceControlProviders.map((item) => ( - {item.kind === "bitbucket" ? ( - - - - ) : item.kind === "github" ? ( + {item.kind === "github" ? (
{/* Shown even without gh: a saved token is how GitHub works without the CLI. */} @@ -619,7 +625,9 @@ export function SourceControlSettingsPanel() { ) : null}
- ) : undefined} + ) : ( + hostSettingsPanel(item.kind) + )}
))} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c23eeb8fdf06..ccb2d2a24a2a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -4,6 +4,7 @@ import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/uti import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { commandLabel } from "./KeybindingsSettings.logic"; import { @@ -132,6 +133,29 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right }; }); +/** Anchor id of a source control host's settings form on the Source Control page. */ +export function sourceControlHostSettingsSearchId(kind: string) { + return `source-control-host-${kind}` as const; +} + +/** One result per host whose definition declares settings, searchable by its field titles. */ +const SOURCE_CONTROL_HOST_SEARCH_ITEMS = sourceControlClients.definitions.flatMap((definition) => + definition.settings && definition.kind !== "github" + ? [ + { + id: sourceControlHostSettingsSearchId(definition.kind), + title: `${definition.label} credentials`, + to: "/settings/source-control" as const, + searchTerms: [ + `${definition.label} ${Object.keys(definition.settings.fields).join(" ")} token credentials sign in`, + ], + environmentOnly: true, + scope: "environment-defaults" as const, + }, + ] + : [], +); + /** * Searchable settings and stable destinations, in result order. Rows with a * dedicated anchor render their id and title via `searchableSetting`; items @@ -808,14 +832,7 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, - { - id: "bitbucket-credentials", - title: "Bitbucket credentials", - to: "/settings/source-control", - searchTerms: ["bitbucket atlassian access token api token email credentials sign in"], - environmentOnly: true, - scope: "environment-defaults", - }, + ...SOURCE_CONTROL_HOST_SEARCH_ITEMS, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index b0452974f1c1..741a90aa128c 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -64,18 +64,18 @@ glab auth login ### Bitbucket -Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: +Open **Settings → Source Control**, expand **Bitbucket**, and enter one of: - **Access token**: a token created for one repository, project, or workspace. It can only reach what it was created for. -- **API token**: an Atlassian API token for your account, used with your account email. It can +- **Atlassian account email** and **API token**: an Atlassian API token for your account. It can reach every repository you can. Give it read/write access to repositories and pull requests, plus user read access (`read:user:bitbucket`). -Choose **Save**; the change applies right away, and replaces any credential saved with the other -method. Credentials are saved on the environment's server, so select a remote environment to -configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose -**Remove**. +A field saves when you leave it, and applies right away. The access token wins when both are saved. +Credentials are saved on the environment's server, so select a remote environment to configure +it. Saved tokens can't be viewed again; enter a new one to replace it, or choose **Remove** to clear +everything saved for Bitbucket. If no credentials are saved, T3 Code falls back to these variables in the server's environment. Restart the server after changing them: @@ -105,8 +105,10 @@ bun install -g @gitcafe/cli cafe auth login --host https://git.cafe/api ``` -Alternatively, set `CAFE_TOKEN` in the server's environment and restart it. The token is only -sent to the host `CAFE_HOST` names, which is `git.cafe` unless you set it. Repositories on +Alternatively, open **Settings → Source Control**, expand **GitCafe**, and enter a token. It is +kept in the server's secret store, applies right away, and is used before `CAFE_TOKEN` and the +`cafe` login. Or set `CAFE_TOKEN` in the server's environment and restart it. Both tokens are only +sent to `git.cafe`; `CAFE_TOKEN` follows `CAFE_HOST` if you set it. Repositories on `staging.git.cafe` need their own login with `--host https://staging.git.cafe/api`. Line comments, reviewer requests, and labels are not available for GitCafe pull requests yet. diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index 52a2b2d4d290..de68831cf58b 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -9,7 +9,11 @@ */ import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import { makeSourceControlClientRegistry } from "@t3tools/source-control-core/client/definition"; +import { + makeSourceControlClientRegistry, + readSourceControlHostSettings, +} from "@t3tools/source-control-core/client/definition"; +import type { ServerSettings } from "@t3tools/contracts"; export { UNKNOWN_SOURCE_CONTROL_CLIENT, @@ -40,3 +44,13 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, ); + +export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; + +/** + * An environment's GitHub host choices and tokens, decoded with GitHub's settings schema. GitHub's + * settings panels are its own, since its logins come from `gh` at runtime. + */ +export function readGitHubSettings(settings: Pick) { + return readSourceControlHostSettings(GitHub.settings, settings.sourceControlHosts.github); +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b9446389d083..675344df4189 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -949,13 +949,6 @@ 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 0e5be96e740a..a8f3a74eb011 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -552,6 +552,11 @@ export interface ProviderSettingsFormAnnotation { readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; + /** + * The value is a credential: the server keeps it in its secret store and clients only learn + * whether one is saved. Source control host settings honor it; provider settings do not yet. + */ + readonly secret?: boolean | undefined; /** Choices for a `select` control. The first entry is the default. */ readonly options?: ReadonlyArray | undefined; } @@ -805,49 +810,20 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -/** - * Bitbucket API credentials for this environment, used before the - * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's - * secret store; settings and clients only see a redaction marker when one is - * set. The access token wins when both kinds are configured. - */ -export const BitbucketSettings = Schema.Struct({ - email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), -}); -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; +/** + * One source control host's saved settings. Each host package declares the fields it reads in + * its client definition's settings schema and decodes this blob with it; the environment stores + * it whole, so a host this build does not ship keeps its settings. A field annotated as a secret + * holds a string, or a string per server host, whose value lives in the server's secret store: + * settings and clients only see a redaction marker for a saved one. + */ +export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); +export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1278,8 +1254,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + /** Keyed by source control host kind, such as `gitcafe`; see `SourceControlHostSettings`. */ + sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).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( @@ -1523,24 +1501,12 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** An empty token clears it; an omitted one keeps what the server has. */ - bitbucket: Schema.optionalKey( - Schema.Struct({ - email: Schema.optionalKey(TrimmedString), - accessToken: Schema.optionalKey(TrimmedString), - 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. + * Merges per host: a patched field replaces the saved one, so a client sends a per-server-host + * map whole, with the redaction marker for each saved secret it keeps. An empty or omitted + * secret is removed from the secret store. */ - github: Schema.optionalKey( - Schema.Struct({ - hosts: Schema.optionalKey(Schema.Record(GitHubHost, GitHubHostSettings)), - tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), - }), - ), + sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index dbc2704171f9..fe58c361d4a4 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -42,16 +42,19 @@ describe("serverSettings helpers", () => { logsAfterDays: 30, }); }); - it("replaces GitHub host choices so a cleared account pin does not survive", () => { + it("replaces a source control host's patched field so a cleared account pin does not survive", () => { const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - github: { hosts: { "github.com": { account: "work", enabled: true } } }, + sourceControlHosts: { + github: { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, + }, + }); + const unpinned = applyServerSettingsPatch(pinned, { + sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } }, + }); + expect(unpinned.sourceControlHosts.github).toEqual({ + hosts: { "github.com": { enabled: false } }, + tokens: {}, }); - 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" }; diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 909eeb0a35ce..85af5bb1fdd6 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -267,6 +267,7 @@ export function applyServerSettingsPatch( worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, + sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. @@ -367,9 +368,19 @@ export function applyServerSettingsPatch( ], } : {}), - // Host replacement: deepMerge would keep a cleared account pin. - ...(patch.github?.hosts !== undefined - ? { github: { ...next.github, hosts: patch.github.hosts } } + // Per host, a patched field replaces the saved one: deepMerge would keep a cleared account pin. + ...(sourceControlHostsPatch !== undefined + ? { + sourceControlHosts: { + ...current.sourceControlHosts, + ...Object.fromEntries( + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ + kind, + { ...current.sourceControlHosts[kind], ...fields }, + ]), + ), + }, + } : {}), ...(projectSettingsOverridesPatch !== undefined ? { diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 26f5c5d6f97e..fc0321ad8109 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -3,7 +3,13 @@ * * @module source-control-bitbucket/client/definition */ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + makeProviderSettingsSchema, + SourceControlProviderKind, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { defineSourceControlClient, isChangeRequestInProjectRepository, @@ -16,6 +22,41 @@ const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; const KIND = SourceControlProviderKind.make("bitbucket"); +const text = (annotations: Schema.Annotations.Key) => + TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey(annotations), + ); + +/** + * Bitbucket API credentials, used before the `T3CODE_BITBUCKET_*` environment variables: an + * access token, or an Atlassian account email with an API token. The access token wins when both + * are saved. + */ +export const settings = makeProviderSettingsSchema( + { + accessToken: text({ + title: "Access token", + description: + "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", + providerSettingsForm: { control: "password", secret: true }, + }), + email: text({ + title: "Atlassian account email", + description: "With an API token instead of an access token.", + providerSettingsForm: { placeholder: "you@example.com" }, + }), + apiToken: text({ + title: "API token", + description: + "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", + providerSettingsForm: { control: "password", secret: true }, + }), + }, + { order: ["accessToken", "email", "apiToken"] }, +); +export type BitbucketSettings = typeof settings.Type; + export const definition = defineSourceControlClient({ kind: KIND, label: "Bitbucket", @@ -54,4 +95,5 @@ export const definition = defineSourceControlClient({ canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull-requests/"), + settings, }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 3df66527baee..d9d68144f1fc 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -7,10 +7,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, - type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -31,6 +29,9 @@ import { import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; + +import * as BitbucketClient from "../client/definition.ts"; import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; @@ -569,7 +570,7 @@ function credentialFrom(input: { * stay as a fallback. Within each source the access token wins. */ function resolveCredential( - settings: BitbucketSettings, + settings: BitbucketClient.BitbucketSettings, env: Config.Success, ): BitbucketCredential | null { return ( @@ -664,12 +665,24 @@ export const make = Effect.gen(function* () { // Read on every request so credentials saved in settings apply without a restart. const currentCredential = host.settings.get.pipe( - Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.map((settings) => + resolveCredential( + readSourceControlHostSettings( + BitbucketClient.settings, + settings.sourceControlHosts[BitbucketClient.definition.kind], + ), + config, + ), + ), Effect.catch((error) => // No cause: a settings decode error can quote a hand-edited token. Effect.logWarning("failed to read Bitbucket credentials from settings", { operation: error.operation, - }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + }).pipe( + Effect.as( + resolveCredential(readSourceControlHostSettings(BitbucketClient.settings, {}), config), + ), + ), ), ); diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 40d2f80783ee..2f245844e467 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -19,6 +19,7 @@ import { SourceControlProviderKind, } from "@t3tools/contracts"; import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import * as Schema from "effect/Schema"; /** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ export interface ChangeRequestTerminology { @@ -140,8 +141,20 @@ export interface SourceControlClientDefinition { readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ readonly isChangeRequestUrl: (url: string) => boolean; + /** + * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered + * as a form in Source Control settings and read by the host's server package. Fields carry + * `providerSettingsForm` annotations, as agent provider settings do; one marked `secret` is + * kept in the server's secret store. Omitted for a host with nothing to configure. + */ + readonly settings?: SourceControlHostSettingsSchema; } +/** A host's settings struct. Its fields are strings, or a secret string per server host. */ +export type SourceControlHostSettingsSchema = { + readonly fields: Readonly>; +} & Schema.Decoder; + /** * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number. * Matching the parsed path keeps a query or fragment that mentions another host's route out. @@ -293,3 +306,33 @@ export function makeSourceControlClientRegistry( definitions.find((definition) => definition.isChangeRequestUrl(url))?.label ?? "the host", }; } + +/** + * A host's saved settings decoded with its schema. A blob that no longer decodes, such as one a + * newer build wrote, reads as the schema's defaults rather than failing the host. + */ +export function readSourceControlHostSettings( + schema: S, + saved: unknown, +): S["Type"] { + const decode = Schema.decodeUnknownOption(schema as Schema.Decoder); + const decoded = decode(saved ?? {}); + if (decoded._tag === "Some") return decoded.value; + const fallback = decode({}); + if (fallback._tag === "Some") return fallback.value; + throw new Error("A source control host settings schema must decode an empty object."); +} + +/** + * The settings fields a host keeps in the server's secret store: those annotated + * `providerSettingsForm.secret`. A secret field holds a string, or a string per server host. + */ +export function secretSourceControlHostSettingsFields( + schema: SourceControlHostSettingsSchema, +): ReadonlyArray { + return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => { + const annotations = + Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema); + return annotations?.providerSettingsForm?.secret ? [field] : []; + }); +} diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 202f297a9e17..59eb472648c2 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -3,7 +3,13 @@ * * @module source-control-gitcafe/client/definition */ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { + makeProviderSettingsSchema, + SourceControlProviderKind, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { defineSourceControlClient, isChangeRequestInProjectRepository, @@ -16,6 +22,20 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); const KIND = SourceControlProviderKind.make("gitcafe"); +/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */ +export const settings = makeProviderSettingsSchema({ + token: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Token", + description: + "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.", + providerSettingsForm: { control: "password", secret: true }, + }), + ), +}); +export type GitCafeSettings = typeof settings.Type; + export const definition = defineSourceControlClient({ kind: KIND, label: "GitCafe", @@ -64,4 +84,5 @@ export const definition = defineSourceControlClient({ return false; } }, + settings, }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts index f0f8be74711f..e54018f30088 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -78,6 +78,44 @@ describe("GitCafeCredentials", () => { }).pipe(Effect.provide(layer)); }); + it.effect("uses a token saved in Settings for git.cafe only, ahead of CAFE_TOKEN", () => { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return Effect.succeed(processOutput("password=gct_staging\n")); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; + yield* settings.update({ sourceControlHosts: { gitcafe: { token: "saved-token" } } }); + + const saved = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(saved.token), "saved-token"); + assert.strictEqual(saved.source, "settings"); + assert.deepStrictEqual(yield* credentials.cliEnv("git.cafe"), { CAFE_TOKEN: "saved-token" }); + // Staging never receives the production token saved in Settings. + assert.deepStrictEqual(yield* credentials.cliEnv("staging.git.cafe"), {}); + assert.strictEqual( + Redacted.value((yield* credentials.get("staging.git.cafe")).token), + "gct_staging", + ); + + // Removing it applies at once: the environment token takes over again. + yield* settings.update({ sourceControlHosts: { gitcafe: { token: "" } } }); + assert.strictEqual((yield* credentials.get("git.cafe")).source, "env"); + }).pipe(Effect.provide(layer)); + }); + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { const { layer, calls } = harness(() => Effect.succeed( diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts index c47e9ce1153f..1aeb7b66e969 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -10,6 +10,9 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; + +import * as GitCafeClient from "../client/definition.ts"; /** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ const TOKEN_TTL = Duration.minutes(5); @@ -19,7 +22,7 @@ const MISSING_TTL = Duration.seconds(10); export interface GitCafeCredential { readonly host: string; readonly token: Redacted.Redacted; - readonly source: "env" | "cafe"; + readonly source: "settings" | "env" | "cafe"; } /** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ @@ -66,10 +69,17 @@ export class GitCafeCredentials extends Context.Service< ) => Effect.Effect; /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ readonly invalidate: (host: string) => Effect.Effect; + /** + * Environment for a `cafe` run against the host: the token saved in Settings as `CAFE_TOKEN`, + * which `cafe` uses before its own login, or nothing when none is saved. + */ + readonly cliEnv: (host: string) => Effect.Effect>>; } >()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} const normalizeHost = (host: string) => host.trim().toLowerCase(); +/** The host `cafe` and a token saved in Settings target unless told otherwise. */ +const DEFAULT_HOST = "git.cafe"; /** * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by @@ -82,7 +92,7 @@ export function environmentToken( const configured = env.CAFE_HOST?.trim(); const configuredHost = configured ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host - : "git.cafe"; + : DEFAULT_HOST; if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; return env.CAFE_TOKEN?.trim() || null; } @@ -97,7 +107,8 @@ function credentialPassword(output: string): string | null { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const { process } = yield* SourceControlHost.SourceControlHost; + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const { process } = sourceControlHost; const environment = yield* HostProcess.Environment; const workingDirectory = yield* HostProcess.WorkingDirectory; @@ -134,14 +145,27 @@ export const make = Effect.gen(function* () { ), ); - const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + /** A token saved in Settings, for production git.cafe only; read fresh on every lookup. */ + const fromSettings = (host: string) => + normalizeHost(host) !== DEFAULT_HOST + ? Effect.succeed(null) + : sourceControlHost.settings.get.pipe( + Effect.map( + (settings) => + readSourceControlHostSettings( + GitCafeClient.settings, + settings.sourceControlHosts[GitCafeClient.definition.kind], + ).token || null, + ), + Effect.orElseSucceed(() => null), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* ( + host: string, + ): Effect.fn.Return { const fromEnv = environmentToken(host, environment); const token = fromEnv ?? (yield* fromCafe(host)); - return { - host, - token: Redacted.make(token), - source: fromEnv !== null ? "env" : "cafe", - } satisfies GitCafeCredential; + return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "cafe" }; }); const cache = yield* Cache.makeWith(lookup, { @@ -158,8 +182,24 @@ export const make = Effect.gen(function* () { }); return GitCafeCredentials.of({ - get: (host) => Cache.get(cache, normalizeHost(host)), + // A saved token is read fresh, so saving or removing one applies at once; the cache only + // spares asking `cafe` again. + get: (host) => + fromSettings(host).pipe( + Effect.flatMap( + (saved): Effect.Effect => + saved === null + ? Cache.get(cache, normalizeHost(host)) + : Effect.succeed({ + host: normalizeHost(host), + token: Redacted.make(saved), + source: "settings", + }), + ), + ), invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + cliEnv: (host) => + fromSettings(host).pipe(Effect.map((saved) => (saved === null ? {} : { CAFE_TOKEN: saved }))), }); }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index 996925033eb3..565071b9e096 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -42,16 +42,8 @@ const failure = (code: string, status: number | null) => ({ }); describe("GitCafeSourceControlProvider", () => { - it("probes the cafe CLI against production", () => { - const { discovery } = GitCafeSourceControlProvider; - assert.strictEqual(discovery.kind, "gitcafe"); - assert.strictEqual(discovery.executable, "cafe"); - assert.deepStrictEqual(discovery.authArgs.slice(0, 2), ["--host", "https://git.cafe/api"]); - assert.deepStrictEqual(discovery.authArgs.slice(-3), ["auth", "status", "--json"]); - }); - it("reads a signed-in account and keeps transient failures apart from sign-out", () => { - const { parseAuth } = GitCafeSourceControlProvider.discovery; + const { parseAuth } = GitCafeSourceControlProvider; const signedIn = parseAuth( authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), ); @@ -115,7 +107,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge(TestSourceControlHost.layer()), Layer.provide( Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), @@ -162,7 +154,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ git: { @@ -244,7 +236,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ git: { @@ -304,7 +296,7 @@ describe("GitCafeSourceControlProvider", () => { }).pipe( Effect.provide( GitCafeApi.layer.pipe( - Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(GitCafeCredentials.layer), Layer.provideMerge( TestSourceControlHost.layer({ process: { diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index 5e271261f1e5..db4bea8dbf4c 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -11,13 +11,16 @@ import { type ChangeRequest, } from "@t3tools/contracts"; import { + probeSourceControlProvider, providerAuth, type SourceControlCliDiscoverySpec, + type SourceControlManagedCliDiscoverySpec, } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; import * as GitCafeHosts from "./gitCafeHosts.ts"; /** Discovery reports the production account; repository operations pick their own host. */ @@ -58,7 +61,7 @@ const cliFailure = (output: string) => .map((line) => decodeFailure(line.trim())) .find(Result.isSuccess)?.success.error; -export const discovery = { +const cliDiscovery = { type: "cli", kind: SourceControlProviderKind.make("gitcafe"), label: "GitCafe", @@ -90,9 +93,35 @@ export const discovery = { detail: `GitCafe authentication status could not be read. Run \`cafe auth login --host https://${AUTH_HOST}/api\`.`, }); }, - installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, or set CAFE_TOKEN on the server.`, + installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, save a token in Settings, or set CAFE_TOKEN on the server.`, } satisfies SourceControlCliDiscoverySpec; +/** `cafe auth status`'s reading, exposed for tests. */ +export const parseAuth = cliDiscovery.parseAuth; + +/** Probes `cafe` as the CLI spec does, with a token saved in Settings handed to it. */ +export const makeDiscovery = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + return { + type: "managed-cli", + kind: cliDiscovery.kind, + label: cliDiscovery.label, + installHint: cliDiscovery.installHint, + probe: (cwd: string) => + credentials.cliEnv(AUTH_HOST).pipe( + Effect.flatMap((env) => + probeSourceControlProvider({ + cwd, + spec: cliDiscovery, + process: { run: (input) => process.run({ ...input, env: { ...input.env, ...env } }) }, + }), + ), + ), + refineUnknownRemote: () => Effect.succeed(null), + } satisfies SourceControlManagedCliDiscoverySpec; +}); + const Repository = Schema.Struct({ name: TrimmedNonEmptyString, defaultBranch: Schema.NullOr(TrimmedNonEmptyString), @@ -148,6 +177,7 @@ const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[? export const make = Effect.gen(function* () { const api = yield* GitCafeApi.GitCafeApi; const host = yield* SourceControlHost.SourceControlHost; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; const error = (operation: string, cwd: string, detail: string, cause?: unknown) => new SourceControlProviderError({ @@ -174,15 +204,20 @@ export const make = Effect.gen(function* () { ), ); const cafe = (operation: string, cwd: string, target: Target, args: ReadonlyArray) => - host.process - .run({ - operation: `GitCafeSourceControlProvider.${operation}`, - command: "cafe", - cwd, - args: [...cliArgs(target.host), ...args], - env: { CAFE_OUTPUT: "json" }, - timeoutMs: 30_000, - }) + credentials + .cliEnv(target.host) + .pipe( + Effect.flatMap((env) => + host.process.run({ + operation: `GitCafeSourceControlProvider.${operation}`, + command: "cafe", + cwd, + args: [...cliArgs(target.host), ...args], + env: { CAFE_OUTPUT: "json", ...env }, + timeoutMs: 30_000, + }), + ), + ) .pipe( Effect.mapError((cause) => error( diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 6fc49c393f21..2654f18b275e 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -15,7 +15,7 @@ export const driver = defineSourceControlDriver({ kind: SourceControlProviderKind.make("gitcafe"), make: Effect.all({ sourceControl: GitCafeSourceControlProvider.make, - discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + discovery: GitCafeSourceControlProvider.makeDiscovery, pullRequests: GitCafePullRequestProvider.make, }), }); diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index 0c18ff63331a..ca6643361352 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -3,7 +3,16 @@ * * @module source-control-github/client/definition */ -import { pullRequestHostOf, SourceControlProviderKind } from "@t3tools/contracts"; +import { + GitHubHost, + makeProviderSettingsSchema, + pullRequestHostOf, + SourceControlProviderKind, + TrimmedNonEmptyString, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { defineSourceControlClient, isChangeRequestInProjectRepository, @@ -17,6 +26,30 @@ const CHANGE_REQUEST_REFERENCE = const KIND = SourceControlProviderKind.make("github"); +export const GitHubHostChoice = Schema.Struct({ + account: Schema.optionalKey(TrimmedNonEmptyString), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), +}); +export type GitHubHostChoice = typeof GitHubHostChoice.Type; + +/** + * Per-host choices, keyed by lowercased host such as `github.com`. `hosts` pins one of the logins + * `gh` holds for a host instead of its active one, or turns the host off; `tokens` holds a token + * per host, used before `GH_TOKEN` and friends, which win over `gh`. Source Control settings draws + * both with GitHub's own panels, since the logins come from `gh` at runtime. + */ +export const settings = makeProviderSettingsSchema({ + hosts: Schema.Record(GitHubHost, GitHubHostChoice).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + tokens: Schema.Record(GitHubHost, TrimmedString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + Schema.annotateKey({ providerSettingsForm: { hidden: true, secret: true } }), + ), +}); +export type GitHubSettings = typeof settings.Type; + export const definition = defineSourceControlClient({ kind: KIND, label: "GitHub", @@ -60,4 +93,5 @@ export const definition = defineSourceControlClient({ canReadChangeRequestOnHost: (identity, link) => isChangeRequestOnProjectHost(KIND, identity, link), isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pull/"), + settings, }); diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts index 15ac6bf648a7..76cced6bda83 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.test.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -57,7 +57,7 @@ function harness( Layer.provideMerge( TestSourceControlHost.layer({ settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - github: { hosts: hostSettings(hosts), tokens }, + sourceControlHosts: { github: { hosts: hostSettings(hosts), tokens } }, }), process: { run }, }), @@ -128,16 +128,20 @@ describe("GitHubCredentials", () => { expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); yield* settings.update({ - github: { hosts: { "github.com": { account: "work", enabled: true } } }, + sourceControlHosts: { + github: { hosts: { "github.com": { account: "work", enabled: true } } }, + }, }); expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work"); - yield* settings.update({ github: { hosts: { "github.com": { enabled: false } } } }); + yield* settings.update({ + sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } }, + }); expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe( "GitHubHostDisabledError", ); - yield* settings.update({ github: { hosts: {} } }); + yield* settings.update({ sourceControlHosts: { 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); diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index 9eaa52301c04..69594c9455a7 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -14,6 +14,10 @@ import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; +import type { ServerSettings } from "@t3tools/contracts"; + +import * as GitHubClient from "../client/definition.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); @@ -176,17 +180,23 @@ export const make = Effect.gen(function* () { ), ); + const readSettings = (settings: ServerSettings) => + readSourceControlHostSettings( + GitHubClient.settings, + settings.sourceControlHosts[GitHubClient.definition.kind], + ); + /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ const hostChoice = (host: string) => sourceControlHost.settings.get.pipe( - Effect.map((settings) => settings.github.hosts[host]), + Effect.map((settings) => readSettings(settings).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) => sourceControlHost.settings.get.pipe( - Effect.map((settings) => settings.github.tokens[host]?.trim() || null), + Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), Effect.orElseSucceed(() => null), ); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 66ac324aaed5..c52b3c2dd2c8 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -10,11 +10,9 @@ import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { - DEFAULT_SERVER_SETTINGS, SourceControlProviderError, TrimmedNonEmptyString, type ChangeRequest, - type GitHubSettings, type SourceControlProviderDiscoveryItem, type SourceControlRepositoryCloneUrls, } from "@t3tools/contracts"; @@ -55,6 +53,9 @@ import { type SourceControlManagedCliDiscoverySpec, } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; + +import * as GitHubClient from "../client/definition.ts"; const decodeLinkSubject = Schema.decodeUnknownEffect( Schema.fromJsonString( @@ -85,7 +86,7 @@ function authAccounts(accounts: ReadonlyArray) { */ export function parseGitHubAuth( input: SourceControlAuthProbeInput, - settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github, + settings: GitHubClient.GitHubSettings = readSourceControlHostSettings(GitHubClient.settings, {}), ) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -202,9 +203,12 @@ export const makeDiscovery = Effect.gen(function* () { label: discovery.label, installHint: discovery.installHint, probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { - const settings = yield* sourceControlHost.settings.get.pipe( - Effect.map((current) => current.github), - Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github), + const settings = readSourceControlHostSettings( + GitHubClient.settings, + yield* sourceControlHost.settings.get.pipe( + Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]), + Effect.orElseSucceed(() => undefined), + ), ); const cli = yield* probeSourceControlProvider({ cwd, diff --git a/packages/source-control-github/src/server/gitHubAuthStatus.ts b/packages/source-control-github/src/server/gitHubAuthStatus.ts index 7321ead2d4ec..c894879a68d3 100644 --- a/packages/source-control-github/src/server/gitHubAuthStatus.ts +++ b/packages/source-control-github/src/server/gitHubAuthStatus.ts @@ -1,4 +1,4 @@ -import type { GitHubSettings } from "@t3tools/contracts"; +import type { GitHubSettings } from "../client/definition.ts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; From 38c187bfb7a0020db36ccc1479e7c3501b205fb8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:22:40 +0200 Subject: [PATCH 041/250] fix(pi): preserve tool images and structured results (#17851) --- .../provider-pi/src/server/adapter.test.ts | 301 +++++++++++++++++- packages/provider-pi/src/server/adapter.ts | 103 +++++- .../src/server/mcpBridge.testkit.ts | 140 ++++++++ .../src/server/mcpExtensionSource.test.ts | 175 ++++------ .../src/server/mcpExtensionSource.ts | 71 ++++- packages/provider-pi/src/server/rpc.ts | 10 +- 6 files changed, 659 insertions(+), 141 deletions(-) create mode 100644 packages/provider-pi/src/server/mcpBridge.testkit.ts diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index e13f8213077f..a5e2d53884ee 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -39,6 +39,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; import { makePiAdapterV2, PiAdapterV2Driver, @@ -46,6 +47,8 @@ import { type PiAdapterV2Options, } from "./adapter.ts"; import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; +import { loadMcpBridge } from "./mcpBridge.testkit.ts"; +import { turnItemOutputText } from "../../../client-runtime/src/work-log/itemDetail.ts"; const layerTest = Layer.mergeAll( NodeServices.layer, @@ -77,7 +80,7 @@ const modelSelection = (model: string): ModelSelection => ({ interface FakePi { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; - readonly emit: (record: PiRpcRecord) => Effect.Effect; + readonly emit: (record: PiRpcRecord, chunkBytes?: number) => Effect.Effect; readonly takeRequest: (type: string) => Effect.Effect; /** Data returned by the next `get_entries` acks, consumed in order. */ readonly queueEntries: (data: unknown) => void; @@ -153,10 +156,14 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let models: ReadonlyArray = []; let stdinBuffer = ""; - const emit = (record: PiRpcRecord) => - Queue.offer(stdout, new TextEncoder().encode(`${encodeJsonLine(record)}\n`)).pipe( - Effect.asVoid, - ); + const emit = (record: PiRpcRecord, chunkBytes?: number) => + Effect.gen(function* () { + const bytes = new TextEncoder().encode(`${encodeJsonLine(record)}\n`); + const size = chunkBytes ?? bytes.byteLength; + for (let offset = 0; offset < bytes.byteLength; offset += size) { + yield* Queue.offer(stdout, bytes.subarray(offset, offset + size)); + } + }); const respondTo = (record: PiRpcRecord): PiRpcRecord | null => { if (typeof record["id"] !== "string") return null; @@ -1852,6 +1859,284 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect( + "keeps native and MCP images available to the shared asset reader with typed results", + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const image = { + type: "image", + mimeType: "image/png", + data: Buffer.alloc(64 * 1024, 0x61).toString("base64"), + }; + const note = "Read image file [image/png]"; + const content = [{ type: "text", text: note }, image]; + const structured = { count: 2, ready: true, paths: ["one.png", "two.png"] }; + const scriptResult = { content, structuredContent: { threadId: "child-thread" } }; + const cases = [ + { + toolName: "read", + result: { content, structuredContent: { ...image, note } }, + expected: { content }, + }, + { + toolName: "image_generate", + result: { content, structuredContent: structured }, + expected: { content, structuredContent: structured }, + }, + { + toolName: "mcp__t3-code__preview_snapshot", + result: { + content, + structuredContent: scriptResult, + details: { server: "t3-code", tool: "preview_snapshot" }, + }, + expected: { content, structuredContent: { threadId: "child-thread" } }, + }, + { + toolName: "structured_tool", + result: { content: [], structuredContent: [false, 2, null, { ready: true }] }, + expected: { content: [], structuredContent: [false, 2, null, { ready: true }] }, + }, + { + toolName: "mcp__custom__extension", + result: { content, structuredContent: { content: ["domain content"], ready: true } }, + expected: { content, structuredContent: { content: ["domain content"], ready: true } }, + }, + { + toolName: "mcp__t3-code__preview_snapshot", + result: { + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + structuredContent: { + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + details: { server: "t3-code", tool: "preview_snapshot" }, + }, + expected: { + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + }, + ]; + for (const [index, test] of cases.entries()) { + for (const phase of ["update", "end"] as const) { + yield* fake.emit({ + type: `tool_execution_${phase}`, + toolCallId: `image-${index}`, + toolName: test.toolName, + ...(phase === "end" ? { result: test.result } : { partialResult: test.result }), + isError: false, + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") + return yield* Effect.die("Expected an image tool item"); + assert.equal(event.turnItem.status, phase === "end" ? "completed" : "running"); + assert.deepEqual(event.turnItem.output, test.expected); + assert.deepEqual( + toolOutputImages(event.turnItem.output), + test.expected.content.some((block) => block.type === "image") + ? [{ mimeType: "image/png", data: image.data }] + : [], + ); + if (index === 0) assert.equal(turnItemOutputText(event.turnItem), note); + if (index === 5) assert.equal(turnItemOutputText(event.turnItem), "first\nsecond"); + if (index === 2) { + assert.deepEqual(compactDynamicToolOutput(event.turnItem.output), { + threadId: "child-thread", + }); + assert.equal(JSON.stringify(event.turnItem.output).split(image.data).length - 1, 1); + } + } + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect( + "keeps large tool text readable, drops identical read mirrors, and bounds structured values", + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const text = "hello\nworld\n" + "🙂".repeat(40_000); + const content = [{ type: "text", text }]; + const cases = [ + { content, structuredContent: text }, + { content, structuredContent: { oversized: "x".repeat(128 * 1024) } }, + { content, structuredContent: Array.from({ length: 10_000 }, () => 1) }, + { + content, + structuredContent: Array.from({ length: 40 }).reduce( + (nested) => ({ nested }), + null, + ), + }, + ]; + for (const [index, result] of cases.entries()) { + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: `structured-${index}`, + toolName: "read", + result, + isError: true, + }); + const event = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") + return yield* Effect.die("Expected a tool item"); + assert.equal(event.turnItem.status, "failed"); + assert.notProperty(event.turnItem.output, "structuredContent"); + const displayed = turnItemOutputText(event.turnItem); + if (index === 0) { + assert.equal(event.turnItem.output, text); + assert.equal(displayed, text); + } else { + assert.equal( + displayed, + text + "\nStructured output omitted because it exceeds the stored result limit.", + ); + assert.deepEqual(Object.keys(event.turnItem.output as object), ["content"]); + } + } + // Distinct typed metadata remains stored even when readable text exists. + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "distinct-metadata", + toolName: "read", + result: { content, structuredContent: { path: "read.ts", ready: true } }, + }); + const event = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") + return yield* Effect.die("Expected a tool item"); + assert.deepEqual(event.turnItem.output, { + content, + structuredContent: { path: "read.ts", ready: true }, + }); + assert.equal(turnItemOutputText(event.turnItem), text); + const note = "Read image file [image/png]"; + const image = { type: "image", data: "AAAA", mimeType: "image/png" }; + const imageContent = [{ type: "text", text: note }, image]; + // An extension can attach distinct typed metadata to a read result. + for (const structuredContent of [ + { ...image, note, width: 1200 }, + { ...image, note: "A distinct note" }, + { ...image, note, data: "AQID" }, + ]) { + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "distinct-image-metadata", + toolName: "read", + result: { content: imageContent, structuredContent }, + }); + const event = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") + return yield* Effect.die("Expected a read image tool item"); + assert.deepEqual(event.turnItem.output, { content: imageContent, structuredContent }); + assert.equal(turnItemOutputText(event.turnItem), note); + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect( + "delivers realistic MCP screenshots through the bridge, JSONL transport, and adapter", + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + for (const size of [3.25 * 1024 * 1024, 10 * 1024 * 1024]) { + const pixels = Buffer.alloc(size, 0x61); + const data = pixels.toString("base64"); + const content = [ + { type: "text", text: "Screenshot captured" }, + { type: "image", data, mimeType: "image/png" }, + ]; + const metadata = { screenshot: { width: 1200, height: 800 }, ready: true }; + const bridge = yield* Effect.promise(() => + loadMcpBridge({ modern: true, result: { content, structuredContent: metadata } }), + ); + const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!; + const result = yield* Effect.promise(() => tool.execute("screenshot", {})); + assert.deepEqual(result.structuredContent, { content, structuredContent: metadata }); + yield* fake.emit( + { + type: "tool_execution_end", + toolCallId: "screenshot", + toolName: tool.name, + result, + isError: false, + }, + 64 * 1024, + ); + // A following small completion proves a dropped screenshot immediately, + // without waiting for a timeout when framing loses the large event. + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "after-screenshot", + toolName: "marker", + result: { content: [{ type: "text", text: "after" }] }, + }); + const event = yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "dynamic_tool") + return yield* Effect.die("Expected a screenshot tool item"); + assert.equal(event.turnItem.toolName, tool.name); + assert.equal(event.turnItem.status, "completed"); + assert.deepEqual(event.turnItem.output, { content, structuredContent: metadata }); + assert.equal(turnItemOutputText(event.turnItem), "Screenshot captured"); + const image = toolOutputImages(event.turnItem.output)[0]; + assert.equal(image?.mimeType, "image/png"); + assert.deepEqual(Buffer.from(image!.data!, "base64"), pixels); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "marker", + ); + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("observes official subagent results without inventing child threads", () => Effect.gen(function* () { const fake = yield* makeFakePi; @@ -3512,8 +3797,10 @@ describe("PiRpc framing", () => { yield* push('{"type":"agent_'); yield* push('start"}\r\n{"type":"agent_settled"}\nnot json\n{"type":"queue_update"}\n'); - yield* push("x".repeat(8 * 1024 * 1024)); - yield* push('x{"type":"must_not_emit"}\n{"type":"after_oversized"}\n'); + yield* push('{"type":"must_not_emit","text":"'); + const chunk = new TextEncoder().encode("x".repeat(8 * 1024 * 1024)); + for (let index = 0; index < 32; index++) yield* Queue.offer(stdout, chunk); + yield* push('"}\n{"type":"after_oversized"}\n'); const first = yield* Queue.take(connection.events); assert.equal(first["type"], "agent_start"); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 7fd91ebc7609..973ef4ebb6aa 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -26,6 +26,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { readToolOutputImage } from "@t3tools/shared/toolOutput"; +import * as Predicate from "effect/Predicate"; import { defaultInstanceIdForDriver, ProviderDriverKind, @@ -247,6 +249,104 @@ function contentText(content: unknown): string { .join(""); } +const MAX_STRUCTURED_TOOL_OUTPUT_BYTES = 64 * 1024; + +/** Preserve complete structured JSON only when its size and traversal remain bounded. */ +function isBoundedStructuredToolValue(value: unknown): boolean { + let bytes = MAX_STRUCTURED_TOOL_OUTPUT_BYTES; + let nodes = 4_096; + const visit = (entry: unknown, depth: number): boolean => { + if (depth > 32 || --nodes < 0 || bytes < 0) return false; + if (Array.isArray(entry)) { + bytes -= 2 + entry.length; + return entry.every((item) => visit(item, depth + 1)); + } + if (Predicate.isObject(entry)) { + bytes -= 2; + for (const key in entry) { + if (!Object.hasOwn(entry, key)) continue; + if (key.length > bytes) return false; + bytes -= Buffer.byteLength(JSON.stringify(key), "utf8") + 2; + if (!visit(entry[key], depth + 1)) return false; + } + return bytes >= 0; + } + if (typeof entry === "string" && entry.length > bytes) return false; + bytes -= Buffer.byteLength(JSON.stringify(entry) ?? "null", "utf8"); + return bytes >= 0; + }; + return visit(value, 0); +} + +/** Keep model content and distinct typed values; server ingestion owns image byte policy. */ +function piToolOutput(result: unknown, toolName: string): unknown { + const rawContent = recordField(result, "content"); + const content = Array.isArray(rawContent) + ? rawContent + : typeof rawContent === "string" + ? [{ type: "text", text: rawContent }] + : []; + let structured = recordField(result, "structuredContent"); + // Pi MCP tools return the full CallToolResult to scripts. The model content + // is already captured, so store only its distinct structured payload. + const details = recordField(result, "details"); + const isMcpResult = + toolName.startsWith("mcp__") && + recordString(details, "server") !== undefined && + recordString(details, "tool") !== undefined && + Array.isArray(recordField(structured, "content")); + if (isMcpResult) { + structured = recordField(structured, "structuredContent"); + } + if (typeof structured === "string" && structured === contentText(content)) { + structured = undefined; + } + // Native read repeats its image and first text note as structured output. + // Drop only that exact mirror; an extension's extra metadata stays distinct. + if (toolName === "read" && Predicate.isObject(structured)) { + const image = readToolOutputImage(structured); + const note = + recordString( + content.find((block) => recordField(block, "type") === "text"), + "text", + ) ?? ""; + if ( + image !== null && + Object.keys(structured).every((key) => ["type", "data", "mimeType", "note"].includes(key)) && + recordString(structured, "note") === note && + content.some((block) => { + const match = readToolOutputImage(block); + return match !== null && match.mimeType === image.mimeType && match.data === image.data; + }) + ) { + structured = undefined; + } + } + if (structured !== undefined && !isBoundedStructuredToolValue(structured)) { + return { + content: [ + ...content, + { + type: "text", + text: "Structured output omitted because it exceeds the stored result limit.", + }, + ], + }; + } + if ( + !isMcpResult && + structured === undefined && + content.every((block) => recordField(block, "type") === "text") + ) { + const text = contentText(content); + return text.length > 0 ? text : undefined; + } + return { + content, + ...(structured === undefined ? {} : { structuredContent: structured }), + }; +} + function providerRef( nativeId: string, strength: "strong" | "weak" = "strong", @@ -1047,6 +1147,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return; } } + const output = piToolOutput(resultRecord, toolName); yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, @@ -1057,7 +1158,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ...mcpToolPresentation({ toolName }), toolName, input: args ?? {}, - ...(outputText.length > 0 ? { output: outputText } : {}), + ...(output === undefined ? {} : { output }), }, }); if (toolName === "subagent") { diff --git a/packages/provider-pi/src/server/mcpBridge.testkit.ts b/packages/provider-pi/src/server/mcpBridge.testkit.ts new file mode 100644 index 000000000000..6061c2a3b556 --- /dev/null +++ b/packages/provider-pi/src/server/mcpBridge.testkit.ts @@ -0,0 +1,140 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Executes the shipped Pi extension at its native JavaScript boundary. +import * as NodeModule from "node:module"; +import * as NodeVM from "node:vm"; +import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; + +interface RegisteredTool { + readonly name: string; + readonly description: string; + readonly parameters: unknown; + readonly outputSchema?: unknown; + readonly exposure?: string; + readonly promptSnippet?: string; + readonly promptGuidelines?: ReadonlyArray; + readonly execute: ( + id: string, + args: Record, + signal?: AbortSignal, + ) => Promise<{ + readonly content: ReadonlyArray<{ + readonly type: string; + readonly text?: string; + readonly data?: string; + readonly mimeType?: string; + }>; + readonly structuredContent?: unknown; + readonly isError?: boolean; + }>; +} + +type AgentStartHook = ( + event: { systemPrompt: string }, + ctx: { ui: { notify: (message: string, severity: string) => void } }, +) => Promise<{ systemPrompt: string }>; + +export async function loadMcpBridge( + options: { + readonly modern?: boolean; + readonly result?: unknown; + readonly toolSearchAvailable?: boolean; + readonly toolSearchDisabled?: boolean; + readonly allowsTool?: (name: string) => boolean; + } = {}, +) { + const handlers = new Map(); + const tools: RegisteredTool[] = []; + const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; + let activeTools = ["read"]; + const transports: Array<{ + readonly url: string; + readonly authorization: string; + readonly signal: AbortSignal | undefined; + }> = []; + const servers: Array<{ readonly name: string; readonly config: Record }> = []; + const catalog = [ + { name: "orchestrator_capabilities", description: "Discover available providers and models." }, + { name: "delegate_task", description: "Delegate work to another agent." }, + { name: "task_status", description: "Check delegated work." }, + { name: "preview_snapshot", description: "Inspect the collaborative browser." }, + ].map((tool) => ({ + ...tool, + inputSchema: { type: "object", properties: {} }, + outputSchema: { type: "object", properties: { count: { type: "number" } } }, + })); + const source = NodeModule.stripTypeScriptTypes( + PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( + "export default async function", + "async function", + ), + ); + await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { + process: { + env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, + }, + AbortSignal, + Type: { Unsafe: (schema: unknown) => schema }, + fetch: async ( + url: string, + transport: { body: string; headers: Record; signal?: AbortSignal }, + ) => { + transport.signal?.throwIfAborted(); + transports.push({ + url, + authorization: transport.headers.authorization!, + signal: transport.signal, + }); + const request = JSON.parse(transport.body) as { + id: number; + method: string; + params?: unknown; + }; + requests.push(request); + const result = + request.method === "tools/list" + ? { tools: catalog } + : request.method === "tools/call" + ? (options.result ?? { content: [{ type: "text", text: "browser snapshot" }] }) + : {}; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { + headers: { "content-type": "application/json" }, + }); + }, + pi: { + on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), + registerTool: (tool: RegisteredTool) => { + if (options.allowsTool && !options.allowsTool(tool.name)) return; + const index = tools.findIndex((current) => current.name === tool.name); + if (index === -1) tools.push(tool); + else tools[index] = tool; + }, + getActiveTools: () => activeTools, + setActiveTools: (names: string[]) => { + activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); + }, + getAllTools: () => + options.toolSearchAvailable && + !options.toolSearchDisabled && + (options.allowsTool?.("tool_search") ?? true) + ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] + : [], + ...(options.modern + ? { + registerMcpServer: (name: string, config: Record) => + servers.push({ name, config }), + unregisterMcpServer: () => servers.splice(0), + } + : {}), + }, + }); + return { + handlers, + tools, + requests, + servers, + transports, + getActiveTools: () => activeTools, + restoreActiveTools: (names: string[]) => { + activeTools = names; + }, + }; +} diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index 25b040c2c002..8fc7889a7937 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -4,9 +4,10 @@ import * as NodePath from "node:path"; import * as NodeOS from "node:os"; import * as NodeModule from "node:module"; import * as NodeVM from "node:vm"; -import { assert, describe, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { PI_T3_MCP_EXTENSION_SOURCE } from "./mcpExtensionSource.ts"; +import { loadMcpBridge } from "./mcpBridge.testkit.ts"; type RequestHook = ( event: { payload: unknown }, @@ -47,123 +48,67 @@ async function loadHooks(commands: ReadonlyArray = []) { return handlers; } -interface RegisteredTool { - readonly name: string; - readonly description: string; - readonly parameters: unknown; - readonly exposure?: string; - readonly promptSnippet?: string; - readonly promptGuidelines?: ReadonlyArray; - readonly execute: ( - id: string, - args: Record, - signal?: AbortSignal, - ) => Promise<{ - readonly content: ReadonlyArray<{ readonly type: string; readonly text: string }>; - }>; -} - -type AgentStartHook = ( - event: { systemPrompt: string }, - ctx: { ui: { notify: (message: string, severity: string) => void } }, -) => Promise<{ systemPrompt: string }>; - -async function loadMcpBridge( - options: { - readonly modern?: boolean; - readonly toolSearchAvailable?: boolean; - readonly toolSearchDisabled?: boolean; - readonly allowsTool?: (name: string) => boolean; - } = {}, -) { - const handlers = new Map(); - const tools: RegisteredTool[] = []; - const requests: Array<{ readonly method: string; readonly params?: unknown }> = []; - let activeTools = ["read"]; - const transports: Array<{ - readonly url: string; - readonly authorization: string; - readonly signal: AbortSignal | undefined; - }> = []; - const servers: Array<{ readonly name: string; readonly config: Record }> = []; - const catalog = [ - { name: "orchestrator_capabilities", description: "Discover available providers and models." }, - { name: "delegate_task", description: "Delegate work to another agent." }, - { name: "task_status", description: "Check delegated work." }, - { name: "preview_snapshot", description: "Inspect the collaborative browser." }, - ].map((tool) => ({ ...tool, inputSchema: { type: "object", properties: {} } })); - const source = NodeModule.stripTypeScriptTypes( - PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( - "export default async function", - "async function", - ), - ); - await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { - process: { - env: { T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token" }, - }, - AbortSignal, - Type: { Unsafe: (schema: unknown) => schema }, - fetch: async ( - url: string, - options: { body: string; headers: Record; signal?: AbortSignal }, - ) => { - transports.push({ - url, - authorization: options.headers.authorization!, - signal: options.signal, - }); - const request = JSON.parse(options.body) as { id: number; method: string; params?: unknown }; - requests.push(request); - const result = - request.method === "tools/list" - ? { tools: catalog } - : request.method === "tools/call" - ? { content: [{ type: "text", text: "browser snapshot" }] } - : {}; - return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), { - headers: { "content-type": "application/json" }, - }); - }, - pi: { - on: (name: string, handler: AgentStartHook) => handlers.set(name, handler), - registerTool: (tool: RegisteredTool) => { - if (options.allowsTool && !options.allowsTool(tool.name)) return; - const index = tools.findIndex((current) => current.name === tool.name); - if (index === -1) tools.push(tool); - else tools[index] = tool; - }, - getActiveTools: () => activeTools, - setActiveTools: (names: string[]) => { - activeTools = names.filter((name) => options.allowsTool?.(name) ?? true); +describe("Pi MCP tool results", () => { + it("returns mixed screenshot blocks and typed script output without repeating structured text", async () => { + const content = [ + { type: "text", text: "Browser screenshot" }, + { type: "image", data: "AAAA", mimeType: "image/png", _meta: { private: true } }, + { type: "text", text: "Screenshot captured" }, + ] as const; + const result = { content, structuredContent: { count: 2 }, _meta: { private: true } }; + const bridge = await loadMcpBridge({ modern: true, result }); + const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!; + const output = await tool.execute("snapshot", {}); + assert.deepEqual(output.content, [ + content[0], + { type: "image", data: "AAAA", mimeType: "image/png" }, + content[2], + ]); + assert.deepEqual(output.structuredContent, { content, structuredContent: { count: 2 } }); + assert.deepEqual(tool.outputSchema, { + type: "object", + properties: { + content: { type: "array", items: { type: "object" } }, + structuredContent: { type: "object", properties: { count: { type: "number" } } }, + isError: { type: "boolean" }, + _meta: { type: "object" }, }, - getAllTools: () => - options.toolSearchAvailable && - !options.toolSearchDisabled && - (options.allowsTool?.("tool_search") ?? true) - ? [{ name: "tool_search", sourceInfo: { path: "builtin:tool-search" } }] - : [], - ...(options.modern - ? { - registerMcpServer: (name: string, config: Record) => - servers.push({ name, config }), - unregisterMcpServer: () => servers.splice(0), - } - : {}), - }, + required: ["content"], + }); }); - return { - handlers, - tools, - requests, - servers, - transports, - getActiveTools: () => activeTools, - restoreActiveTools: (names: string[]) => { - activeTools = names; + + it.each([false, true])( + "preserves image-only results and error status, isError=%s", + async (isError) => { + const image = { type: "image", data: "AAAA", mimeType: "image/png" }; + const result = { content: [image], isError }; + const bridge = await loadMcpBridge({ result }); + const output = await bridge.tools[0]!.execute("image", {}); + assert.deepEqual(output.content[0], image); + assert.equal(output.content.length, isError ? 2 : 1); + if (isError) assert.include(output.content[1]?.text ?? "", "returned an error"); + assert.equal(output.isError, isError ? true : undefined); + assert.deepEqual(output.structuredContent, result); }, - }; -} + ); + + it("uses structured output as model text when content is empty", async () => { + const result = { content: [], structuredContent: { count: 2 } }; + const bridge = await loadMcpBridge({ result }); + const output = await bridge.tools[0]!.execute("structured", {}); + assert.deepEqual(output.content, [{ type: "text", text: '{"count":2}' }]); + assert.deepEqual(output.structuredContent, result); + }); + + it("propagates cancellation without returning a successful result", async () => { + const bridge = await loadMcpBridge(); + const controller = new AbortController(); + controller.abort(); + await expect(bridge.tools[0]!.execute("cancelled", {}, controller.signal)).rejects.toThrow( + "aborted", + ); + }); +}); describe("Pi MCP tool exposure", () => { it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => { diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index 8279a515ed65..e33b07cfdcd1 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -38,9 +38,11 @@ const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)}); type RuntimeMode = "approval-required" | "auto-accept-edits" | "auto" | "full-access"; +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + type JsonRpcResponse = { readonly id?: number | string; - readonly result?: unknown; + readonly result?: JsonValue; readonly error?: { readonly message?: string }; }; @@ -48,6 +50,7 @@ type McpTool = { readonly name: string; readonly description?: string; readonly inputSchema?: Record; + readonly outputSchema?: Record; }; function env(name: string): string | undefined { @@ -96,28 +99,57 @@ function jsonSchemaToTypebox(schema: Record | undefined) { return Type.Object({}, { additionalProperties: true }); } -function formatMcpContent(result: unknown): string { - if (result === null || result === undefined) return ""; - if (typeof result !== "object") return String(result); +type ModelContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +function mcpModelContent(result: unknown): ModelContent[] { + if (result === null || result === undefined) return []; + if (typeof result !== "object") return [{ type: "text", text: String(result) }]; const record = result as { - readonly content?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; + readonly content?: ReadonlyArray<{ + readonly type?: string; + readonly text?: string; + readonly data?: string; + readonly mimeType?: string; + }>; readonly structuredContent?: unknown; - readonly isError?: boolean; }; - const texts: string[] = []; + const content: ModelContent[] = []; if (Array.isArray(record.content)) { for (const part of record.content) { - if (part?.type === "text" && typeof part.text === "string") texts.push(part.text); + if (part?.type === "text" && typeof part.text === "string") { + content.push({ type: "text", text: part.text }); + } else if (part?.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") { + content.push({ type: "image", data: part.data, mimeType: part.mimeType }); + } } } - // Most T3 tools mirror structuredContent in a text block. Repeating it would - // leave T3's own output parsing two JSON documents instead of one. - if (record.structuredContent !== undefined) { - const structured = JSON.stringify(record.structuredContent); - if (!texts.includes(structured)) texts.push(structured); + // Structured output belongs to scripts. Only use it as model text when + // the server supplied no model-facing blocks, matching Pi's native MCP tools. + if (content.length === 0) { + content.push({ type: "text", text: JSON.stringify(record.structuredContent ?? result) }); } - if (texts.length > 0) return texts.join("\\n"); - return JSON.stringify(result); + return content; +} + +function mcpScriptResult(result: JsonValue | undefined) { + if (typeof result !== "object" || result === null || Array.isArray(result)) return result; + const { _meta: _ignored, ...scriptResult } = result; + return scriptResult; +} + +function mcpOutputSchema(tool: McpTool) { + return jsonSchemaToTypebox({ + type: "object", + properties: { + content: { type: "array", items: { type: "object" } }, + ...(tool.outputSchema === undefined ? {} : { structuredContent: tool.outputSchema }), + isError: { type: "boolean" }, + _meta: { type: "object" }, + }, + required: ["content"], + }); } function isMcpToolError(result: unknown): boolean { @@ -328,6 +360,7 @@ export default async function t3McpExtension(pi: ExtensionAPI) { label: name, description: tool.description ?? name, parameters: jsonSchemaToTypebox(tool.inputSchema), + ...(supportsExposure ? { outputSchema: mcpOutputSchema(tool) } : {}), ...(supportsExposure ? { exposure } : {}), async execute(_toolCallId, params, signal) { const result = await client.callTool( @@ -335,9 +368,13 @@ export default async function t3McpExtension(pi: ExtensionAPI) { (params ?? {}) as Record, signal, ); - const text = formatMcpContent(result); + const content = mcpModelContent(result); + if (isMcpToolError(result) && !content.some((part) => part.type === "text" && part.text.length > 0)) { + content.push({ type: "text", text: "MCP tool t3-code/" + name + " returned an error" }); + } return { - content: [{ type: "text", text }], + content, + structuredContent: mcpScriptResult(result), details: { server: "t3-code", tool: name }, ...(isMcpToolError(result) ? { isError: true } : {}), }; diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index 7c58ccc99976..3f67291330ae 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -30,6 +30,10 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + MAX_TOOL_OUTPUT_IMAGES, + MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, +} from "@t3tools/shared/toolOutput"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; @@ -125,7 +129,11 @@ interface PendingPiRequest { readonly deferred: Deferred.Deferred; } -const MAX_PI_RECORD_CHARS = 8 * 1024 * 1024; +// MCP completion events carry images in both model content and the script +// CallToolResult. Allow the shared image budget twice, plus bounded JSON/text +// overhead, before dropping an extension's oversized record. +const MAX_PI_RECORD_CHARS = + 2 * MAX_TOOL_OUTPUT_IMAGES * MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH + 8 * 1024 * 1024; function makeJsonlFramer() { let buffer = ""; From a8bdfdb54181fb5c9d43f2747112c137a804d016 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:34:56 +0200 Subject: [PATCH 042/250] fix(server): Claude 5 task lists reach the tasks drawer (#14964) --- .../Adapters/ClaudeAdapterV2.test.ts | 425 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 106 +++++ 2 files changed, 523 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 95a6b453c45e..9ecb9c63d734 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -11,7 +11,14 @@ import type { SpawnedProcess, SpawnOptions, } from "@anthropic-ai/claude-agent-sdk"; -import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; +import type { + AskUserQuestionInput, + TaskCreateInput, + TaskCreateOutput, + TaskListOutput, + TaskUpdateInput, + TaskUpdateOutput, +} from "@anthropic-ai/claude-agent-sdk/sdk-tools"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ChatAttachmentId, @@ -26,6 +33,9 @@ import { type OrchestrationV2ProviderThread, ProjectId, ProviderInstanceId, + type ProviderInstanceEnvironment, + ProviderDriverKind, + type ProviderReplayTranscript, type ProviderApprovalDecision, ProviderSessionId, ProviderTurnId, @@ -74,6 +84,17 @@ import type * as ProviderContinuationRequests from "@t3tools/provider-core/serve import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts"; +import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts"; +import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts"; +import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + CLAUDE_MODEL_SELECTION, + materializeFixtureInput, + projectionFor, +} from "../testkit/fixtures/shared.ts"; const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ @@ -1102,17 +1123,20 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); -// Opens a session with the given configured binary path, runs one turn, and -// returns the executable paths the SDK was asked to spawn. +// Opens a session with the given configured binary path and environment, runs +// one turn, and returns the executable paths the SDK was asked to spawn and the +// CLAUDE_CODE_ENABLE_TODO_TOOLS value it was given. const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( binaryPath: string, + environment: ProviderInstanceEnvironment = [], ) { const executablePaths: Array = []; + const taskToolEnvironment: Array = []; const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( { instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, displayName: undefined, - environment: [], + environment, enabled: true, config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, }, @@ -1128,6 +1152,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio open: (input) => Effect.sync(() => { executablePaths.push(input.options.pathToClaudeCodeExecutable); + taskToolEnvironment.push(input.options.env?.CLAUDE_CODE_ENABLE_TODO_TOOLS); return { messages: Stream.never, offer: () => Effect.void, @@ -1164,19 +1189,38 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio attachments: [], }), ); - return executablePaths; + return { executablePaths, taskToolEnvironment }; }); describe("ClaudeAdapterV2 executable path", () => { - it.effect("expands ~ in the configured binary path for the SDK", () => + it.effect.each([ + { source: "the default", environment: [], hostEnvironment: {}, expected: undefined }, + { + source: "an instance opt-in", + environment: [{ name: "CLAUDE_CODE_ENABLE_TODO_TOOLS", value: "1", sensitive: false }], + hostEnvironment: {}, + expected: "1", + }, + { + source: "a host opt-in", + environment: [], + hostEnvironment: { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" }, + expected: "1", + }, + ])("expands the executable path and respects $source for task tools", (testCase) => Effect.scoped( Effect.gen(function* () { const path = yield* Path.Path; - const executablePaths = yield* captureSdkExecutablePaths("~/bin/claude"); + const { executablePaths, taskToolEnvironment } = yield* captureSdkExecutablePaths( + "~/bin/claude", + testCase.environment, + ); assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); + assert.deepEqual(taskToolEnvironment, [testCase.expected]); }), ).pipe( + Effect.provideService(HostProcess.Environment, testCase.hostEnvironment), Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), @@ -1188,7 +1232,7 @@ describe("ClaudeAdapterV2 executable path", () => { Effect.gen(function* () { const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; - const executablePaths = yield* captureSdkExecutablePaths("claude").pipe( + const { executablePaths } = yield* captureSdkExecutablePaths("claude").pipe( Effect.provideService(HostProcess.Platform, "win32"), Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), @@ -3722,6 +3766,371 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + type TaskToolCall = + | { name: "TaskCreate"; input: TaskCreateInput; output: TaskCreateOutput } + | { name: "TaskUpdate"; input: TaskUpdateInput; output: TaskUpdateOutput } + | { name: "TaskList"; input: Record; output: TaskListOutput }; + + function taskToolFrames( + tool: TaskToolCall, + nextUuid: () => string, + options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean }, + ): ReadonlyArray { + const uuid = nextUuid(); + const toolUseId = `tool-${uuid}`; + const parentToolUseId = options?.parentToolUseId ?? null; + return [ + claudeSdkFrame({ + type: "assistant", + message: { + id: `msg_${uuid}`, + model: "claude-sonnet-4-6", + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: toolUseId, name: tool.name, input: tool.input }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + parent_tool_use_id: parentToolUseId, + uuid, + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: "Task tool finished.", + is_error: options?.isError ?? false, + }, + ...(options?.extraToolResult + ? [{ type: "tool_result", tool_use_id: "unrelated-tool", content: "ok" }] + : []), + ], + }, + tool_use_result: tool.output, + parent_tool_use_id: parentToolUseId, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ]; + } + + const makeTaskToolHarness = Effect.gen(function* () { + const harness = yield* makeWakeHarness; + let sequence = 600; + let turn = 0; + const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const startTurn = Effect.fnUntraced(function* () { + turn++; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-task-tools-${turn}`), + providerTurnOrdinal: turn, + text: "Update the task list.", + attachments: [], + }), + ); + }); + const call = Effect.fnUntraced(function* ( + tool: TaskToolCall, + options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean }, + ) { + for (const frame of taskToolFrames(tool, nextUuid, options)) { + yield* harness.offerAndWait(frame); + } + }); + const finishTurn = Effect.fnUntraced(function* () { + yield* harness.offerAndWait( + makeResultFrame({ uuid: nextUuid(), result: "Task list updated." }), + ); + yield* Queue.take(harness.terminalReceipts); + }); + const plans = () => + harness.events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [], + ); + return { startTurn, call, finishTurn, plans }; + }); + + const createTask = (id: string, subject: string): TaskToolCall => ({ + name: "TaskCreate", + input: { subject: `Requested ${subject}`, description: `Work on ${subject}.` }, + output: { task: { id, subject } }, + }); + const updateTask = (input: TaskUpdateInput, success = true): TaskToolCall => ({ + name: "TaskUpdate", + input, + output: { success, taskId: input.taskId, updatedFields: ["status"] }, + }); + + it.effect("persists a todo_list from inline Claude task-tool frames", () => + Effect.gen(function* () { + let sequence = 700; + const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const prompt = "Track a synthetic task."; + const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript({ + provider: "claudeAgent", + protocol: "claude-agent-sdk.query", + version: "1", + scenario: "claude_task_tools_inline", + metadata: { nativeSessionId: WAKE_NATIVE_SESSION }, + entries: [ + { + type: "expect_outbound", + frame: { + type: "query.open", + options: { + model: "claude-sonnet-4-6", + tools: { type: "preset", preset: "claude_code" }, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + settings: { showThinkingSummaries: true }, + sessionId: WAKE_NATIVE_SESSION, + }, + }, + }, + { + type: "expect_outbound", + frame: { + type: "prompt.offer", + message: { + type: "user", + message: { role: "user", content: prompt }, + parent_tool_use_id: null, + }, + }, + }, + ...[ + ...taskToolFrames(createTask("1", "Inspect"), nextUuid), + ...taskToolFrames(updateTask({ taskId: "1", status: "in_progress" }), nextUuid), + makeResultFrame({ uuid: nextUuid(), result: "Task recorded." }), + ].map((frame) => ({ type: "emit_inbound" as const, frame })), + ], + } satisfies ProviderReplayTranscript); + const materialized = yield* materializeFixtureInput({ + scenario: transcript.scenario, + fixtureInput: { steps: [{ type: "message", text: prompt }] }, + driver: ProviderDriverKind.make("claudeAgent"), + modelSelection: CLAUDE_MODEL_SELECTION, + }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime); + const scenario = { name: transcript.scenario, transcript, ...materialized }; + yield* Effect.gen(function* () { + const result = yield* runOrchestratorV2Scenario(scenario); + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + const plans = projection.plans.filter((plan) => plan.kind === "todo_list"); + assert.lengthOf(plans, 1); + assert.deepEqual( + plans[0]?.steps.map(({ id, text, status }) => ({ id, text, status })), + [{ id: "task-1", text: "Inspect", status: "running" }], + ); + assert.lengthOf( + projection.turnItems.filter((item) => item.type === "todo_list"), + 1, + ); + }).pipe( + Effect.provide(layerProviderReplay(scenario, ClaudeOrchestratorReplayHarness)), + provideDeterministicTestRuntime, + Effect.scoped, + ); + }), + ); + + it.effect( + "projects TaskCreate and TaskUpdate into one plan per turn and supersedes it later", + () => + Effect.gen(function* () { + const harness = yield* makeTaskToolHarness; + yield* harness.startTurn(); + yield* harness.call(createTask("1", "Inspect")); + yield* harness.call(createTask("2", "Implement")); + yield* harness.call(updateTask({ taskId: "1", status: "in_progress" })); + yield* harness.finishTurn(); + + const firstTurnPlans = harness.plans(); + assert.lengthOf(firstTurnPlans, 3); + assert.equal(new Set(firstTurnPlans.map((plan) => plan.id)).size, 1); + assert.deepEqual(firstTurnPlans.at(-1)?.steps, [ + { id: "task-1", text: "Inspect", status: "running" }, + { id: "task-2", text: "Implement", status: "pending" }, + ]); + const firstPlan = firstTurnPlans.at(-1)!; + + yield* harness.startTurn(); + yield* harness.call(updateTask({ taskId: "1", status: "completed", subject: "Inspected" })); + yield* harness.finishTurn(); + + const laterPlans = harness.plans().slice(firstTurnPlans.length); + assert.lengthOf(laterPlans, 2); + assert.equal(laterPlans[0]?.id, firstPlan.id); + assert.equal(laterPlans[0]?.status, "superseded"); + const newPlan = laterPlans[1]!; + assert.notEqual(newPlan.id, firstPlan.id); + assert.notEqual(newPlan.runId, firstPlan.runId); + assert.deepEqual(newPlan.steps, [ + { id: "task-1", text: "Inspected", status: "completed" }, + { id: "task-2", text: "Implement", status: "pending" }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("removes deleted tasks without resurrecting them on later updates or turns", () => + Effect.gen(function* () { + const harness = yield* makeTaskToolHarness; + yield* harness.startTurn(); + yield* harness.call(createTask("1", "Discard")); + yield* harness.call(createTask("2", "Keep")); + yield* harness.call(updateTask({ taskId: "1", status: "deleted" })); + yield* harness.call(updateTask({ taskId: "1", status: "pending" })); + yield* harness.finishTurn(); + assert.lengthOf(harness.plans(), 3); + assert.deepEqual(harness.plans().at(-1)?.steps, [ + { id: "task-2", text: "Keep", status: "pending" }, + ]); + + yield* harness.startTurn(); + yield* harness.call(updateTask({ taskId: "2", status: "in_progress" })); + yield* harness.finishTurn(); + assert.deepEqual(harness.plans().at(-1)?.steps, [ + { id: "task-2", text: "Keep", status: "running" }, + ]); + for (const plan of harness.plans().slice(2)) { + assert.isFalse(plan.steps.some((step) => step.id === "task-1")); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each(["success false", "error result", "unknown id"] as const)( + "does not emit a plan update for TaskUpdate with %s", + (reason) => + Effect.gen(function* () { + const harness = yield* makeTaskToolHarness; + yield* harness.startTurn(); + yield* harness.call(createTask("1", "Inspect")); + yield* harness.call( + updateTask( + { taskId: reason === "unknown id" ? "missing" : "1", status: "completed" }, + reason !== "success false", + ), + { isError: reason === "error result" }, + ); + yield* harness.finishTurn(); + assert.lengthOf(harness.plans(), 1); + assert.deepEqual(harness.plans()[0]?.steps, [ + { id: "task-1", text: "Inspect", status: "pending" }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("replaces task state with TaskList, including an empty list", () => + Effect.gen(function* () { + const harness = yield* makeTaskToolHarness; + yield* harness.startTurn(); + yield* harness.call(createTask("1", "Stale")); + yield* harness.call({ + name: "TaskList", + input: {}, + output: { + tasks: [ + { id: "2", subject: "Queued", status: "pending", blockedBy: [] }, + { id: "3", subject: "Working", status: "in_progress", blockedBy: [] }, + { id: "4", subject: "Done", status: "completed", blockedBy: [] }, + ], + }, + }); + yield* harness.call(updateTask({ taskId: "1", status: "in_progress" })); + yield* harness.call(updateTask({ taskId: "2", status: "in_progress" })); + yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } }); + yield* harness.call(updateTask({ taskId: "2", status: "pending" })); + yield* harness.finishTurn(); + const plans = harness.plans(); + assert.lengthOf(plans, 4); + assert.deepEqual(plans[1]?.steps, [ + { id: "task-2", text: "Queued", status: "pending" }, + { id: "task-3", text: "Working", status: "running" }, + { id: "task-4", text: "Done", status: "completed" }, + ]); + assert.deepEqual(plans[2]?.steps, [ + { id: "task-2", text: "Queued", status: "running" }, + { id: "task-3", text: "Working", status: "running" }, + { id: "task-4", text: "Done", status: "completed" }, + ]); + assert.deepEqual(plans[3]?.steps, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("keeps subagent TaskCreate, TaskUpdate and TaskList out of the main plan", () => + Effect.gen(function* () { + const harness = yield* makeTaskToolHarness; + yield* harness.startTurn(); + yield* harness.call(createTask("1", "Main task")); + const child = { parentToolUseId: "tool-parent-agent" }; + yield* harness.call(createTask("2", "Child task"), child); + yield* harness.call(updateTask({ taskId: "1", status: "completed" }), child); + yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } }, child); + yield* harness.call(updateTask({ taskId: "1", status: "in_progress" })); + yield* harness.finishTurn(); + assert.lengthOf(harness.plans(), 2); + assert.deepEqual(harness.plans().at(-1)?.steps, [ + { id: "task-1", text: "Main task", status: "running" }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("does not associate structured task output with multiple tool results", () => + Effect.gen(function* () { + const harness = yield* makeTaskToolHarness; + yield* harness.startTurn(); + yield* harness.call(createTask("1", "Ambiguous"), { extraToolResult: true }); + yield* harness.finishTurn(); + assert.deepEqual(harness.plans(), []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("resolves API retries on resumed assistant activity", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index aadf28a618f1..3f8ecc1f004b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3150,6 +3150,87 @@ export function claudeTodoSteps(input: unknown): ReadonlyArray>; + +function claudeTaskField(value: unknown, key: string): unknown { + return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined; +} + +function claudeTaskString(value: unknown, key: string): string | undefined { + const field = claudeTaskField(value, key); + return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; +} + +function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined { + switch (value) { + case "pending": + return "pending"; + case "in_progress": + return "running"; + case "completed": + return "completed"; + default: + return undefined; + } +} + +/** + * Applies a finished TaskCreate, TaskUpdate or TaskList call to a task list. + * Claude 5 tracks todos with these tools instead of TodoWrite. Ids come from + * Claude (TaskCreate reports them in its result), a `deleted` status removes + * the task, and TaskList replaces the whole list. Returns undefined when the + * call is not a task tool or leaves the list unchanged. + */ +function applyClaudeTaskTool( + tasks: ClaudeTaskList, + toolName: string, + input: unknown, + result: unknown, +): ClaudeTaskList | undefined { + if (toolName === "TaskList") { + const listed = claudeTaskField(result, "tasks"); + if (!Array.isArray(listed)) return undefined; + const next = new Map>(); + for (const task of listed) { + const id = claudeTaskString(task, "id"); + const text = claudeTaskString(task, "subject"); + const status = claudeTaskStatus(claudeTaskField(task, "status")); + if (id !== undefined && text !== undefined && status !== undefined) { + next.set(id, { text, status }); + } + } + return next.size === 0 && tasks.size === 0 ? undefined : next; + } + if (toolName === "TaskCreate") { + const created = claudeTaskField(result, "task"); + const id = claudeTaskString(created, "id"); + const text = claudeTaskString(created, "subject") ?? claudeTaskString(input, "subject"); + if (id === undefined || text === undefined) return undefined; + return new Map(tasks).set(id, { text, status: "pending" }); + } + if (toolName !== "TaskUpdate" || claudeTaskField(result, "success") === false) { + return undefined; + } + const id = claudeTaskString(input, "taskId") ?? claudeTaskString(result, "taskId"); + const task = id === undefined ? undefined : tasks.get(id); + if (id === undefined || task === undefined) return undefined; + const nativeStatus = claudeTaskField(input, "status"); + if (nativeStatus === "deleted") { + const next = new Map(tasks); + next.delete(id); + return next; + } + const text = claudeTaskString(input, "subject") ?? task.text; + const status = claudeTaskStatus(nativeStatus) ?? task.status; + if (text === task.text && status === task.status) return undefined; + return new Map(tasks).set(id, { text, status }); +} + +function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray { + return [...tasks].map(([id, task]) => ({ id: `task-${id}`, ...task })); +} + export function claudeProposedPlan(input: unknown): string | null { const value = typeof input === "object" && input !== null && Reflect.get(input, "type") === "record" @@ -3238,6 +3319,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const queryContext = yield* Ref.make(null); const openedNativeThreads = yield* Ref.make(new Set()); const latestPlanByKind = yield* Ref.make(new Map()); + // Claude 5 task tools edit one list per native session, across turns. + const taskListsByNativeThread = yield* Ref.make(new Map()); const planIdsByNativeItem = yield* Ref.make( new Map(), ); @@ -6639,6 +6722,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); yield* emitToolCallArtifacts(artifacts); toolCallsFor(context, toolCall).delete(toolCall.nativeItemId); + // A subagent's task tools must not replace the parent's list. + if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) { + const taskLists = yield* Ref.get(taskListsByNativeThread); + const taskList = applyClaudeTaskTool( + taskLists.get(liveQuery.nativeThreadId) ?? new Map(), + toolCall.toolName, + claudeNativeToolInputValue(toolCall.input), + claudeNativeToolOutputValue(output), + ); + if (taskList !== undefined) { + yield* Ref.set( + taskListsByNativeThread, + new Map(taskLists).set(liveQuery.nativeThreadId, taskList), + ); + // One list per turn, updated in place; a later turn supersedes it. + yield* emitClaudePlanProjection({ + context, + nativeItemId: `claude-tasks:${context.providerTurnId}`, + kind: "todo_list", + steps: claudeTaskSteps(taskList), + }).pipe(Effect.orDie); + } + } } const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message); From f92afe79c921dd7de20204e83d62fed0491b4e54 Mon Sep 17 00:00:00 2001 From: Matthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:20:57 -0700 Subject: [PATCH 043/250] fix(web): find bar and thread details panel stop covering each other (#17858) Co-authored-by: Yash Singh --- apps/web/src/components/ChatView.tsx | 5 - .../src/components/chat/ChatCanvas.test.tsx | 119 +++++++++++++++++ apps/web/src/components/chat/ChatCanvas.tsx | 31 ++++- .../src/components/chat/ThreadDetailsCard.tsx | 38 +++++- .../web/src/components/chat/ThreadFindBar.tsx | 120 +++++++++--------- .../components/chat/ThreadFindProvider.tsx | 5 +- .../components/chat/chatCanvasLayout.test.ts | 40 ++++++ .../src/components/chat/chatCanvasLayout.ts | 19 ++- .../chat/threadDetailsCardLayout.test.ts | 5 +- .../chat/threadDetailsCardLayout.ts | 6 +- apps/web/src/components/ui/popover.tsx | 7 +- 11 files changed, 313 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/components/chat/ChatCanvas.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2ec0af4ed20..e442fae36aa6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7998,11 +7998,6 @@ export default function ChatView(props: ChatViewProps) { const [isThreadFindActive, setIsThreadFindActive] = useState(false); const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []); const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []); - // The details popover hangs off the header over the find bar; opening find dismisses it. - useEffect(() => { - if (!isThreadFindActive || threadPanelPresentation !== "popover" || !activeThreadRef) return; - useRightPanelStore.getState().setThreadPanelOpen(activeThreadRef, "popover", false); - }, [activeThreadRef, isThreadFindActive, threadPanelPresentation]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { diff --git a/apps/web/src/components/chat/ChatCanvas.test.tsx b/apps/web/src/components/chat/ChatCanvas.test.tsx new file mode 100644 index 000000000000..5ab43b81cb2e --- /dev/null +++ b/apps/web/src/components/chat/ChatCanvas.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom + +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { ChatCanvas } from "./ChatCanvas"; +import { useChatCanvas } from "./ChatCanvasContext"; + +const resize = vi.hoisted(() => ({ callbacks: new Set<() => void>() })); +vi.mock("../../lib/observeResize", () => ({ + observeResize: (_elements: unknown, callback: () => void) => { + resize.callbacks.add(callback); + return () => resize.callbacks.delete(callback); + }, +})); + +let root: Root; +let container: HTMLDivElement; +let fontSize = 16; +let canvas: ReturnType; + +function Preview({ x }: { x: number }) { + const current = useChatCanvas(); + useLayoutEffect(() => { + canvas = current; + }, [current]); + const reportPreview = current?.reportPreview; + useLayoutEffect(() => { + reportPreview?.({ + key: "browser:one", + width: 240, + source: { width: 240, height: 365 }, + position: { x, y: 12 }, + lastInteraction: "drag", + }); + }, [reportPreview, x]); + return null; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + fontSize = 16; + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(1800); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(1000); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + // A nonzero canvas origin catches viewport coordinates used as canvas coordinates. + if (this.hasAttribute("data-thread-find-bar")) { + const gap = 0.75 * fontSize; + return new DOMRect( + 200 + 1800 - gap - 17.5 * fontSize, + 100 + gap, + 17.5 * fontSize, + 2.25 * fontSize, + ); + } + return new DOMRect(200, 100, 1800, 1000); + }); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + paddingLeft: "48px", + width: "736px", + minWidth: "640px", + } as CSSStyleDeclaration); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + resize.callbacks.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function renderFind(open: boolean, previewX = 1548) { + await act(() => + root.render( + + {open ?
: null} + + , + ), + ); +} + +it.each([16, 24])( + "keeps a preview below Find and releases its space on close at a %spx root font", + async (size) => { + fontSize = size; + await renderFind(false); + expect(canvas?.layout.frame?.y).toBe(12); + + await renderFind(true); + expect(canvas?.detailsCardTopInset).toBe(3 * size); + expect(canvas?.layout.frame?.y).toBe(3 * size + 12); + + await renderFind(false); + expect(canvas?.detailsCardTopInset).toBe(0); + expect(canvas?.layout.frame?.y).toBe(12); + + await renderFind(true); + expect(canvas?.layout.frame?.y).toBe(3 * size + 12); + }, +); + +it("remeasures the open bar when its font size changes without resizing the canvas", async () => { + await renderFind(true, 1200); + expect(canvas?.layout.frame?.y).toBe(12); + + fontSize = 24; + await act(() => { + for (const callback of resize.callbacks) callback(); + }); + expect(canvas?.detailsCardTopInset).toBe(72); + expect(canvas?.layout.frame?.y).toBe(84); +}); diff --git a/apps/web/src/components/chat/ChatCanvas.tsx b/apps/web/src/components/chat/ChatCanvas.tsx index 35f966565851..809443c8f686 100644 --- a/apps/web/src/components/chat/ChatCanvas.tsx +++ b/apps/web/src/components/chat/ChatCanvas.tsx @@ -47,6 +47,9 @@ export function ChatCanvas({ minChatWidth: 640, composerHeight: 0, timelineGutter: 0, + findBarLeft: 0, + findBarRight: 0, + findBarBottom: 0, }); const reportPreview = useCallback((next: ChatCanvasPreview) => { setPreview((current) => @@ -69,9 +72,16 @@ export function ChatCanvas({ const element = elementRef.current; const probe = widthProbeRef.current; if (!element || !probe) return; + const findBar = + detailsCardTopInset > 0 ? element.querySelector("[data-thread-find-bar]") : null; const measure = () => { + const canvasBounds = element.getBoundingClientRect(); + const findBarBounds = findBar?.getBoundingClientRect(); const styles = getComputedStyle(probe); const next = { + findBarLeft: findBarBounds ? findBarBounds.left - canvasBounds.left : 0, + findBarRight: findBarBounds ? findBarBounds.right - canvasBounds.left : 0, + findBarBottom: findBarBounds ? findBarBounds.bottom - canvasBounds.top : 0, width: element.clientWidth, height: element.clientHeight, padding: Number.parseFloat(styles.paddingLeft), @@ -92,22 +102,37 @@ export function ChatCanvas({ }; measure(); const observed: Element[] = [element, probe]; + if (findBar) observed.push(findBar); if (composerOverlayElement) observed.push(composerOverlayElement); if (timelineElement) observed.push(timelineElement); return observeResize(observed, measure); - }, [composerOverlayElement, timelineElement]); + }, [composerOverlayElement, timelineElement, detailsCardTopInset]); const context = useMemo(() => { const container = { width: measurements.width, height: measurements.height }; + const findBar = + detailsCardTopInset > 0 + ? { + left: measurements.findBarLeft, + right: measurements.findBarRight, + bottom: measurements.findBarBottom, + } + : null; return { container, lane: { padding: measurements.padding, minChatWidth: measurements.minChatWidth }, - layout: resolveChatCanvasLayout({ ...measurements, container, preview, detailsCard }), + layout: resolveChatCanvasLayout({ + ...measurements, + container, + preview, + detailsCard, + findBar, + }), previewKey: preview?.key ?? null, reportPreview, clearPreview, registerTimeline, reportDetailsCard, - detailsCardTopInset, + detailsCardTopInset: findBar?.bottom ?? 0, }; }, [ measurements, diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4aa4ce400b8b..0b0ebb8398bd 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -7,6 +7,7 @@ import { selectThreadPanelOpen, useRightPanelStore } from "../../rightPanelStore import type { ThreadPanelPresentation } from "../../rightPanelLayout"; import { useChatCanvas } from "./ChatCanvasContext"; import { + THREAD_DETAILS_CARD_GAP, resolveThreadDetailsCardDensity, resolveThreadDetailsCardLayout, } from "./threadDetailsCardLayout"; @@ -45,6 +46,7 @@ export function ThreadDetailsCard({ }) : null; const mode = placement ? "inline" : "popover"; + const topInset = canvas?.detailsCardTopInset ?? 0; const inlineOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "inline"), ); @@ -137,17 +139,38 @@ export function ThreadDetailsCard({ - useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open) - } + onOpenChange={(open, details) => { + // The find bar sits beside the card, so clicking or focusing it does not + // dismiss the card. Escape still does. + const target = + details.reason === "focus-out" + ? (details.event as FocusEvent).relatedTarget + : details.reason === "outside-press" + ? details.event.target + : null; + if (!open && target instanceof Element && target.closest("[data-thread-find-bar]")) { + details.cancel(); + return; + } + useRightPanelStore.getState().setThreadPanelOpen(threadRef, "popover", open); + }} > {placement ? ( inlineOpen ? (