From 521f5b5a5682b18f2d59cf95226fc203cd314240 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:34:41 +0300 Subject: [PATCH 001/530] fix(web): replace Lineage timers with Stop on hover (#16791) --- .../src/components/chat/ThreadDetailsControl.tsx | 1 + .../components/chat/ThreadRelationshipsControl.tsx | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx index f14a52c14496..0b83c9313522 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx @@ -20,6 +20,7 @@ import { ChevronDownIcon } from "lucide-react"; const parts = { row: THREAD_DETAILS_PANEL_ROW_CLASS, + "group-row": `${THREAD_DETAILS_PANEL_ROW_CLASS} hover:!bg-transparent dark:hover:!bg-transparent`, select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index f56c1c92b562..cd1265e46a60 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -388,6 +388,9 @@ export function ThreadRelationshipsPanel(props: { agent?.origin === "app_owned" && agent.startedAt && ["pending", "running", "waiting"].includes(agent.status); + const trailingVisibilityClass = canStop + ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" + : ""; const threadTitle = relationshipThreadTitle({ title: node?.thread?.title ?? agent?.title ?? threadId, isSubagent, @@ -444,13 +447,13 @@ export function ThreadRelationshipsPanel(props: { carries status, so an agent with a known time shows only that. */} {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? ( ) : !isMergeTarget ? ( {threadRelationshipStatusLabel(status)} @@ -458,7 +461,10 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? (
    @@ -528,7 +534,7 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" disabled={node?.missing === true} onClick={() => openThread(threadId)} - part="row" + part={canStop ? "group-row" : "row"} /> } > From 6ecb82dc42c4c721fd2c03c92316ee5361306cdb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 09:43:10 -0700 Subject: [PATCH 002/530] fix(clients): running subagent cards stay visible after their parent turn settles (#16878) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/lib/threadActivity.test.ts | 68 +++++++ apps/mobile/src/lib/threadActivity.ts | 6 +- .../chat/MessagesTimeline.logic.test.ts | 173 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 54 +++++- .../src/state/subagentRuntime.ts | 11 +- 5 files changed, 307 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 1cf78cc29f3f..16be0186e68b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -975,6 +975,74 @@ describe("buildThreadFeed", () => { ).toBe(true); }); + it("keeps a settled run's still-running subagents visible while its other work folds", () => { + const subagent = ( + id: string, + updatedAt: string, + ordinal: number, + status: OrchestrationV2TurnItem["status"], + ): OrchestrationV2TurnItem => ({ + ...base(id, updatedAt, ordinal), + providerTurnId: ProviderTurnId.make("provider-turn-1"), + status, + completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt), + type: "subagent", + subagentId: NodeId.make(id), + origin: "app_owned", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: sourceThreadId, + prompt: `Inspect ${id}`, + result: null, + }); + const latestRun = { + runId, + status: "completed" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:05.000Z", + }; + const present = (children: ReadonlyArray) => + deriveThreadFeedPresentation( + buildThreadFeed([ + projected(userMessage(), 0), + projected(command("2026-06-20T00:00:02.000Z"), 1), + ...children.map((child, index) => projected(child, index + 2)), + projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5), + ]), + latestRun, + new Set(), + ); + const visibleSubagentIds = (presented: ReadonlyArray) => + presented.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap((activity) => + activity.projectedItem.item.type === "subagent" + ? [activity.projectedItem.item.id] + : [], + ) + : [], + ); + + const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]); + expect(live.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "activity-group", + "message", + ]); + expect(visibleSubagentIds(live)).toEqual(["item-live"]); + + // A launch batch stays whole while any member is live. + const mixed = present([ + subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"), + subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"), + ]); + expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]); + + const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]); + expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); + }); + it("folds settled V2 run work while keeping the terminal assistant message visible", () => { const feed = buildThreadFeed([ projected(userMessage(), 0), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 927fd59b7dec..4038fa4982c7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -5,6 +5,7 @@ import type { } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { turnItemHasDetail, @@ -1107,7 +1108,10 @@ function deriveThreadFeedRunFolds( (activity) => activity.prominent || activity.projectedItem.item.type === "notification" || - activity.projectedItem.item.type === "handoff", + activity.projectedItem.item.type === "handoff" || + // A child still working outlives its settled launching run; + // its group stays visible while any member is live. + isLiveSubagentTurnItem(activity.projectedItem.item), ) ), ) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 173a964d5efc..0f22324e0ca1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2,10 +2,14 @@ import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; import { CheckpointRef, NodeId, + ProviderDriverKind, + ProviderInstanceId, + ProviderTurnId, RunAttemptId, TurnItemId, RuntimeRequestId, type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { @@ -4889,3 +4893,172 @@ describe("failed turn transcript", () => { }, ); }); + +describe("live subagents after their parent turn settles", () => { + const runId = RunId.make("parent-run"); + const threadId = ThreadId.make("parent-thread"); + const providerTurnId = ProviderTurnId.make("parent-provider-turn"); + const at = (second: number) => + DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`); + const base = (id: string, second: number) => ({ + id: TurnItemId.make(id), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: second, + title: null, + startedAt: at(second), + completedAt: at(second), + updatedAt: at(second), + }); + const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({ + position: item.ordinal, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: item.id, + item, + }); + const child = ( + id: string, + second: number, + status: OrchestrationV2TurnItem["status"], + origin: "app_owned" | "provider_native" = "app_owned", + ): OrchestrationV2TurnItem => ({ + ...base(id, second), + status, + completedAt: status === "running" ? null : at(second), + type: "subagent", + subagentId: NodeId.make(id), + origin, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null, + prompt: `Inspect ${id}`, + result: null, + }); + const rowsFor = (children: ReadonlyArray) => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: ( + [ + { + ...base("user", 0), + status: "completed", + type: "user_message", + messageId: MessageId.make("user"), + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + text: "Delegate the review", + attachments: [], + }, + { + ...base("reasoning", 1), + status: "completed", + type: "reasoning", + text: "Two children can split this.", + streaming: false, + }, + { + ...base("command", 2), + status: "completed", + type: "command_execution", + input: "pwd", + output: "/repo", + exitCode: 0, + }, + ...children, + { + ...base("assistant", 8), + status: "completed", + type: "assistant_message", + messageId: MessageId.make("assistant"), + text: "Both children are running.", + streaming: false, + }, + ] satisfies OrchestrationV2TurnItem[] + ).map(projected), + optimisticMessages: [], + }), + latestRun: { + runId, + status: "completed", + startedAt: DateTime.formatIso(at(0)), + completedAt: DateTime.formatIso(at(8)), + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + it.each(["app_owned", "provider_native"] as const)( + "keeps a running %s child visible while the rest of the settled turn folds", + (origin) => { + const rows = rowsFor([child("child", 3, "running", origin)]); + // The reasoning and command still fold; only the live card stays out. + expect(rows.map((row) => row.id)).toEqual([ + "user", + `turn-fold:${runId}`, + "child", + "assistant", + ]); + }, + ); + + it.each(["completed", "failed"] as const)("folds a %s child with its settled turn", (status) => { + const rows = rowsFor([child("child", 3, status)]); + expect(rows.some((row) => row.id === "child")).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); + }); + + it("keeps a launch batch visible while any member is still running", () => { + const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]); + expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]); + expect(rows.find((row) => row.id === "done")).toMatchObject({ + subagents: [{ item: { id: "done" } }, { item: { id: "live" } }], + }); + }); + + it("keeps a running child visible when a steer supersedes its attempt", () => { + const attempt = { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + }; + const entries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: ( + [ + { + ...base("command", 2), + status: "completed", + type: "command_execution", + input: "pwd", + output: "/repo", + exitCode: 0, + }, + child("child", 3, "running"), + ] satisfies OrchestrationV2TurnItem[] + ).map(projected), + optimisticMessages: [], + }).map((entry) => ({ ...entry, attempt })); + const rows = deriveMessagesTimelineRows({ + timelineEntries: entries, + latestRun: { + runId, + status: "running", + startedAt: DateTime.formatIso(at(0)), + completedAt: null, + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7360650a3a99..d403e37c3322 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,5 +1,6 @@ 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 Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; @@ -709,6 +710,7 @@ interface SupersededAttemptFold { function deriveSupersededAttemptFolds( timelineEntries: ReadonlyArray, unfoldedRunIds: ReadonlySet, + liveSubagentEntryIds: ReadonlySet, ): ReadonlyMap { const entriesByAttemptId = new Map(); for (const entry of timelineEntries) { @@ -719,6 +721,8 @@ function deriveSupersededAttemptFolds( // A published page stays visible, as it does when its turn folds. entry.kind === "html-render" || timelineEntryIsPersistentResourceCard(entry) || + // A steer supersedes the attempt but leaves its children running. + liveSubagentEntryIds.has(entry.id) || (entry.kind === "work" && entry.entry.itemType === "system_notice") ) { continue; @@ -767,6 +771,43 @@ function deriveUnsettledRunId( return isSettled ? null : latestRun.runId; } +/** + * Subagent cards that stay out of their turn's folds. A child can keep working + * after its launching turn settles (or its attempt is superseded), and the + * waiting footer counts it, so its card stays visible until the child ends. + * Adjacent cards from one provider turn render as a single grouped row; the + * whole group stays visible while any member is live, so a launch batch never + * shows half of its children. + */ +function liveSubagentCardEntryIds(entries: ReadonlyArray): ReadonlySet { + const visible = new Set(); + let batch: Array> = []; + const flush = () => { + if (batch.length === 0) return; + if (batch.some((entry) => isLiveSubagentTurnItem(entry.projectedItem.item))) { + for (const entry of batch) visible.add(entry.id); + } + batch = []; + }; + for (const entry of entries) { + if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") { + flush(); + continue; + } + const item = entry.projectedItem.item; + const previous = batch.at(-1)?.projectedItem.item; + if ( + previous !== undefined && + (previous.runId !== item.runId || previous.providerTurnId !== item.providerTurnId) + ) { + flush(); + } + batch.push(entry); + } + flush(); + return visible; +} + /** `runlessKey` stands in for the run of entries that have none. */ function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null { if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null; @@ -874,6 +915,7 @@ function deriveTurnFolds(input: { unfoldedRunIds: ReadonlySet; /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ runlessWorkActive: boolean; + liveSubagentEntryIds: ReadonlySet; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -986,9 +1028,12 @@ function deriveTurnFolds(input: { if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) { continue; } - // Linked resources can outlive their launching run and stay visible - // after the surrounding work folds. - if (timelineEntryIsPersistentResourceCard(entry)) { + // Linked resources and still-working children can outlive their + // launching run and stay visible after the surrounding work folds. + if ( + timelineEntryIsPersistentResourceCard(entry) || + input.liveSubagentEntryIds.has(entry.id) + ) { continue; } if (entry.kind === "work" && entry.entry.itemType === "notification") continue; @@ -1242,9 +1287,11 @@ export function deriveMessagesTimelineRows(input: { const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( timelineEntries, failedRunIds, + liveSubagentEntryIds, ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, @@ -1258,6 +1305,7 @@ export function deriveMessagesTimelineRows(input: { latestRun: input.latestRun ?? null, unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive, + liveSubagentEntryIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index da222ecfef3d..2b1fc96e54ac 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -3,7 +3,7 @@ * web agent rows render. */ import * as DateTime from "effect/DateTime"; -import type { OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; export type RuntimeSubagentStatus = @@ -91,6 +91,15 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { return isOrchestrationV2WorkActive(status); } +/** + * A subagent card whose child is still working. The card outlives its + * launching turn: once that turn settles, the waiting footer counts the child, + * so the card stays out of the turn's fold until the child finishes. + */ +export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { + return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); +} + /** * Projects orchestration-v2 subagent entities into the runtime shape the web * agent rows render. From 4d976d1fc233d3aa971fa7dcbf2e7316bfa3ac72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 09:44:08 -0700 Subject: [PATCH 003/530] feat: MCP apps render and run inline in threads (#16236) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/src/Stack.tsx | 10 + .../threads/McpAppFullscreenScreen.tsx | 168 +++++ .../src/features/threads/McpAppWebView.tsx | 611 ++++++++++++++++++ .../src/features/threads/ThreadFeed.tsx | 16 + apps/mobile/src/lib/attachmentDownload.ts | 23 + apps/mobile/src/lib/openExternalUrl.ts | 1 + apps/mobile/src/lib/threadActivity.test.ts | 32 + apps/mobile/src/lib/threadActivity.ts | 47 +- apps/mobile/src/state/mcpApps.ts | 5 + .../record-codex-app-server-replay-fixture.ts | 1 + apps/server/src/attachmentStore.test.ts | 20 + apps/server/src/attachmentStore.ts | 10 +- apps/server/src/auth/RpcAuthorization.ts | 5 + .../src/mcpApps/McpAppModelContext.test.ts | 68 ++ apps/server/src/mcpApps/McpAppModelContext.ts | 105 +++ .../server/src/mcpApps/McpAppRequests.test.ts | 284 ++++++++ apps/server/src/mcpApps/McpAppRequests.ts | 240 +++++++ apps/server/src/mcpApps/McpAppSnapshot.ts | 108 ++++ .../src/observability/RpcInstrumentation.ts | 4 + .../Adapters/CodexAdapterV2.test.ts | 289 +++++++++ .../Adapters/CodexAdapterV2.ts | 323 ++++++++- .../src/orchestration-v2/ProjectionStore.ts | 6 +- .../src/orchestration-v2/ProviderAdapter.ts | 45 ++ .../RunExecutionService.test.ts | 99 +++ .../orchestration-v2/RunExecutionService.ts | 21 + .../src/orchestration-v2/runtimeLayer.ts | 15 + .../testkit/ProviderReplayHarness.ts | 2 + .../codex_transcript.ndjson | 2 +- .../message_steering/codex_transcript.ndjson | 2 +- .../multi_turn/codex_transcript.ndjson | 2 +- .../plan_questions/codex_transcript.ndjson | 2 +- .../proposed_plan/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 4 +- .../queued_turn/codex_transcript.ndjson | 2 +- .../fixtures/simple/codex_transcript.ndjson | 2 +- .../fixtures/subagent/codex_transcript.ndjson | 2 +- .../subagent_continue/codex_transcript.ndjson | 2 +- .../subagent_v2/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../thread_rollback/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 4 +- .../codex_transcript.ndjson | 2 +- .../todo_list/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../turn_interrupt/codex_transcript.ndjson | 2 +- .../codex_transcript.ndjson | 2 +- .../web_search/codex_transcript.ndjson | 2 +- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../Migrations/059_McpAppModelContext.ts | 21 + .../reconcileV2PreviewMigration.test.ts | 2 + apps/server/src/ws.ts | 6 + apps/web/src/components/ChatView.tsx | 35 +- apps/web/src/components/chat/McpAppFrame.tsx | 550 ++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 38 +- .../src/components/chat/MessagesTimeline.tsx | 71 +- apps/web/src/confirmDialog.test.ts | 4 + apps/web/src/confirmDialog.ts | 5 + apps/web/src/session-logic.test.ts | 19 + apps/web/src/session-logic.ts | 37 +- apps/web/src/state/mcpApps.ts | 5 + docs/user/html-renders.md | 8 + packages/client-runtime/package.json | 8 + .../client-runtime/src/mcpApps/host.test.ts | 419 ++++++++++++ packages/client-runtime/src/mcpApps/host.ts | 576 +++++++++++++++++ packages/client-runtime/src/state/mcpApps.ts | 33 + packages/contracts/src/index.ts | 1 + packages/contracts/src/mcpApps.ts | 114 ++++ packages/contracts/src/rpc.ts | 43 ++ packages/shared/package.json | 4 + packages/shared/src/mcpApp.test.ts | 150 +++++ packages/shared/src/mcpApp.ts | 236 +++++++ packages/shared/src/toolOutput.ts | 32 +- 83 files changed, 4990 insertions(+), 55 deletions(-) create mode 100644 apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx create mode 100644 apps/mobile/src/features/threads/McpAppWebView.tsx create mode 100644 apps/mobile/src/state/mcpApps.ts create mode 100644 apps/server/src/mcpApps/McpAppModelContext.test.ts create mode 100644 apps/server/src/mcpApps/McpAppModelContext.ts create mode 100644 apps/server/src/mcpApps/McpAppRequests.test.ts create mode 100644 apps/server/src/mcpApps/McpAppRequests.ts create mode 100644 apps/server/src/mcpApps/McpAppSnapshot.ts create mode 100644 apps/server/src/persistence/Migrations/059_McpAppModelContext.ts create mode 100644 apps/web/src/components/chat/McpAppFrame.tsx create mode 100644 apps/web/src/state/mcpApps.ts create mode 100644 packages/client-runtime/src/mcpApps/host.test.ts create mode 100644 packages/client-runtime/src/mcpApps/host.ts create mode 100644 packages/client-runtime/src/state/mcpApps.ts create mode 100644 packages/contracts/src/mcpApps.ts create mode 100644 packages/shared/src/mcpApp.test.ts create mode 100644 packages/shared/src/mcpApp.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 8dd0bea2f550..14fbc8b82f51 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -51,6 +51,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; @@ -747,6 +748,15 @@ const RootStackConfig = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files/:path*`, options: SOLID_HEADER_OPTIONS, }), + ThreadMcpApp: createNativeStackScreen({ + screen: McpAppFullscreenScreen, + linking: `${THREAD_LINKING_PREFIX}/apps/:itemId`, + options: { + presentation: "fullScreenModal", + headerShown: false, + gestureEnabled: false, + }, + }), ThreadAttachment: createNativeStackScreen({ screen: AttachmentFileScreen, linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx new file mode 100644 index 000000000000..0a09941fe78d --- /dev/null +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -0,0 +1,168 @@ +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useThreadShell, useThreadShells } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import { ThreadMcpApp } from "./McpAppWebView"; + +type McpAppFullscreenScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly conversationThreadId?: string; + readonly itemId: string; + readonly revision?: string; +}>; + +/** + * The thread an app's messages go to. A link only names it, so it counts only + * when it is the app's own thread or a fork of it; anything else falls back to + * the app's thread. + */ +function useConversationThreadId( + environmentId: EnvironmentId, + threadId: ThreadId, + requested: string | undefined, +): ThreadId { + const shells = useThreadShells(); + return useMemo( + () => + requested !== undefined && descendsFrom(shells, environmentId, requested, threadId) + ? ThreadId.make(requested) + : threadId, + [shells, environmentId, threadId, requested], + ); +} + +/** True when `threadId` is `ancestorId` or one of its forks, at any depth. */ +function descendsFrom( + shells: ReadonlyArray, + environmentId: EnvironmentId, + threadId: string, + ancestorId: ThreadId, +): boolean { + const parents = new Map( + shells + .filter((shell) => shell.environmentId === environmentId) + // Only forks continue a conversation; a subagent is its own. + .filter((shell) => shell.lineage.relationshipToParent === "fork") + .map((shell) => [shell.id as string, shell.lineage.parentThreadId]), + ); + let current: string | null | undefined = threadId; + for (let depth = 0; depth < 64 && current != null; depth += 1) { + if (current === ancestorId) return true; + current = parents.get(current); + } + return false; +} + +/** + * An MCP App full screen (`ui/request-display-mode`), in a modal over the + * thread. It is its own view of the app; closing it returns to the row. The + * app is read from its stored tool call, never from the route, so a link can + * only open an app that call really produced. + */ +export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + // A plain goBack pops whatever is on top, so it only runs while this modal + // is: an exit that lands while another screen covers it (after a teardown + // wait) waits for the modal to be on top again, rather than popping that + // screen or being lost. + const focused = useIsFocused(); + const [exitRequested, setExitRequested] = useState(false); + const onClose = useCallback(() => { + if (navigation.isFocused()) navigation.goBack(); + else setExitRequested(true); + }, [navigation]); + const [size, setSize] = useState({ width: 0, height: 0 }); + const params = route.params; + const environmentId = EnvironmentId.make(params.environmentId); + const threadId = ThreadId.make(params.threadId); + const itemId = TurnItemId.make(params.itemId); + const conversationThreadId = useConversationThreadId( + environmentId, + threadId, + params.conversationThreadId, + ); + const detail = useEnvironmentQuery( + orchestrationEnvironment.turnItem({ + environmentId, + input: { + threadId, + itemId, + ...(params.revision === undefined ? {} : { revision: params.revision }), + }, + }), + ); + const item = detail.data?.item; + // One reference per app: the item is a new object on every refetch (a + // reconnect, for one), and a new reference would rebuild the app's host. + const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; + const [app, setApp] = useState(derived); + if (derived?.attachmentId !== app?.attachmentId) setApp(derived); + // The approval or question the agent waits on renders on the thread this + // modal covers, so the app steps aside for it. + const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId)); + const awaitingUser = + conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + useEffect(() => { + if ((awaitingUser || exitRequested) && focused) navigation.goBack(); + }, [awaitingUser, exitRequested, focused, navigation]); + + return ( + + + + {app?.server ?? "App"} + + + + + + + setSize({ + width: Math.round(event.nativeEvent.layout.width), + height: Math.round(event.nativeEvent.layout.height - insets.bottom), + }) + } + > + {app === undefined ? ( + detail.data === undefined ? null : ( + This app cannot be shown. + ) + ) : size.width > 0 ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx new file mode 100644 index 000000000000..119f5f946817 --- /dev/null +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -0,0 +1,611 @@ +import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import { + makeMcpAppHost, + McpAppHostRefusal, + mcpAppStyleVariables, + mcpResourceBytes, + type McpAppCallToolResult, + type McpAppHost, + type McpAppHostContext, +} from "@t3tools/client-runtime/mcp-apps"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, MessageId } from "@t3tools/contracts"; +import { + mcpAppAllowAttribute, + mcpAppFileName, + mcpAppReferencesEqual, + type McpAppReference, +} from "@t3tools/shared/mcpApp"; +import * as Predicate from "effect/Predicate"; +import Constants from "expo-constants"; +import { useNavigation } from "@react-navigation/native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; + +import { AppText as Text } from "../../components/AppText"; +import { shareGeneratedAttachment } from "../../lib/attachmentDownload"; +import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { uuidv4 } from "../../lib/uuid"; +import { useAssetUrlState } from "../../state/assets"; +import { useThreadShell } from "../../state/entities"; +import { mcpAppEnvironment } from "../../state/mcpApps"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useEnvironmentQuery } from "../../state/query"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +/** The feed reserves a fixed box for an app; a taller app scrolls inside it. */ +const MCP_APP_ROW_HEIGHT = 420; +const ROW_BOTTOM_MARGIN = 8; + +export function mcpAppRowHeight() { + return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN; +} + +// The WebView loads a tiny outer page that hosts the app in a real +// opaque-origin iframe, as web does, so the app's `window.parent` and the +// `event.source` of host replies are a real window, which the MCP Apps SDK +// requires. The outer page only relays: app โ†’ React Native, and host +// replies (injected as `__t3McpAppReceive(...)`) โ†’ app. +// +// Android exposes `ReactNativeWebView` to every frame, so a frame nested in +// the app could post to React Native directly. The outer page therefore wraps +// what it relays with a secret only it holds, and React Native drops anything +// else. The bridge belongs to the captured document: once the app frame loads +// a second time (it navigated itself), the outer page stops relaying and says +// so, rather than letting a page T3 never served pose as the app. This is not +// a confidentiality boundary: a frame can always navigate itself, so the app +// could carry anything it read out in a URL either way. +function outerDocument(src: string, allow: string, secret: string) { + const attribute = (value: string) => + value.replace(/&/g, "&").replace(/"/g, """).replace(/ + + +`; +} + +const commandFailure = (result: { + readonly cause: Parameters[0]["cause"]; +}) => { + const error = squashAtomCommandFailure(result); + return new McpAppHostRefusal( + error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", + ); +}; + +const confirm = (title: string, message: string, action: string) => + new Promise((resolve) => { + Alert.alert( + title, + message, + [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { text: action, onPress: () => resolve(true) }, + ], + // Android dismisses an open alert when the next one shows; a dismissed + // request is declined rather than left holding an in-flight slot. + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); + +/** Largest file an app may hand the user through `ui/download-file`. */ +const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; + +/** + * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as + * a fixed row of the thread feed, or full screen in its own modal screen. A + * WebView cannot move between the two without reloading, so each is its own + * view of the app: the inline one is torn down before full screen opens, and + * comes back when it closes. + */ +export function ThreadMcpApp(props: { + readonly environmentId: EnvironmentId; + /** The thread that produced the app, which its requests run against. */ + readonly threadId: ThreadId; + /** The thread on screen, which approved app messages are sent to. */ + readonly conversationThreadId: ThreadId; + readonly itemId: TurnItemId; + readonly revision: string; + readonly app: McpAppReference; + readonly width: number; + /** Full screen fills its screen; inline is the feed's fixed row. */ + readonly displayMode?: "inline" | "fullscreen"; + /** Full screen only: leaves it, back to the inline row. */ + readonly onExitFullscreen?: () => void; + /** Full screen only: the screen's height, which the app is told it fills. */ + readonly height?: number; +}) { + // One reference per app: a new object with the same content (a refetch, a + // rerender) must not rebuild the host of a document that is already live. + const [app, setApp] = useState(props.app); + if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app); + const fullscreen = props.displayMode === "fullscreen"; + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + // The app asked to be closed; the row falls back to a note that brings it back. + const [closed, setClosed] = useState(false); + // Full screen is a separate view of the app, so the inline one steps aside + // (and is torn down) while it is open, and comes back when the thread is + // shown again. + const [presentedFullscreen, setPresentedFullscreen] = useState(false); + // Counts the documents this row has shown on purpose (back from full screen, + // or reopened after closing); each gets its own view and host. + const [documentKey, setDocumentKeyState] = useState(0); + + const { themeId, themeAppearance, themeVariables, systemColorsActive } = + useAppearancePreferences(); + const theme = useMemo( + () => + mobileHtmlRenderTheme({ + themeId, + appearance: themeAppearance, + variables: themeVariables, + systemColors: systemColorsActive, + platform: Platform.OS, + }), + [themeId, themeAppearance, themeVariables, systemColorsActive], + ); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId: app.attachmentId, + fileName: mcpAppFileName(app), + mimeType: "text/html", + disposition: "inline" as const, + }), + [app], + ); + const asset = useAssetUrlState(props.environmentId, resource); + // The view keeps its first URL: a re-minted one would reload the app. + const [uri, setUri] = useState(null); + if (uri === null && asset._tag === "Success") setUri(asset.url); + const [loaded, setLoaded] = useState(false); + const [navigatedAway, setNavigatedAway] = useState(false); + // Bumped when the OS reclaims the web process: a fresh view, document and + // host, so the reloaded app is initialized and replayed again. Once only, so + // an app that keeps crashing its process is not reloaded forever. + const [generation, setGeneration] = useState(0); + const [crashed, setCrashed] = useState(false); + const restart = () => { + if (generation > 0) { + setCrashed(true); + return; + } + setLoaded(false); + // The first URL's token may have expired by now; the asset query keeps + // a refreshed one, which this new view can load. + if (asset._tag === "Success") setUri(asset.url); + setGeneration(1); + }; + // Minted per view, so only this view's outer page can speak for its app. + const [secret] = useState(uuidv4); + // A new document starts loading, is not yet one that navigated away, and + // loads the asset query's current URL: the first one's token may be gone. + const setDocumentKey = (next: (value: number) => number) => { + setLoaded(false); + setNavigatedAway(false); + if (asset._tag === "Success") setUri(asset.url); + setDocumentKeyState(next); + }; + // Back from full screen: the thread shows a fresh inline view of the app. + // The listener reads the latest helper, whose URL comes from this render. + const openNewDocument = useRef(setDocumentKey); + useEffect(() => { + openNewDocument.current = setDocumentKey; + }); + useEffect(() => { + if (!presentedFullscreen) return; + return navigation.addListener("focus", () => { + openNewDocument.current((value) => value + 1); + setPresentedFullscreen(false); + }); + }, [navigation, presentedFullscreen]); + + // The feed omits tool input and output; the app needs both. + const detail = useEnvironmentQuery( + orchestrationEnvironment.turnItem({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + }), + ); + const storedItem = detail.data?.item; + const toolCall = useMemo(() => { + if (storedItem?.type !== "dynamic_tool") return undefined; + const output = storedItem.output as { readonly result?: unknown } | undefined; + return { + arguments: storedItem.input, + result: output?.result as McpAppCallToolResult | undefined, + }; + }, [storedItem]); + + const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); + const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); + const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); + const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { + reportFailure: false, + }); + // The tool's definition, which the app receives as `toolInfo` at initialize. + const [toolDefinition, setToolDefinition] = useState(undefined); + // Read by the host on every message, so it always sees current values + // without being rebuilt (which would drop the app's session). + // The approval or question the agent waits on renders on this thread, and a + // full-screen app would cover it. + const conversation = useThreadShell( + scopeThreadRef(props.environmentId, props.conversationThreadId), + ); + const awaitingUser = + conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + const live = { + awaitingUser, + theme, + props, + callTool, + toolInfo, + readResource, + updateModelContext, + insets, + toolDefinition, + navigation, + }; + const latest = useRef(live); + useEffect(() => { + latest.current = live; + }); + useEffect(() => { + let cancelled = false; + void latest.current + .toolInfo({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, + }) + .then((info) => { + if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { + setToolDefinition(info.value.tool); + } + }); + return () => { + cancelled = true; + }; + }, [props.environmentId, props.threadId, props.itemId, app.tool]); + const webView = useRef>(null); + const hostRef = useRef(null); + + // One host per loaded document. + useEffect(() => { + if (uri === null) return; + const scope = () => { + const { environmentId, threadId, itemId } = latest.current.props; + return { environmentId, input: { threadId, itemId } }; + }; + const hostContext = (): McpAppHostContext => { + const current = latest.current; + const isFullscreen = current.props.displayMode === "fullscreen"; + return { + theme: current.theme.appearance, + styles: { variables: mcpAppStyleVariables(current.theme.variables) }, + displayMode: isFullscreen ? "fullscreen" : "inline", + availableDisplayModes: ["inline", "fullscreen"], + // Both are fixed boxes, so the app is told its exact size. + containerDimensions: { + width: current.props.width, + height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT, + }, + platform: "mobile", + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, + deviceCapabilities: { touch: true, hover: false }, + // The full-screen modal keeps the top and bottom insets for its + // header and the home indicator; only the sides reach the app. + safeAreaInsets: isFullscreen + ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } + : { top: 0, right: 0, bottom: 0, left: 0 }, + ...(current.toolDefinition === undefined + ? {} + : { toolInfo: { tool: current.toolDefinition } }), + }; + }; + const next = makeMcpAppHost({ + app, + hostVersion: Constants.expoConfig?.version ?? "0.0.0", + post: (message) => + webView.current?.injectJavaScript( + `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, + ), + hostContext, + callTool: async ({ name, arguments: args }) => { + const { environmentId, input } = scope(); + const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); + if (info._tag !== "Success") throw commandFailure(info); + if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); + if ( + !info.value.readOnly && + !(await confirm( + `Allow ${app.server} to run ${info.value.title ?? name}?`, + JSON.stringify(args, null, 2), + "Allow", + )) + ) { + throw new McpAppHostRefusal("Declined by the user."); + } + const result = await latest.current.callTool({ + environmentId, + input: { ...input, name, arguments: args }, + }); + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + readResource: async ({ uri: resourceUri }) => { + const { environmentId, input } = scope(); + const result = await latest.current.readResource({ + environmentId, + input: { ...input, uri: resourceUri }, + }); + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + openLink: async (url) => { + // A WebView cannot tell whether the reader just tapped the app, so it + // asks, rather than letting an app leave T3 on a timer. + if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + if (!(await tryOpenExternalUrl(url, "mcp-app"))) { + throw new McpAppHostRefusal("The link could not be opened."); + } + }, + sendMessage: async (text) => { + if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + // Through the outbox like a typed message, so it survives a dropped + // connection; the thread's own settings fill in when it sends. + await enqueueThreadOutboxMessage({ + environmentId: latest.current.props.environmentId, + threadId: latest.current.props.conversationThreadId, + messageId: MessageId.make(uuidv4()), + commandId: CommandId.make(uuidv4()), + text, + attachments: [], + dispatchMode: "queue", + createdAt: new Date().toISOString(), + }); + }, + updateModelContext: async (context) => { + const { environmentId, input } = scope(); + const result = await latest.current.updateModelContext({ + environmentId, + input: { + ...input, + ...context, + conversationThreadId: latest.current.props.conversationThreadId, + }, + }); + if (result._tag !== "Success") throw commandFailure(result); + }, + requestDisplayMode: async (mode) => { + const current = latest.current.props; + // Full screen would cover the approval or question the agent waits + // on, or a screen the user moved to. + if ( + mode === "fullscreen" && + (latest.current.awaitingUser || !latest.current.navigation.isFocused()) + ) { + return current.displayMode ?? "inline"; + } + if (mode === "fullscreen" && current.displayMode !== "fullscreen") { + // The inline view is torn down by the switch (this row unmounts + // while the modal covers it); the modal opens a fresh view. + // The inline view steps aside for the modal; it gets its teardown first. + await hostRef.current?.teardown(); + // The wait may have let an approval arrive, or the user moved to + // another screen; either way the view reloads inline. + if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) { + openNewDocument.current((value) => value + 1); + return "inline"; + } + setPresentedFullscreen(true); + latest.current.navigation.navigate("ThreadMcpApp", { + environmentId: String(current.environmentId), + threadId: String(current.threadId), + conversationThreadId: String(current.conversationThreadId), + itemId: String(current.itemId), + revision: current.revision, + }); + return "fullscreen"; + } + if (mode === "inline" && current.displayMode === "fullscreen") { + await hostRef.current?.teardown(); + current.onExitFullscreen?.(); + return "inline"; + } + return current.displayMode ?? "inline"; + }, + downloadFile: async (files) => { + const names = files.map((file) => file.name).join(", "); + if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + for (const file of files) { + // A linked file is read from the app's own server, like its other reads. + let bytes: Uint8Array | undefined; + let mimeType = file.mimeType ?? "application/octet-stream"; + if (file._tag === "embedded") { + bytes = file.bytes; + } else { + const { environmentId, input } = scope(); + const read = await latest.current.readResource({ + environmentId, + input: { ...input, uri: file.uri }, + }); + if (read._tag !== "Success") throw commandFailure(read); + const content = read.value.contents[0]; + bytes = mcpResourceBytes(content); + const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; + if (typeof declared === "string") mimeType = declared; + } + if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); + if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { + throw new McpAppHostRefusal(`${file.name} is too large to save.`); + } + const shared = await shareGeneratedAttachment({ + bytes, + attachment: { name: file.name, mimeType }, + signal: new AbortController().signal, + }); + if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); + } + }, + onRequestTeardown: () => { + const current = latest.current.props; + if (current.displayMode === "fullscreen") { + void hostRef.current?.teardown().then(() => current.onExitFullscreen?.()); + } else { + void hostRef.current?.teardown().then(() => setClosed(true)); + } + }, + // Both modes are fixed boxes, so the app's own height only decides + // whether it scrolls inside one. + onSizeChanged: () => undefined, + }); + hostRef.current = next; + return () => { + // An unmount cannot wait: the request goes out before the view does. + void next.teardown(); + hostRef.current = null; + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host. + }, [uri, app, generation, documentKey]); + + // The host reads the context through `latest`; these only say when to resend. + useEffect(() => { + hostRef.current?.updateHostContext(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. + }, [theme, props.width, props.height, insets, toolDefinition]); + // A new document gets a new host, which needs the call again. + useEffect(() => { + if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. + }, [toolCall, uri, generation, documentKey]); + + const source = useMemo( + () => + uri === null + ? null + : { html: outerDocument(uri, mcpAppAllowAttribute(app.permissions), secret) }, + [uri, app.permissions, secret], + ); + + if (presentedFullscreen) { + return ( + + + The {app.server} app is open full screen + + + ); + } + + if (closed) { + return ( + + The {app.server} app was closed + { + setDocumentKey((value) => value + 1); + setClosed(false); + }} + > + Show app + + + ); + } + + return ( + + {navigatedAway ? ( + + + The {app.server} app left its page and was stopped + + + ) : uri !== null && !crashed ? ( + + key={`${documentKey}:${generation}`} + ref={webView} + onContentProcessDidTerminate={restart} + onRenderProcessGone={restart} + source={source!} + accessibilityLabel={`${app.server} app`} + style={{ flex: 1, backgroundColor: "transparent" }} + nestedScrollEnabled + originWhitelist={["*"]} + // Only the outer page loads at the top; the app opens links through + // the bridge. Android reports every navigation as top-frame, frames + // the app nests included, so it has no gate to apply: there the + // outer page never navigates, and the app frame's sandbox already + // forbids top navigation and popups. + onShouldStartLoadWithRequest={(request) => + Platform.OS === "android" || + request.isTopFrame === false || + request.url === "about:blank" + } + setSupportMultipleWindows={false} + onLoadEnd={() => setLoaded(true)} + // The outer page is inline HTML, so these report a broken view, not + // an app page; the row shows the failure instead of a blank box. + onError={() => setCrashed(true)} + onHttpError={() => setCrashed(true)} + onMessage={(event: WebViewMessageEvent) => { + let envelope: unknown; + try { + envelope = JSON.parse(event.nativeEvent.data); + } catch { + return; // Not JSON: not a bridge message. + } + if (!Predicate.isObject(envelope) || envelope.secret !== secret) return; + const message = envelope.message; + if (Predicate.isObject(message) && message.t3 === "navigated") { + hostRef.current?.dispose(); + setNavigatedAway(true); + return; + } + hostRef.current?.receive(message); + }} + /> + ) : asset._tag === "Failure" || crashed ? ( + + Unable to load the {app.server} app + + ) : null} + {uri !== null && !loaded && !crashed && !navigatedAway ? ( + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4d81c6f07063..c18874b1cb03 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -188,6 +188,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; +import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, @@ -1597,6 +1598,20 @@ function renderFeedEntry( ); } + if (entry.type === "mcp-app") { + return ( + + ); + } + if (entry.type === "work-toggle") { return ( { + // An unavailable share sheet is a refusal for the caller, not a failure. + const sharing = await availableSharing(input.signal).catch(() => null); + if (sharing === null) return false; + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) return false; + cached.file.write(input.bytes); + await cached.preview.share(input.signal); + return true; + } finally { + cached.preview.dispose(); + } +} diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index 0963044b9ffb..f4feb41dcc66 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([ "pull-request", "provider-auth", "html-render", + "mcp-app", ]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 16be0186e68b..6743876a2fe7 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2511,3 +2511,35 @@ describe("html renders", () => { } }); }); + +describe("MCP apps", () => { + const app = { + attachmentId: "attachment-app", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }; + const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({ + ...base("item-app", "2026-06-20T00:00:02.500Z", 2), + status, + type: "dynamic_tool", + toolName: "weather.get_weather", + input: { city: "Oslo" }, + output: { t3McpApp: app, result: { content: [] } }, + }); + const feed = (status: OrchestrationV2TurnItem["status"]) => + buildThreadFeed([ + projected(userMessage(), 0), + projected(appCall(status), 1), + projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2), + ]); + + it("hosts a completed app in place of its tool row, owned by its source item", () => { + const entry = feed("completed").find((candidate) => candidate.type === "mcp-app"); + expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId }); + }); + + it("keeps a running app call an ordinary work row", () => { + expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 4038fa4982c7..d88b1dba2ea2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -8,6 +8,7 @@ import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subage import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { + turnItemDetailRevision, turnItemHasDetail, turnItemNeedsDetailFetch, } from "@t3tools/client-runtime/work-log/item-detail"; @@ -48,6 +49,7 @@ import type { OrchestrationV2UserMessageInputIntent, RunAttemptId, ScheduledTaskId, + TurnItemId, } from "@t3tools/contracts"; import { RunId, ThreadId } from "@t3tools/contracts"; import { @@ -59,7 +61,12 @@ import { } from "@t3tools/shared/toolActivity"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; -import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import type { McpAppReference } from "@t3tools/shared/mcpApp"; +import { + compactDynamicToolOutput, + htmlRenderFromToolItem, + mcpAppFromToolItem, +} from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; export type PendingApproval = ThreadPendingApproval; @@ -165,12 +172,24 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly runId: RunId | null; readonly render: HtmlRenderReference; + } + | { + /** An MCP App a completed tool call captured, hosted in place of its work row. */ + readonly type: "mcp-app"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + /** The thread and item that own the app; a fork's inherited app is its source's. */ + readonly sourceThreadId: ThreadId; + readonly itemId: TurnItemId; + readonly revision: string; + readonly app: McpAppReference; }; export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; type ThreadFeedEntryContent = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -1049,7 +1068,9 @@ function deriveThreadFeedRunFolds( const runId = entry.type === "message" && entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) - : entry.type === "activity-group" || entry.type === "html-render" + : entry.type === "activity-group" || + entry.type === "html-render" || + entry.type === "mcp-app" ? (entry.runId ?? runlessKey) : null; if (!runId) continue; @@ -1102,6 +1123,7 @@ function deriveThreadFeedRunFolds( entry.id !== firstAssistantId && entry.id !== terminalAssistantId && entry.type !== "html-render" && + entry.type !== "mcp-app" && !( entry.type === "activity-group" && entry.activities.some( @@ -1750,6 +1772,25 @@ export function buildThreadFeed( entries.push(entry); continue; } + const app = + item.type === "dynamic_tool" && item.status === "completed" + ? mcpAppFromToolItem(item) + : undefined; + if (app) { + const entry: RawThreadFeedEntry = { + type: "mcp-app", + id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, + createdAt, + runId: item.runId, + sourceThreadId: row.sourceThreadId, + itemId: row.sourceItemId, + revision: turnItemDetailRevision(item), + app, + }; + projectedEntriesCache.set(row, { attemptId, entry }); + entries.push(entry); + continue; + } if (item.type === "user_message" || item.type === "assistant_message") { const updatedAt = DateTime.formatIso(item.updatedAt); const entry: RawThreadFeedEntry = { diff --git a/apps/mobile/src/state/mcpApps.ts b/apps/mobile/src/state/mcpApps.ts new file mode 100644 index 000000000000..84dafef26313 --- /dev/null +++ b/apps/mobile/src/state/mcpApps.ts @@ -0,0 +1,5 @@ +import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index 936a7e8b4e06..4343aec87ba1 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -72,6 +72,7 @@ const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS = const CODEX_CLIENT_CAPABILITIES = { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], + extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } }, } as const; const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna"; const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL; diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 1a952d1a6344..a8b7927b624d 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -243,4 +243,24 @@ describe("threadHtmlRenderAttachmentIds", () => { ]), ).toEqual([own]); }); + + it("includes captured MCP App documents from any tool", () => { + const app = createAttachmentId("thread-a", "html")!; + expect( + threadHtmlRenderAttachmentIds("thread-a", [ + { + toolName: "weather.get_weather", + output: { + t3McpApp: { + attachmentId: app, + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }, + result: { content: [] }, + }, + }, + ]), + ).toEqual([app]); + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index c959e8f3a2d0..0927b03761f7 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -4,7 +4,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; -import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { normalizeAttachmentRelativePath, @@ -116,8 +116,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string } /** - * Attachments a thread's `html_render` tool calls published. Only ids minted - * for this thread count, so deleting a fork never removes its source's pages. + * Attachments a thread's tool calls published: `html_render` pages and + * captured MCP App documents. Only ids minted for this thread count, so + * deleting a fork never removes its source's pages. */ export function threadHtmlRenderAttachmentIds( threadId: string, @@ -126,7 +127,8 @@ export function threadHtmlRenderAttachmentIds( const segment = toSafeThreadAttachmentSegment(threadId); if (segment === null) return []; return Array.from(items).flatMap((item) => { - const attachmentId = htmlRenderFromToolItem(item)?.attachmentId; + const attachmentId = + htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId; return attachmentId !== undefined && parseThreadSegmentFromAttachmentId(attachmentId) === segment ? [attachmentId] diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index ee0d24e44d5a..d434ca86a3a5 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -155,6 +155,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, + // An app's tool calls can change things on its server, like a user action. + [WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope, + [WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope, + [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope, + [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/mcpApps/McpAppModelContext.test.ts b/apps/server/src/mcpApps/McpAppModelContext.test.ts new file mode 100644 index 000000000000..f5fd7f331456 --- /dev/null +++ b/apps/server/src/mcpApps/McpAppModelContext.test.ts @@ -0,0 +1,68 @@ +import { ThreadId, TurnItemId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; + +const database = NodeSqliteClient.layer({ filename: ":memory:" }); +const layer = it.layer( + Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))), +); + +const threadId = ThreadId.make("thread-context"); +const forkId = ThreadId.make("thread-context-fork"); + +/** An app's tool call in the projection, in a run with the given status. */ +const insertAppItem = (itemId: string, runId: string, runStatus: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO orchestration_v2_projection_runs + (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) + VALUES (${runId}, ${threadId}, ${runId.length}, 'codex', ${runStatus}, '2026-01-01', '{}') + `; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items + (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json) + VALUES (${itemId}, ${threadId}, ${runId}, 0, 'dynamic_tool', 'completed', '2026-01-01', '{}') + `; + }); + +layer("McpAppModelContext", (it) => { + it.effect("sends only apps still in the thread's history, including a fork's", () => + Effect.gen(function* () { + yield* runMigrations(); + const store = yield* McpAppModelContext.McpAppModelContext; + yield* insertAppItem("item-kept", "run-a", "completed"); + yield* insertAppItem("item-rolled-back", "run-bb", "rolled_back"); + const set = (thread: ThreadId, itemId: string, text: string) => + store.set({ + threadId: thread, + itemId: TurnItemId.make(itemId), + server: "todos", + tool: "list_todos", + text, + }); + yield* set(threadId, "item-kept", "kept"); + yield* set(threadId, "item-rolled-back", "from a rolled-back run"); + yield* set(threadId, "item-deleted", "from an item that is gone"); + // A fork showing the source thread's app keeps its own context, even + // after the source rolls that app's run back. + yield* set(forkId, "item-kept", "from the fork"); + yield* set(forkId, "item-rolled-back", "inherited before the rollback"); + + const texts = (thread: ThreadId) => + store.forThread(thread).pipe(Effect.map((entries) => entries.map((entry) => entry.text))); + assert.deepEqual(yield* texts(threadId), ["kept"]); + assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]); + + // Clearing removes the app's context. + yield* set(threadId, "item-kept", ""); + assert.deepEqual(yield* texts(threadId), []); + }), + ); +}); diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts new file mode 100644 index 000000000000..dcdd5cd786bb --- /dev/null +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -0,0 +1,105 @@ +import type { ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** Largest context one app may keep for the agent, in UTF-8 bytes. */ +export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024; + +export class McpAppModelContextError extends Schema.TaggedError()( + "McpAppModelContextError", + { cause: Schema.optional(Schema.Defect()) }, +) {} + +export interface McpAppModelContextEntry { + readonly itemId: string; + readonly server: string; + readonly tool: string; + readonly text: string; +} + +/** + * What each MCP App told the agent through `ui/update-model-context`. The spec + * replaces an app's context on every update, so there is one entry per app (its + * tool call); the thread's next turn carries the entries to the provider. + */ +export class McpAppModelContext extends Context.Service< + McpAppModelContext, + { + /** Replaces the app's context; empty text removes it. */ + readonly set: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + readonly server: string; + readonly tool: string; + readonly text: string; + }) => Effect.Effect; + readonly forThread: ( + threadId: ThreadId, + ) => Effect.Effect, McpAppModelContextError>; + } +>()("t3/mcpApps/McpAppModelContext") {} + +export const layer = Layer.effect( + McpAppModelContext, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const fail = (cause: unknown) => new McpAppModelContextError({ cause }); + + const set = Effect.fn("McpAppModelContext.set")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + readonly server: string; + readonly tool: string; + readonly text: string; + }) { + if (input.text.trim() === "") { + yield* sql` + DELETE FROM mcp_app_model_context + WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId} + `.pipe(Effect.mapError(fail)); + return; + } + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at) + VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt}) + ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at + `.pipe(Effect.mapError(fail)); + }); + + const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { + // Only apps still in the thread's history count: an app whose item is + // gone, or whose run this thread rolled back, stops informing the agent. + // The item can live in a thread this one was forked from, so it is + // matched by id alone; that thread rolling the run back later leaves the + // fork's copy in place. + return yield* sql` + SELECT context.item_id AS "itemId", context.server, context.tool, context.text + FROM mcp_app_model_context AS context + JOIN orchestration_v2_projection_turn_items AS item + ON item.turn_item_id = context.item_id + LEFT JOIN orchestration_v2_projection_runs AS run + ON run.run_id = item.run_id + WHERE context.thread_id = ${threadId} + AND ( + run.status IS NULL + OR run.status <> 'rolled_back' + OR run.thread_id <> context.thread_id + ) + ORDER BY context.updated_at, context.item_id + `.pipe(Effect.mapError(fail)); + }); + + return McpAppModelContext.of({ set, forThread }); + }), +); + +/** No app context: for code paths and tests with no MCP Apps. */ +export const layerEmpty = Layer.succeed( + McpAppModelContext, + McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }), +); diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts new file mode 100644 index 000000000000..1fdc8e0c50ca --- /dev/null +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -0,0 +1,284 @@ +import { + ProviderSessionId, + ProviderThreadId, + ThreadId, + TurnItemId, + type McpAppRequestError, + type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import type { + ProviderAdapterV2McpApps, + ProviderAdapterV2SessionRuntime, +} from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; +import * as McpAppRequests from "./McpAppRequests.ts"; + +const threadId = ThreadId.make("thread-app"); +const itemId = TurnItemId.make("item-app"); +const providerThreadId = ProviderThreadId.make("provider-thread-app"); +const providerSessionId = ProviderSessionId.make("provider-session-app"); +const forkId = ThreadId.make("thread-fork"); +const unrelatedId = ThreadId.make("thread-unrelated"); +// A chain of forks deeper than any fixed walk limit: deep-70 โ†’ โ€ฆ โ†’ deep-1 โ†’ app. +const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`); +const deepParent = (id: ThreadId): ThreadId | null => { + const match = /^thread-deep-(\d+)$/.exec(id); + if (match === null) return null; + const depth = Number(match[1]); + return depth === 1 ? threadId : deepForkId(depth - 1); +}; + +const appItem = (output: unknown): OrchestrationV2TurnItem => ({ + id: itemId, + threadId, + runId: null, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), + type: "dynamic_tool", + toolName: "weather.get_weather", + input: {}, + output, +}); + +const app = { + attachmentId: "thread-app-00000000-0000-0000-0000-000000000000-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", +}; + +/** Each app's stored context, keyed by item, as the real store keeps it. */ +const storedContext = new Map(); + +function makeLayer(input: { + readonly item: OrchestrationV2TurnItem | null; + readonly mcpApps?: ProviderAdapterV2McpApps; + readonly live?: boolean; +}) { + return McpAppRequests.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(McpAppModelContext.McpAppModelContext)({ + set: (entry) => + Effect.sync(() => { + const key = `${entry.threadId}/${entry.itemId}`; + if (entry.text.trim() === "") storedContext.delete(key); + else storedContext.set(key, entry.text); + }), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getTurnItem: () => Effect.succeed(input.item), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (id: ThreadId) => + Effect.succeed({ + // A fork of the app's thread, and an unrelated thread. + thread: { + lineage: + deepParent(id) !== null + ? { + parentThreadId: deepParent(id), + relationshipToParent: "fork", + rootThreadId: threadId, + } + : id === forkId + ? { + parentThreadId: threadId, + relationshipToParent: "fork", + rootThreadId: threadId, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + }, + providerThreads: [ + { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread, + ], + } as never), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: () => + Effect.succeed( + input.live === false + ? Option.none() + : Option.some( + (input.mcpApps === undefined + ? {} + : { mcpApps: input.mcpApps }) as ProviderAdapterV2SessionRuntime, + ), + ), + }), + ), + ), + ); +} + +const reason = (effect: Effect.Effect) => + effect.pipe( + Effect.flip, + Effect.map((error) => error.reason), + ); + +describe("McpAppRequests", () => { + const calls: Array<{ server: string; tool: string }> = []; + const mcpApps: ProviderAdapterV2McpApps = { + listTools: () => + Effect.succeed([ + { name: "refresh", annotations: { readOnlyHint: true } }, + { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, + ]), + callTool: (input) => + Effect.sync(() => { + calls.push({ server: input.server, tool: input.tool }); + return { content: [{ type: "text", text: "ok" }] }; + }), + readResource: () => Effect.succeed({ contents: [] }), + }; + + it.effect("calls tools on the app's own server and reports their read-only hint", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const result = yield* requests.callTool({ + threadId, + itemId, + name: "refresh", + arguments: {}, + }); + assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] }); + assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" }); + assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), { + callable: true, + readOnly: true, + tool: { name: "refresh", annotations: { readOnlyHint: true } }, + }); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), + ); + + it.effect("refuses tools hidden from apps or missing from the server", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const before = calls.length; + for (const name of ["model_only", "unknown"]) { + assert.equal( + yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })), + "tool-not-callable", + ); + } + assert.equal(calls.length, before); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), + ); + + it.effect("keeps each app's latest model context, without a live session", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const own = { threadId, itemId, conversationThreadId: threadId }; + // The app's text is stored as sent, whitespace included. + yield* requests.updateModelContext({ + ...own, + content: [{ type: "text", text: " indented value " }], + }); + assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value "); + // An update replaces the app's context rather than adding to it. + yield* requests.updateModelContext({ + ...own, + content: [{ type: "text", text: "Filtered to overdue" }], + structuredContent: { filter: "overdue" }, + }); + assert.equal( + storedContext.get(`${threadId}/${itemId}`), + 'Filtered to overdue\n{"filter":"overdue"}', + ); + assert.equal( + yield* reason( + requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }), + ), + "unsupported-content", + ); + // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB. + assert.equal( + yield* reason( + requests.updateModelContext({ + ...own, + content: [{ type: "text", text: "่กจ".repeat(6000) }], + }), + ), + "request-failed", + ); + yield* requests.updateModelContext(own); + assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), + ); + + it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const content = [{ type: "text", text: "Seen from the fork" }]; + yield* requests.updateModelContext({ + threadId, + itemId, + conversationThreadId: forkId, + content, + }); + assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork"); + assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); + assert.equal( + yield* reason( + requests.updateModelContext({ + threadId, + itemId, + conversationThreadId: unrelatedId, + content, + }), + ), + "not-an-app", + ); + // However many forks deep, a descendant still counts. + yield* requests.updateModelContext({ + threadId, + itemId, + conversationThreadId: deepForkId(70), + content, + }); + assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`)); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), + ); + + it.effect("reports why a request cannot run", () => + Effect.gen(function* () { + const run = (layer: ReturnType) => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" })); + }).pipe(Effect.provide(layer)); + assert.equal( + yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })), + "not-an-app", + ); + assert.equal( + yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })), + "session-stopped", + ); + assert.equal( + yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })), + "provider-unsupported", + ); + }), + ); +}); diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts new file mode 100644 index 000000000000..c95606ccd215 --- /dev/null +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -0,0 +1,240 @@ +import { + McpAppRequestError, + type McpAppCallToolInput, + type McpAppCallToolResult, + type McpAppReadResourceInput, + type McpAppReadResourceResult, + type McpAppToolInfo, + type McpAppToolInfoInput, + type McpAppUpdateModelContextInput, + type OrchestrationV2AppThread, + type ThreadId, + type TurnItemId, +} from "@t3tools/contracts"; +import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import type { + ProviderAdapterV2McpApps, + ProviderAdapterV2McpTool, +} from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; + +/** + * Requests an MCP App makes of its own MCP server. The app is resolved from the + * tool call that produced it, so it reaches only that server, and each request + * runs through the thread's live provider session, whose MCP client owns the + * connection and its credentials. A stopped session is reported, never + * started: an app view must not spend a provider turn. + */ +export class McpAppRequests extends Context.Service< + McpAppRequests, + { + readonly callTool: ( + input: McpAppCallToolInput, + ) => Effect.Effect; + readonly toolInfo: ( + input: McpAppToolInfoInput, + ) => Effect.Effect; + readonly readResource: ( + input: McpAppReadResourceInput, + ) => Effect.Effect; + readonly updateModelContext: ( + input: McpAppUpdateModelContextInput, + ) => Effect.Effect; + } +>()("t3/mcpApps/McpAppRequests") {} + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const utf8 = new TextEncoder(); + +const readOnlyHint = (tool: ProviderAdapterV2McpTool) => + Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; + +const toolTitle = (tool: ProviderAdapterV2McpTool) => + Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" + ? tool.annotations.title + : undefined; + +const make = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const modelContext = yield* McpAppModelContext.McpAppModelContext; + + const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => + new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); + + /** The app a tool call produced, read from the stored item. */ + const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) { + // The stored item, not the wire projection, so the reference is intact. + const item = yield* orchestrator + .getTurnItem({ threadId: input.threadId, itemId: input.itemId }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const app: McpAppReference | undefined = + item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; + if (item === null || app === undefined || item.providerThreadId === null) { + return yield* fail(input.threadId, "not-an-app"); + } + return { app, providerThreadId: item.providerThreadId }; + }); + + /** The app, its provider thread, and the live session's MCP Apps operations. */ + const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) { + const { app, providerThreadId } = yield* resolveApp(input); + const projection = yield* threadManagement + .getThreadRecords(input.threadId, ["providerThreads"]) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === providerThreadId, + ); + if (providerThread?.providerSessionId == null) { + return yield* fail(input.threadId, "session-stopped"); + } + const runtime = Option.getOrUndefined( + yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))), + ); + if (runtime === undefined) return yield* fail(input.threadId, "session-stopped"); + const mcpApps: ProviderAdapterV2McpApps | undefined = runtime.mcpApps; + if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported"); + return { app, providerThread, mcpApps }; + }); + + const findTool = Effect.fn("McpAppRequests.findTool")(function* ( + threadId: ThreadId, + resolved: Effect.Success>, + name: string, + ) { + const tools = yield* resolved.mcpApps + .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) + .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + return tools.find((tool) => tool.name === name); + }); + + const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) { + const resolved = yield* resolve(input); + const tool = yield* findTool(input.threadId, resolved, input.name); + const title = tool === undefined ? undefined : toolTitle(tool); + return { + callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta), + readOnly: tool !== undefined && readOnlyHint(tool), + ...(title === undefined ? {} : { title }), + ...(tool === undefined ? {} : { tool }), + } satisfies McpAppToolInfo; + }); + + const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { + const resolved = yield* resolve(input); + // The spec forbids apps calling tools hidden from them; checked here too, + // since a client's own check is only a courtesy to its user. + const tool = yield* findTool(input.threadId, resolved, input.name); + if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { + return yield* fail(input.threadId, "tool-not-callable"); + } + return yield* resolved.mcpApps + .callTool({ + providerThread: resolved.providerThread, + server: resolved.app.server, + tool: input.name, + arguments: input.arguments, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + const readResource = Effect.fn("McpAppRequests.readResource")(function* ( + input: McpAppReadResourceInput, + ) { + const resolved = yield* resolve(input); + return yield* resolved.mcpApps + .readResource({ + providerThread: resolved.providerThread, + server: resolved.app.server, + uri: input.uri, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + // Context is state for the agent's next turn, so it needs the app but not a + // live session. Text blocks are kept as written and structured content as + // JSON; other content kinds are not declared by the host. + /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */ + const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( + threadId: ThreadId, + ancestorId: ThreadId, + ) { + // Walks fork links until the chain ends; a thread seen twice means a + // corrupt cycle, which proves nothing. + const seen = new Set(); + let current: ThreadId | null = threadId; + while (current !== null && !seen.has(current)) { + if (current === ancestorId) return true; + seen.add(current); + const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement + .getThreadRecords(current, []) + .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + const { lineage } = records.thread; + current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null; + } + return false; + }); + + const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* ( + input: McpAppUpdateModelContextInput, + ) { + const { app } = yield* resolveApp(input); + // Context belongs to the conversation on screen, which only shows this app + // when it is the app's own thread or one forked from it. + if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { + return yield* fail(input.threadId, "not-an-app"); + } + const texts: Array = []; + for (const block of input.content ?? []) { + if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") { + return yield* fail(input.threadId, "unsupported-content"); + } + texts.push(block.text); + } + if (input.structuredContent !== undefined) { + texts.push(encodeJson(input.structuredContent)); + } + // Kept as sent; blank text clears the app's context in the store. + const text = texts.join("\n"); + if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) { + return yield* fail( + input.threadId, + "request-failed", + new Error("Model context is too large."), + ); + } + yield* modelContext + .set({ + threadId: input.conversationThreadId, + itemId: input.itemId, + server: app.server, + tool: app.tool, + text, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext }); +}); + +export const layer = Layer.effect(McpAppRequests, make); diff --git a/apps/server/src/mcpApps/McpAppSnapshot.ts b/apps/server/src/mcpApps/McpAppSnapshot.ts new file mode 100644 index 000000000000..12a2a6378401 --- /dev/null +++ b/apps/server/src/mcpApps/McpAppSnapshot.ts @@ -0,0 +1,108 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { + injectMcpAppCsp, + MCP_APP_MAX_HTML_BYTES, + MCP_APP_MIME_TYPE, + MCP_APP_RESOURCE_SCHEME, + readMcpAppCsp, + readMcpAppPermissions, + type McpAppReference, +} from "@t3tools/shared/mcpApp"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Predicate from "effect/Predicate"; + +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import { createAttachmentId } from "../attachmentStore.ts"; + +const encoder = new TextEncoder(); + +/** + * The HTML document in a `resources/read` result, with the `_meta.ui` the + * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is + * base64 per MCP. A null document is one too large to store. + */ +function readMcpAppDocument( + contents: ReadonlyArray, + uri: string, +): { readonly html: string | null; readonly meta: unknown } | undefined { + for (const content of contents) { + if (!Predicate.isObject(content)) continue; + if (content.uri !== undefined && content.uri !== uri) continue; + const mimeType = typeof content.mimeType === "string" ? content.mimeType : ""; + if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue; + // Sizes are checked before decoding, so an oversized resource is + // refused without first being copied: a string's UTF-8 form is at least + // its length, and base64 decodes to three quarters of its own. + const html = + typeof content.text === "string" + ? content.text.length > MCP_APP_MAX_HTML_BYTES + ? null + : content.text + : typeof content.blob === "string" + ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4 + ? null + : Buffer.from(content.blob, "base64").toString("utf8") + : undefined; + if (html === null) return { html: null, meta: undefined }; + if (html === undefined || html.trim() === "") continue; + const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined; + return { html, meta }; + } + return undefined; +} + +/** + * Stores an app's document as a thread attachment and returns the reference + * clients host it from. Saving a snapshot keeps the app viewable after the + * provider exits; interaction still goes through a live provider session. + * Undefined when the resource is not an MCP App document or is too large. + */ +export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: { + readonly attachmentsDir: string; + readonly threadId: ThreadId; + readonly server: string; + readonly tool: string; + readonly resourceUri: string; + readonly contents: ReadonlyArray; +}) { + if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined; + const document = readMcpAppDocument(input.contents, input.resourceUri); + if (document === undefined) return undefined; + if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) { + yield* Effect.logWarning("MCP app document exceeds the size limit.", { + server: input.server, + resourceUri: input.resourceUri, + }); + return undefined; + } + // The `-html` suffix makes the asset route serve it as a sandboxed document. + const attachmentId = createAttachmentId(input.threadId, "html"); + const filePath = + attachmentId === null + ? null + : resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${attachmentId}.html`, + }); + if (attachmentId === null || filePath === null) return undefined; + const html = document.html; + const ui = Predicate.isObject(document.meta) ? document.meta : {}; + const csp = readMcpAppCsp(ui.csp); + const fileSystem = yield* FileSystem.FileSystem; + // Only the returned reference lets thread deletion find the document, so a + // write that fails or is interrupted removes it. + yield* fileSystem + .writeFileString(filePath, injectMcpAppCsp(html, csp)) + .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore))); + const permissions = readMcpAppPermissions(ui.permissions); + return { + attachmentId, + server: input.server, + tool: input.tool, + resourceUri: input.resourceUri, + ...(csp === undefined ? {} : { csp }), + ...(permissions === undefined ? {} : { permissions }), + ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}), + } satisfies McpAppReference; +}); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index f0f51f3d969a..d01c7812bc1a 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -142,6 +142,10 @@ const RPC_AGGREGATES = { [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", [WS_METHODS.attachmentsDelete]: "workspace", [WS_METHODS.providerUploadFeedback]: "provider", + [WS_METHODS.mcpAppsCallTool]: "provider", + [WS_METHODS.mcpAppsToolInfo]: "provider", + [WS_METHODS.mcpAppsUpdateModelContext]: "provider", + [WS_METHODS.mcpAppsReadResource]: "provider", [WS_METHODS.subscribeVcsStatus]: "vcs", [WS_METHODS.subscribeWorktreeSetup]: "vcs", [WS_METHODS.worktreeSetupCancel]: "vcs", diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 63f0cbdae66b..602052f08ea7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -18,6 +18,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type OrchestrationV2TurnItem, ProjectId, ProviderInstanceId, ProviderSessionId, @@ -40,6 +41,8 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; +import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; +import { resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -463,6 +466,38 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); + it.effect("sends MCP app model context as untrusted Codex context", () => + Effect.gen(function* () { + const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; + const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + const appContext = [{ key: "mcp_app_todos_list_todos_item-1", text: "Filtered to overdue" }]; + const alone = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-app-context", + codexInput: [{ type: "text", text: "what's on my list?" }], + runtimePolicy: policy, + modelSelection, + appContext, + }); + assert.deepEqual(alone.additionalContext, { + "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" }, + }); + // Alongside T3's own context, both are kept. + const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-app-context", + codexInput: [{ type: "text", text: "what's on my list?" }], + runtimePolicy: policy, + modelSelection, + hasT3Mcp: true, + appContext, + }); + assert.deepEqual(withT3.additionalContext?.["mcp_app_todos_list_todos_item-1"], { + kind: "untrusted", + value: "Filtered to overdue", + }); + assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); + }), + ); + it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () => Effect.gen(function* () { const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ @@ -1548,6 +1583,9 @@ function codexReplayPreamble(input: { capabilities: { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, + }, }, }, }, @@ -1972,6 +2010,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { runtime, providerThread, threadId, + serverConfig, events, continuationRequests, terminalEvents, @@ -2113,6 +2152,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { capabilities: { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, + }, }, }, ]); @@ -6305,6 +6347,253 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const MCP_APP_SCENARIO = "codex-mcp-app-capture"; + const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; + const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; + const MCP_APP_ITEM = "mcp-weather-call"; + const MCP_APP_PROMPT = "Show the weather in Oslo."; + const MCP_APP_RESOURCE = "ui://weather/dashboard"; + const MCP_APP_HTML = "

    Weather

    "; + const mcpAppToolItem = (status: "inProgress" | "completed") => ({ + type: "mcpToolCall", + id: MCP_APP_ITEM, + server: "weather", + tool: "get_weather", + status, + arguments: { city: "Oslo" }, + mcpAppResourceUri: MCP_APP_RESOURCE, + ...(status === "completed" + ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } + : {}), + }); + + const mcpAppTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: MCP_APP_NATIVE_THREAD, + nativeTurnId: MCP_APP_NATIVE_TURN, + prompt: MCP_APP_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/app-tool", + frame: { + method: "item/started", + params: { + item: mcpAppToolItem("inProgress"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/app-tool", + frame: { + method: "item/completed", + params: { + item: mcpAppToolItem("completed"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: MCP_APP_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), + }, + }, + }, + { + type: "expect_outbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + method: "mcpServer/resource/read", + params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, + }, + }, + { + type: "emit_inbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + result: { + contents: [ + { + uri: MCP_APP_RESOURCE, + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_HTML, + _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, + }, + ], + }, + }, + }, + ], + }); + + it.effect("captures an MCP app's resource and holds the turn open until it lands", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + + // The completed tool row is emitted once, with the app, never as a bare row first. + const completedRows = harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.lengthOf(completedRows, 1); + + assert.equal(item.type, "dynamic_tool"); + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); + assert.deepEqual( + { ...reference, attachmentId: undefined }, + { + attachmentId: undefined, + server: "weather", + tool: "get_weather", + resourceUri: MCP_APP_RESOURCE, + csp: { connectDomains: ["https://api.weather.test"] }, + }, + ); + assert.deepEqual(output.result, { + content: [{ type: "text", text: "Sunny" }], + structuredContent: { temp: 21 }, + }); + + const fileSystem = yield* FileSystem.FileSystem; + const stored = yield* fileSystem.readFileString( + resolveAttachmentPathById({ + attachmentsDir: harness.serverConfig.attachmentsDir, + attachmentId: reference!.attachmentId, + })!, + ); + // Stored with the app's declared policy ahead of its scripts. + assert.include(stored, "connect-src https://api.weather.test"); + assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf(""; + const injected = injectMcpAppCsp(html, undefined); + expect(injected.startsWith('")); + expect(injectMcpAppCsp("

    hi

    ", undefined).startsWith(" { + it("drops entries that could widen or break the policy", () => { + expect( + readMcpAppCsp({ + connectDomains: [ + "https://ok.test", + "*", + "'self'", + "https://evil.test; script-src *", + "http://localhost:3000", + "javascript:alert(1)", + ], + resourceDomains: "https://not-an-array.test", + }), + ).toEqual({ connectDomains: ["https://ok.test", "http://localhost:3000"] }); + expect(readMcpAppCsp({ connectDomains: ["*"] })).toBeUndefined(); + }); +}); + +describe("readMcpAppReference", () => { + it("requires a ui:// resource and the owning server and tool", () => { + const reference = { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }; + expect(readMcpAppReference(reference)).toEqual(reference); + expect(readMcpAppReference({ ...reference, resourceUri: "https://x.test" })).toBeUndefined(); + expect(readMcpAppReference({ ...reference, server: "" })).toBeUndefined(); + }); +}); + +describe("tool metadata", () => { + it("reads the resource URI from either spelling and honors app visibility", () => { + expect(readMcpAppResourceUri({ ui: { resourceUri: "ui://a/b" } })).toBe("ui://a/b"); + expect(readMcpAppResourceUri({ "ui/resourceUri": "ui://a/c" })).toBe("ui://a/c"); + expect(readMcpAppResourceUri({ ui: { resourceUri: "https://a" } })).toBeUndefined(); + expect(mcpAppToolCallableByApp(undefined)).toBe(true); + expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false); + expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true); + }); +}); + +describe("mcpAppFromToolItem", () => { + it("reads the app from both the stored output and its compact wire form", async () => { + const { compactDynamicToolOutput, mcpAppFromToolItem } = await import("./toolOutput.ts"); + const app = { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }; + const stored = { t3McpApp: app, result: { content: [] } }; + const toolName = "weather.get_weather"; + expect(mcpAppFromToolItem({ toolName, output: stored })).toEqual(app); + expect(mcpAppFromToolItem({ toolName, output: compactDynamicToolOutput(stored) })).toEqual(app); + }); + + it("ignores a reference naming a server or tool other than the item's own", async () => { + const { mcpAppFromToolItem } = await import("./toolOutput.ts"); + // A tool result shaped like an app reference, claiming another server. + const forged = { + t3McpApp: { + attachmentId: "thread-1-abc-html", + server: "bank", + tool: "transfer", + resourceUri: "ui://bank/app", + }, + }; + expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined(); + expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined(); + }); + + it("keeps an app whose resource URI is longer than a name", async () => { + const { compactDynamicToolOutput } = await import("./toolOutput.ts"); + const resourceUri = `ui://weather/${"segment/".repeat(60)}dashboard`; + const output = compactDynamicToolOutput({ + t3McpApp: { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri, + }, + }); + expect(output?.t3McpApp?.resourceUri).toBe(resourceUri); + }); + + it("keeps the compact app reference within the wire budget", async () => { + const { compactDynamicToolOutput } = await import("./toolOutput.ts"); + const domains = Array.from( + { length: 32 }, + (_, index) => `https://${"a".repeat(200)}${index}.test`, + ); + const output = compactDynamicToolOutput({ + t3McpApp: { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + csp: { connectDomains: domains, resourceDomains: domains }, + }, + }); + expect(new TextEncoder().encode(JSON.stringify(output)).byteLength).toBeLessThanOrEqual(8_192); + expect(output?.t3McpApp?.server).toBe("weather"); + }); +}); diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts new file mode 100644 index 000000000000..e3d43eb49aa1 --- /dev/null +++ b/packages/shared/src/mcpApp.ts @@ -0,0 +1,236 @@ +import * as Predicate from "effect/Predicate"; + +/** + * MCP Apps (https://github.com/modelcontextprotocol/ext-apps, spec 2026-01-26): + * an MCP tool can name a `ui://` HTML resource that hosts render as an + * interactive view of its result. T3 snapshots the resource when the tool call + * completes, stores it as a thread attachment, and records an `McpAppReference` + * in the tool item's output; clients host it in an opaque-origin frame and + * speak the spec's JSON-RPC bridge with it. + */ + +export const MCP_APP_PROTOCOL_VERSION = "2026-01-26"; +export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; +/** The MCP extension id a client declares to receive UI resources. */ +export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; +export const MCP_APP_RESOURCE_SCHEME = "ui://"; +/** Where T3 records the app reference inside a tool item's output object. */ +export const MCP_APP_OUTPUT_KEY = "t3McpApp"; + +const MCP_APP_MIN_HEIGHT = 80; +export const MCP_APP_DEFAULT_HEIGHT = 320; +export const MCP_APP_MAX_HEIGHT = 2000; +/** Largest app document T3 stores. */ +export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024; +const MAX_DOMAINS = 32; +const MAX_DOMAIN_LENGTH = 256; +const MAX_NAME_LENGTH = 256; +// A `ui://` URI can carry a path and query; the compact wire budget still +// bounds the whole reference. +const MAX_RESOURCE_URI_LENGTH = 4096; + +/** `_meta.ui.csp` on a UI resource: origins the app needs, by kind. */ +export interface McpAppCsp { + readonly connectDomains?: ReadonlyArray; + readonly resourceDomains?: ReadonlyArray; + readonly frameDomains?: ReadonlyArray; + readonly baseUriDomains?: ReadonlyArray; +} + +/** `_meta.ui.permissions` on a UI resource. */ +export interface McpAppPermissions { + readonly camera?: Record; + readonly microphone?: Record; + readonly geolocation?: Record; + readonly clipboardWrite?: Record; +} + +/** What a tool item's output carries so clients can host the app. */ +export interface McpAppReference { + readonly attachmentId: string; + /** The MCP server the tool belongs to, as the provider names it. */ + readonly server: string; + readonly tool: string; + readonly resourceUri: string; + readonly csp?: McpAppCsp; + readonly permissions?: McpAppPermissions; + readonly prefersBorder?: boolean; +} + +// Only scheme-qualified origins, optionally with a leading `*.` subdomain +// wildcard, survive. Anything else (keywords, paths, quotes, whitespace) could +// widen or break the generated policy, so it is dropped. +const DOMAIN_PATTERN = /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i; + +function readDomains(value: unknown): ReadonlyArray | undefined { + if (!Array.isArray(value)) return undefined; + const domains = value + .filter( + (domain): domain is string => + typeof domain === "string" && + domain.length <= MAX_DOMAIN_LENGTH && + DOMAIN_PATTERN.test(domain), + ) + .slice(0, MAX_DOMAINS); + return domains.length === 0 ? undefined : domains; +} + +/** The well-formed part of a resource's declared `_meta.ui.csp`. */ +export function readMcpAppCsp(value: unknown): McpAppCsp | undefined { + if (!Predicate.isObject(value)) return undefined; + const csp: { + connectDomains?: ReadonlyArray; + resourceDomains?: ReadonlyArray; + frameDomains?: ReadonlyArray; + baseUriDomains?: ReadonlyArray; + } = {}; + for (const key of [ + "connectDomains", + "resourceDomains", + "frameDomains", + "baseUriDomains", + ] as const) { + const domains = readDomains(value[key]); + if (domains !== undefined) csp[key] = domains; + } + return Object.keys(csp).length === 0 ? undefined : csp; +} + +const PERMISSION_KEYS = ["camera", "microphone", "geolocation", "clipboardWrite"] as const; + +export function readMcpAppPermissions(value: unknown): McpAppPermissions | undefined { + if (!Predicate.isObject(value)) return undefined; + const permissions: Partial>> = {}; + for (const key of PERMISSION_KEYS) { + if (Predicate.isObject(value[key])) permissions[key] = {}; + } + return Object.keys(permissions).length === 0 ? undefined : permissions; +} + +const boundedName = (value: unknown) => + typeof value === "string" && value.trim() !== "" && value.length <= MAX_NAME_LENGTH + ? value + : undefined; + +export function readMcpAppReference(value: unknown): McpAppReference | undefined { + if (!Predicate.isObject(value)) return undefined; + const attachmentId = boundedName(value.attachmentId); + const server = boundedName(value.server); + const tool = boundedName(value.tool); + const resourceUri = + typeof value.resourceUri === "string" && value.resourceUri.length <= MAX_RESOURCE_URI_LENGTH + ? value.resourceUri + : undefined; + if ( + attachmentId === undefined || + server === undefined || + tool === undefined || + resourceUri === undefined || + !resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME) + ) { + return undefined; + } + const csp = readMcpAppCsp(value.csp); + const permissions = readMcpAppPermissions(value.permissions); + return { + attachmentId, + server, + tool, + resourceUri, + ...(csp === undefined ? {} : { csp }), + ...(permissions === undefined ? {} : { permissions }), + ...(typeof value.prefersBorder === "boolean" ? { prefersBorder: value.prefersBorder } : {}), + }; +} + +export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppReference): boolean { + return ( + left.attachmentId === right.attachmentId && + left.server === right.server && + left.tool === right.tool && + left.resourceUri === right.resourceUri + ); +} + +/** The tool's `_meta.ui.resourceUri` (or the deprecated flat key), if it names a UI resource. */ +export function readMcpAppResourceUri(meta: unknown): string | undefined { + if (!Predicate.isObject(meta)) return undefined; + const nested = Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined; + const uri = nested ?? meta["ui/resourceUri"]; + return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined; +} + +/** + * Whether the tool's `_meta.ui.visibility` lets an app call it. The spec + * defaults to visible to both the model and apps. + */ +export function mcpAppToolCallableByApp(meta: unknown): boolean { + const visibility = + Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined; + return !Array.isArray(visibility) || visibility.includes("app"); +} + +/** + * The Content-Security-Policy for an app document, per the spec's + * construction: undeclared origins stay blocked, and nothing loads from the + * host's own origin ('self' would reach T3's API). + */ +export function mcpAppContentSecurityPolicy(csp: McpAppCsp | undefined): string { + const resources = csp?.resourceDomains?.join(" ") ?? ""; + const list = (domains: ReadonlyArray | undefined, fallback: string) => + domains === undefined || domains.length === 0 ? fallback : domains.join(" "); + return [ + "default-src 'none'", + `script-src 'unsafe-inline' ${resources}`.trim(), + `style-src 'unsafe-inline' ${resources}`.trim(), + `img-src data: blob: ${resources}`.trim(), + `font-src data: ${resources}`.trim(), + `media-src data: blob: ${resources}`.trim(), + `connect-src ${list(csp?.connectDomains, "'none'")}`, + `frame-src ${list(csp?.frameDomains, "'none'")}`, + "object-src 'none'", + `base-uri ${list(csp?.baseUriDomains, "'none'")}`, + "form-action 'none'", + ].join("; "); +} + +const escapeAttribute = (value: string) => + value.replace(/&/g, "&").replace(/"/g, """).replace(/`; + // After a leading doctype, so the document stays in standards mode; the + // parser moves the tag into the head it opens. + const doctype = /^\uFEFF?\s*]*>/i.exec(html); + return doctype + ? `${doctype[0]}${meta}${html.slice(doctype[0].length)}` + : `${meta}${html}`; +} + +/** The iframe `allow` attribute for the permissions an app declared. */ +export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { + const features: Array = []; + if (permissions?.camera) features.push("camera"); + if (permissions?.microphone) features.push("microphone"); + if (permissions?.geolocation) features.push("geolocation"); + if (permissions?.clipboardWrite) features.push("clipboard-write"); + return features.join("; "); +} + +export function clampMcpAppHeight(height: number): number { + if (!Number.isFinite(height)) return MCP_APP_DEFAULT_HEIGHT; + return Math.round(Math.min(MCP_APP_MAX_HEIGHT, Math.max(MCP_APP_MIN_HEIGHT, height))); +} + +export function mcpAppFileName(reference: McpAppReference): string { + const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app"; + return `${name}.html`; +} diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index a771e0159ccc..5cfb1463aaea 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -9,6 +9,7 @@ import { readHtmlRenderReference, type HtmlRenderReference, } from "./htmlRender.ts"; +import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; const MAX_PARSED_BYTES = 16_384; @@ -39,6 +40,7 @@ interface CompactToolOutput { scheduledTaskId?: string; status?: "rolled_back"; htmlRender?: HtmlRenderReference; + [MCP_APP_OUTPUT_KEY]?: McpAppReference; thread?: { threadId: string }; threads?: Array<{ threadId?: string; status?: "rolled_back" }>; } @@ -124,6 +126,9 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un if (data.status === "rolled_back") output.status = "rolled_back"; const htmlRender = readHtmlRenderReference(data.htmlRender); if (htmlRender !== undefined) output.htmlRender = htmlRender; + const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); + // Kept under its stored key, so the compact wire output reads back the same way. + if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp; const nestedThreadId = Predicate.isObject(data.thread) ? boundedId(data.thread.threadId) : undefined; @@ -159,11 +164,21 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un } } } - if (encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES) { + const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES; + if (oversized()) { delete output.threads; delete output.threadId; delete output.status; } + // An app's declared origins are the only unbounded-ish part of its + // reference; hosting works without them (the stored document carries its + // own policy), so they go before the app does. + const app = output[MCP_APP_OUTPUT_KEY]; + if (app?.csp !== undefined && oversized()) { + const { csp: _csp, ...rest } = app; + output[MCP_APP_OUTPUT_KEY] = rest; + } + if (oversized()) delete output[MCP_APP_OUTPUT_KEY]; return Object.keys(output).length === 0 ? undefined : output; } @@ -177,6 +192,21 @@ export function htmlRenderFromToolItem(item: { return output?.isError ? undefined : output?.htmlRender; } +/** + * The MCP App a completed tool call carries, if any. The adapter that captured + * it put the reference in the output, beside the tool's own result. A tool's + * result can imitate that shape, so the reference only counts when it names + * the very server and tool the item records: a server can then only ever + * point at an app of its own. + */ +export function mcpAppFromToolItem(item: { + readonly toolName: string | null | undefined; + readonly output?: unknown; +}): McpAppReference | undefined { + const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY]; + return app !== undefined && item.toolName === `${app.server}.${app.tool}` ? app : undefined; +} + /** Some providers report completion even when command output describes a failure. */ export function toolOutputIndicatesFailure(text: string): boolean { return ( From a8c480241836a7f6b79862e4504f37801cda6cde Mon Sep 17 00:00:00 2001 From: Vantrongs Date: Wed, 7 Oct 2026 19:46:27 +0300 Subject: [PATCH 004/530] fix(server): a background Claude subagent's work shows while its parent is idle (#16486) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../Adapters/ClaudeAdapterV2.test.ts | 1432 +++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 278 +++- ...estratorReplayFixtures.integration.test.ts | 160 ++ .../input.ts | 8 +- .../output.ts | 73 +- 5 files changed, 1759 insertions(+), 192 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 785c4b1c9ec1..b57dffad2c15 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -44,6 +44,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -2085,6 +2086,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + // 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; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2093,6 +2097,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-wake-", }); const sdkMessages = yield* Queue.unbounded(); + const processQueues: Array> = []; const processedMessages = new WeakMap>(); const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) { const processed = yield* Deferred.make(); @@ -2126,8 +2131,24 @@ describe("ClaudeAdapterV2 background wake turns", () => { queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), open: (input) => - Effect.sync(() => { + Effect.gen(function* () { openedOptions = input.options; + if (options?.freshQueueOnReopen === true && processQueues.length > 0) { + const processMessages = yield* Queue.unbounded(); + processQueues.push(processMessages); + return { + messages: Stream.fromQueue(processMessages), + offer: (message: SDKUserMessage) => + Effect.sync(() => { + offeredMessages.push(message); + }), + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(processMessages), + }; + } + processQueues.push(sdkMessages); return { messages: Stream.fromQueue(sdkMessages).pipe( Stream.flatMap((message) => @@ -2205,6 +2226,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThread, threadId, sdkMessages, + processQueues, offerAndWait, offeredMessages, permissionModeChanges, @@ -6261,150 +6283,174 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("releases held frames before the notification that first names the tool use", () => + it.effect("streams a background subagent's work while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { - const TASK_ID = "task-late-tool-use"; - const TOOL_USE_ID = "toolu-late-tool-use"; + const TASK_ID = "task-live-idle"; + const TOOL_USE_ID = "toolu-live-idle"; + const STRADDLING_BASH = "toolu-live-straddling-bash"; + const IDLE_BASH = "toolu-live-idle-bash"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + const latestToolItem = (nativeId: string) => + harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId, + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-late-tool-use"), - text: "Run an auditor.", + attemptId: RunAttemptId.make("attempt-claude-live-idle-1"), + text: "Audit in the background.", attachments: [], }), ); - const frames = [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: TASK_ID, - description: "Audit recent commits", - task_type: "local_agent", - prompt: "Audit the last five commits.", - uuid: "00000000-0000-4000-8000-000000000341", - session_id: WAKE_NATIVE_SESSION, + // The root settles while the subagent's first Bash call still runs. + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000951", }), - // task_started carried no tool_use_id, so these frames are held - // until the notification pairs the task with its tool use. The SDK - // types tool_use_id as optional; no recorded or logged task_started - // has omitted it, so this guards the typed contract only. ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000342", - text: "Working.", + uuid: "00000000-0000-4000-8000-000000000952", + bashToolUseId: STRADDLING_BASH, }), - // The final answer arrives as one snapshot per text block. - ...makeSubagentAssistantFrames({ + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000953", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + for (const frame of [ + makeSubagentToolResultFrame({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000344", - messageId: "msg_late_final", - text: "Part one.", + uuid: "00000000-0000-4000-8000-000000000954", + toolUseId: STRADDLING_BASH, }), ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000345", - messageId: "msg_late_final", - text: "Part two.", + uuid: "00000000-0000-4000-8000-000000000955", + text: "Still auditing.", + bashToolUseId: IDLE_BASH, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + description: "Reading the last commits", + uuid: "00000000-0000-4000-8000-000000000956", + session_id: WAKE_NATIVE_SESSION, + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + // Before anything wakes the root, the idle work is already projected. + yield* awaitUntil(() => { + const event = latestToolItem(IDLE_BASH); + return event?.type === "turn_item.updated" && event.turnItem.status === "running"; + }, "idle Bash call"); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), + "idle progress", + ); + const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); + assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); + // The call that spans the turn end keeps the type and input its + // tool_use gave it during the open turn. + const straddling = latestToolItem(STRADDLING_BASH); + const straddlingItem = + straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined; + assert.equal(straddlingItem?.status, "completed"); + assert.equal( + straddlingItem?.type === "command_execution" ? straddlingItem.input : undefined, + "git log -5", + ); + assert.equal(harness.continuationRequests.length, 0); + + for (const frame of [ + makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000957", + toolUseId: IDLE_BASH, }), makeSubagentNotificationFrame({ taskId: TASK_ID, toolUseId: TOOL_USE_ID, - summary: "Part one.\n\nPart two.", - uuid: "00000000-0000-4000-8000-000000000346", + summary: "Still auditing.", + uuid: "00000000-0000-4000-8000-000000000958", }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* Queue.offer( + harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000347", + uuid: "00000000-0000-4000-8000-000000000959", result: "The auditor finished.", }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - - const routing = subagentRouting(harness.events, []); - assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ - "Working.", - "Part one.", - "Part two.", - ]); - assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - - it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => - Effect.scoped( - Effect.gen(function* () { - const TOOL_USE_ID = "toolu-retry-subagent"; - 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-claude-retry-subagent"), - text: "Run an auditor.", + attemptId: RunAttemptId.make("attempt-claude-live-idle-2"), + text: "Background task completed.", attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", }), ); - const frames = [ - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000351", - text: "Held before registration.", - }), - claudeSdkFrame({ - type: "system", - subtype: "api_retry", - attempt: 2, - max_retries: 10, - retry_delay_ms: 1_500, - error_status: 529, - error: "overloaded", - uuid: "00000000-0000-4000-8000-000000000352", - session_id: WAKE_NATIVE_SESSION, - }), - makeSubagentTaskStartedFrame({ - taskId: "task-retry-subagent", - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000353", - }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); + assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [ + "Still auditing.", + ]); + for (const nativeId of [STRADDLING_BASH, IDLE_BASH]) { + assert.deepEqual( + [...(finalRouting.toolThreadIds.get(nativeId) ?? [])], + [finalRouting.childThreadId], + ); + const statuses = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(statuses, ["command_execution:running", "command_execution:completed"]); } - const childTexts = () => { - const routing = subagentRouting(harness.events, []); - return routing.childThreadId === undefined - ? [] - : routing.assistantTexts(routing.childThreadId); - }; - yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); - const retryStatuses = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem.status] - : [], + const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated"); + assert.equal( + finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, + "completed", ); - assert.deepEqual(retryStatuses, ["running"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); - it.effect("holds subagent frames that precede task_started and shows its result once", () => + it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { - const TASK_ID = "task-early-subagent"; - const TOOL_USE_ID = "toolu-early-subagent"; - const FINAL_REPORT = "Early auditor done."; + const OUTER_TASK_ID = "task-idle-outer"; + const OUTER_TOOL_USE_ID = "toolu-idle-outer"; + const NESTED_TASK_ID = "task-idle-nested"; + const NESTED_TOOL_USE_ID = "toolu-idle-nested"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -6412,69 +6458,1135 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-early-subagent"), - text: "Run an auditor.", + attemptId: RunAttemptId.make("attempt-claude-idle-nested-1"), + text: "Audit in the background.", attachments: [], }), ); - const frames = [ - // The SDK can forward child frames before the task_started that - // registers their subagent. - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000321", - text: "Starting early.", - bashToolUseId: "toolu-early-bash", + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000971", }), - makeSubagentToolResultFrame({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000322", - toolUseId: "toolu-early-bash", + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000972", + result: "Auditing in the background.", }), - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000323", + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // While the root is idle, the subagent runs a nested one to completion. + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_idle_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000973", + session_id: WAKE_NATIVE_SESSION, }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000324", - text: "Still working.", + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000974", + session_id: WAKE_NATIVE_SESSION, }), - // Progress without a tool_use_id replaces the subagent entry while - // the per-turn tool-use alias still points at the previous one. + // The outer subagent's progress and own work don't wait for the + // nested start. claudeSdkFrame({ type: "system", subtype: "task_progress", - task_id: TASK_ID, - description: "Checking the diffs", - uuid: "00000000-0000-4000-8000-000000000325", + task_id: OUTER_TASK_ID, + description: "Waiting on the nested check", + uuid: "00000000-0000-4000-8000-000000000984", session_id: WAKE_NATIVE_SESSION, }), ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000326", - text: FINAL_REPORT, + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000988", + messageId: "msg_idle_outer_working", + text: "OUTER_WORKING", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", + ), + "outer work while the nested start waits", + ); + assert.lengthOf(harness.continuationRequests, 0); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000975", + text: "NESTED_OK", }), makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: FINAL_REPORT, - uuid: "00000000-0000-4000-8000-000000000327", + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000976", }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000328", - result: "The auditor finished.", + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000977", + toolUseId: NESTED_TOOL_USE_ID, }), - ]; - for (const frame of frames) { + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000978", + text: "OUTER_DONE", + }), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000000979", + }), + ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - - const routing = subagentRouting(harness.events, ["toolu-early-bash"]); - assert.isDefined(routing.childThreadId); - assert.deepEqual( + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Waiting on the nested check", + ), + "outer progress", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000980", + result: "The audit finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-nested-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + // The nested Agent call is the nested subagent, never a plain tool row. + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, + ), + ); + const finalSubagents = new Map( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const] + : [], + ), + ); + const outer = finalSubagents.get(OUTER_TASK_ID); + const nested = finalSubagents.get(NESTED_TASK_ID); + assert.equal(outer?.status, "completed"); + assert.equal(nested?.status, "completed"); + assert.equal(nested?.parentNodeId, outer?.id); + assert.deepEqual( + subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined), + ["OUTER_WORKING", "OUTER_DONE"], + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("ends an earlier turn's nested subagent while the root turn is idle", () => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TASK_ID = "task-earlier-outer"; + const OUTER_TOOL_USE_ID = "toolu-earlier-outer"; + const NESTED_TASK_ID = "task-earlier-nested"; + const NESTED_TOOL_USE_ID = "toolu-earlier-nested"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const userTurn = (attemptId: string, providerTurnOrdinal: number) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(attemptId), + text: "Keep auditing.", + attachments: [], + providerTurnOrdinal, + }), + ); + // Turn 1 launches the outer subagent, which starts a nested one. + yield* userTurn("attempt-claude-earlier-nested-1", 1); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a01", + }), + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_earlier_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a02", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000a03", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a04", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + // A later turn settles, so its context, which never saw the nested + // launch, is the one the root's idle frames go through. + yield* userTurn("attempt-claude-earlier-nested-2", 2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); + + // The nested subagent ends and its Agent call returns while the root + // is idle. The outer subagent's next message marks when both are handled. + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000a06", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a07", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a08", + text: "OUTER_AFTER_NESTED", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", + ), + "outer work after the nested call returns", + ); + + // The nested Agent call is the nested subagent, never a plain tool row. + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, + ), + ); + const nested = harness.events.findLast( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID, + ); + assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed"); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("ends a subagent's open call when the CLI exits while the root turn is idle", () => + Effect.scoped( + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-idle-exit"; + const BASH = "toolu-idle-exit-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-exit"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: "task-idle-exit", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000981", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000982", + bashToolUseId: BASH, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000983", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + assert.deepEqual(bashStatuses(), ["command_execution:running"]); + + // The CLI exits before the call returns; its result can never arrive. + yield* Queue.shutdown(harness.sdkMessages); + yield* awaitUntil(() => bashStatuses().length === 2, "call ended"); + assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("ends a subagent's open call when Stop's close of the CLI times out", () => + Effect.scoped( + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-idle-close-timeout"; + const BASH = "toolu-idle-close-timeout-bash"; + // close() leaves the stream open, so Stop gives up waiting for it. + const harness = yield* makeWakeHarnessWithOptions(); + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-close-timeout"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: "task-idle-close-timeout", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000985", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000986", + bashToolUseId: BASH, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000987", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + assert.deepEqual(bashStatuses(), ["command_execution:running"]); + + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(stop); + assert.deepEqual(bashStatuses(), [ + "command_execution:running", + "command_execution:interrupted", + ]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect.each(["before", "after"] as const)( + "keeps a subagent call's result when the CLI exits before the drain (result %s the nested launch's)", + (bashResultOrder) => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TASK_ID = "task-exit-buffered-outer"; + const OUTER_TOOL_USE_ID = "toolu-exit-buffered-outer"; + const NESTED_TASK_ID = "task-exit-buffered-nested"; + const NESTED_TOOL_USE_ID = "toolu-exit-buffered-nested"; + const BASH = "toolu-exit-buffered-bash"; + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const now = yield* DateTime.now; + const bashItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [ + `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, + ] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-buffered-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001001", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001002", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // While the root is idle, the subagent starts Bash; it is projected at once. + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001003", + bashToolUseId: BASH, + })) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => bashItems().length === 1, "idle Bash call"); + + // A nested start buffers, and its launch result waits behind it. Bash's + // successful result arrives before or after that launch result. + const bashResultFrame = makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001006", + toolUseId: BASH, + }); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_exit_buffered_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001004", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000001005", + session_id: WAKE_NATIVE_SESSION, + }), + ...(bashResultOrder === "before" ? [bashResultFrame] : []), + ...makeSubagentAssistantFrames({ + parentToolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001007", + text: "NESTED_OK", + }), + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000001008", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001009", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...(bashResultOrder === "after" ? [bashResultFrame] : []), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000001010", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation request", + ); + + // The CLI exits before the continuation run drains the wake buffer. + yield* Queue.shutdown(harness.sdkMessages); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "query exit"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-buffered-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + // The continuation opened a new CLI process: the first one's exit was handled. + assert.equal(harness.processQueues.length, 2); + yield* Queue.offer( + harness.processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001011", + result: "The audit finished.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const input = JSON.stringify("git log -5"); + assert.deepEqual(bashItems(), [ + `command_execution:running:${input}`, + `command_execution:completed:${input}`, + ]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect( + "ends a subagent's SendMessage with its result when the CLI exits while the resume waits", + () => + Effect.scoped( + Effect.gen(function* () { + const SENDER_TASK_ID = "task-exit-resume-sender"; + const SENDER_TOOL_USE_ID = "toolu-exit-resume-sender"; + const RESUMED_TASK_ID = "task-exit-resume-target"; + const RESUMED_TOOL_USE_ID = "toolu-exit-resume-target"; + const SEND = "toolu-exit-resume-sendmessage"; + const SEND_INPUT = { to: RESUMED_TASK_ID, message: "Check the last commit again." }; + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const now = yield* DateTime.now; + const sendItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === SEND + ? [ + `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, + ] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-resume-1"), + text: "Run two auditors in the background.", + attachments: [], + }), + ); + // The target subagent finishes inside the turn; the sender keeps running. + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: SENDER_TASK_ID, + toolUseId: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001101", + }), + makeSubagentTaskStartedFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: RESUMED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001102", + }), + makeSubagentNotificationFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: RESUMED_TOOL_USE_ID, + summary: "First answer.", + uuid: "00000000-0000-4000-8000-000000001103", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001104", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // While the root is idle, the sender resumes the finished subagent. + // Its SendMessage call is projected at once. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_exit_resume_sendmessage", + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: SEND, name: "SendMessage", input: SEND_INPUT }], + }, + parent_tool_use_id: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001105", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil(() => sendItems().length >= 1, "idle SendMessage call"); + + // The resume's task_started buffers. The SendMessage result names that + // start's tool use, but its call already shows as a row, so it ends it. + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: RESUMED_TASK_ID, + tool_use_id: SEND, + description: "Audit recent commits", + is_backgrounded: true, + task_type: "local_agent", + prompt: SEND_INPUT.message, + uuid: "00000000-0000-4000-8000-000000001106", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: SEND, content: "Message sent." }], + }, + parent_tool_use_id: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001107", + session_id: WAKE_NATIVE_SESSION, + }), + makeSubagentNotificationFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: SEND, + summary: "Second answer.", + uuid: "00000000-0000-4000-8000-000000001108", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation request", + ); + const beforeExit = sendItems(); + + // The CLI exits before the continuation run drains the wake buffer. + yield* Queue.shutdown(harness.sdkMessages); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "query exit"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-resume-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + // The continuation opened a new CLI process: the first one's exit was handled. + assert.equal(harness.processQueues.length, 2); + yield* Queue.offer( + harness.processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001109", + result: "The audit finished.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const input = JSON.stringify(SEND_INPUT); + assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]); + assert.deepEqual(sendItems(), [ + `dynamic_tool:running:${input}`, + `dynamic_tool:completed:${input}`, + ]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("releases held frames before the notification that first names the tool use", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-late-tool-use"; + const TOOL_USE_ID = "toolu-late-tool-use"; + 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-claude-late-tool-use"), + text: "Run an auditor.", + attachments: [], + }), + ); + const frames = [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: TASK_ID, + description: "Audit recent commits", + task_type: "local_agent", + prompt: "Audit the last five commits.", + uuid: "00000000-0000-4000-8000-000000000341", + session_id: WAKE_NATIVE_SESSION, + }), + // task_started carried no tool_use_id, so these frames are held + // until the notification pairs the task with its tool use. The SDK + // types tool_use_id as optional; no recorded or logged task_started + // has omitted it, so this guards the typed contract only. + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000342", + text: "Working.", + }), + // The final answer arrives as one snapshot per text block. + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000344", + messageId: "msg_late_final", + text: "Part one.", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000345", + messageId: "msg_late_final", + text: "Part two.", + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "Part one.\n\nPart two.", + uuid: "00000000-0000-4000-8000-000000000346", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000347", + result: "The auditor finished.", + }), + ]; + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + + const routing = subagentRouting(harness.events, []); + assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ + "Working.", + "Part one.", + "Part two.", + ]); + assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect.each([ + ["", "none"], + [" behind another subagent's start without one", "before"], + [" when another subagent's start without one arrives between its frames", "between"], + ] as const)( + "keeps a subagent's frames in order when a late progress names its tool use while the root is idle%s", + ([, otherStart]) => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-late-owner-idle"; + const TOOL_USE_ID = "toolu-late-owner-idle"; + const BASH = "toolu-late-owner-idle-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + // task_started without tool_use_id (optional in the SDK type), then + // the root settles. + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: TASK_ID, + description: "Audit recent commits", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Audit the last five commits.", + uuid: "00000000-0000-4000-8000-000000000a01", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a02", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // Another subagent starts while the root is idle, also without a + // tool_use_id, so its start waits in the wake buffer. + const otherStartFrame = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-late-owner-other", + description: "Check the changelog", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Check the changelog.", + uuid: "00000000-0000-4000-8000-000000000a09", + session_id: WAKE_NATIVE_SESSION, + }); + const progressFrame = claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + tool_use_id: TOOL_USE_ID, + description: "Reading the last commits", + usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 }, + uuid: "00000000-0000-4000-8000-000000000a04", + session_id: WAKE_NATIVE_SESSION, + }); + const bashResultFrame = makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a05", + toolUseId: BASH, + }); + // Root idle: the child's Bash call arrives before anything names the + // owner's tool use; progress names it before or after the call returns. + for (const frame of [ + ...(otherStart === "before" ? [otherStartFrame] : []), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a03", + text: "First.", + bashToolUseId: BASH, + }), + ...(otherStart === "between" + ? [otherStartFrame, bashResultFrame, progressFrame] + : [progressFrame, bashResultFrame]), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a06", + text: "Second.", + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "Second.", + uuid: "00000000-0000-4000-8000-000000000a07", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation request", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a08", + result: "The auditor finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const routing = subagentRouting(harness.events, [BASH]); + const childTexts = routing.assistantTexts(routing.childThreadId); + const statuses = bashStatuses(); + // Observed order of child texts and Bash item updates, for diagnosis. + const trace = harness.events.flatMap((event) => + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.threadId === routing.childThreadId + ? [`text:${event.message.text}#${event.message.id}`] + : event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === BASH + ? [`bash:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(childTexts, ["First.", "Second."], `trace: ${JSON.stringify(trace)}`); + assert.deepEqual( + [...(routing.toolThreadIds.get(BASH) ?? [])], + [routing.childThreadId], + `trace: ${JSON.stringify(trace)}`, + ); + assert.deepEqual( + statuses, + ["command_execution:running", "command_execution:completed"], + `trace: ${JSON.stringify(trace)}`, + ); + // Progress that waited behind the subagent's frames still reaches its + // card, and asks for no continuation of its own. + assert.isTrue( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), + ); + assert.lengthOf(harness.continuationRequests, 1); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => + Effect.scoped( + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-retry-subagent"; + 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-claude-retry-subagent"), + text: "Run an auditor.", + attachments: [], + }), + ); + const frames = [ + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000351", + text: "Held before registration.", + }), + claudeSdkFrame({ + type: "system", + subtype: "api_retry", + attempt: 2, + max_retries: 10, + retry_delay_ms: 1_500, + error_status: 529, + error: "overloaded", + uuid: "00000000-0000-4000-8000-000000000352", + session_id: WAKE_NATIVE_SESSION, + }), + makeSubagentTaskStartedFrame({ + taskId: "task-retry-subagent", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000353", + }), + ]; + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, frame); + } + const childTexts = () => { + const routing = subagentRouting(harness.events, []); + return routing.childThreadId === undefined + ? [] + : routing.assistantTexts(routing.childThreadId); + }; + yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); + const retryStatuses = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.retry !== undefined + ? [event.turnItem.status] + : [], + ); + assert.deepEqual(retryStatuses, ["running"]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("holds subagent frames that precede task_started and shows its result once", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-early-subagent"; + const TOOL_USE_ID = "toolu-early-subagent"; + const FINAL_REPORT = "Early auditor done."; + 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-claude-early-subagent"), + text: "Run an auditor.", + attachments: [], + }), + ); + const frames = [ + // The SDK can forward child frames before the task_started that + // registers their subagent. + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000321", + text: "Starting early.", + bashToolUseId: "toolu-early-bash", + }), + makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000322", + toolUseId: "toolu-early-bash", + }), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000323", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000324", + text: "Still working.", + }), + // Progress without a tool_use_id replaces the subagent entry while + // the per-turn tool-use alias still points at the previous one. + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + description: "Checking the diffs", + uuid: "00000000-0000-4000-8000-000000000325", + session_id: WAKE_NATIVE_SESSION, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000326", + text: FINAL_REPORT, + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: FINAL_REPORT, + uuid: "00000000-0000-4000-8000-000000000327", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000328", + result: "The auditor finished.", + }), + ]; + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + + const routing = subagentRouting(harness.events, ["toolu-early-bash"]); + assert.isDefined(routing.childThreadId); + assert.deepEqual( [...(routing.toolThreadIds.get("toolu-early-bash") ?? [])], [routing.childThreadId], ); @@ -6949,6 +8061,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { tool_use_result: resumeAck, }), ); + // The resumed run's own work follows its task_started into the wake + // buffer: nothing ingests the child thread until the drain. + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000961", + text: "RESUMED_WORK", + bashToolUseId: "toolu-resume-postsettle-bash", + }), + makeSubagentToolResultFrame({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000962", + toolUseId: "toolu-resume-postsettle-bash", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } yield* Queue.offer(harness.sdkMessages, secondNotification); yield* awaitUntil( () => harness.continuationRequests.length === 2, @@ -7010,6 +8139,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(finalSubagent?.result, SECOND_SUMMARY); // The completion keeps the resuming run's attribution. assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); + // Its work is projected once, by the drain that re-opens it. + const reopenIndex = harness.events.findIndex( + (event) => + event.type === "subagent.updated" && + event.subagent.status === "running" && + event.subagent.runId === "run-attempt-claude-wake-9c", + ); + assert.isAtLeast(reopenIndex, 0); + const resumedWorkIndexes = harness.events.flatMap((event, index) => + event.type === "message.updated" && event.message.text === "RESUMED_WORK" ? [index] : [], + ); + assert.lengthOf(resumedWorkIndexes, 1); + assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex); assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 81f9abc001a3..6cedbb153d80 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2709,6 +2709,7 @@ function formatClaudeUsageLimitWait(waitMs: number): string { interface ActiveClaudeTurnContext { readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly nativeThreadId: string; readonly nativeTurnId: string; nativeMessageCursor: string | null; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; @@ -2744,7 +2745,6 @@ interface ActiveClaudeTurnContext { readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; readonly subagentNodesByTaskId: Map; - readonly pendingSubagentLaunchesByToolUseId: Map; // Set on turns that offered a prompt. Claude runs a wake turn it queued // for background work before the next prompt's turn, and only the // prompt's turn echoes this uuid (see handleSdkMessage). @@ -2856,12 +2856,12 @@ function rememberPendingClaudeSubagentLaunch( */ function rememberClaudeSubagentLaunch( context: ActiveClaudeTurnContext, + pending: Map, toolUseId: string, input: ClaudeNativeToolInput, ownerToolUseId: string | null, ): void { if (context.subagentsByToolUseId.has(toolUseId)) return; - const pending = context.pendingSubagentLaunchesByToolUseId; const requested = firstStringInputField(input, ["model"]); // "inherit" is the caller's model: the session's here, an owner's once // task_started resolves it. @@ -3129,6 +3129,18 @@ export function makeClaudeAdapterV2( // the turn that launched the subagent (wake drain, later turns) resolve // their subagent through this index into sessionSubagentsByTaskId. const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); + // A subagent's tool calls can outlive the turn that started them, so + // they live here rather than on a turn context: a turn that ends while + // one runs neither fails it nor hides it from the turn that follows. + const subagentToolCalls = new Map(); + // Launches seen before their task_started. A running subagent's Agent + // call can be projected while the root is idle and its task_started + // only in the continuation turn, so these outlive a turn too. + const pendingSubagentLaunchesByToolUseId = new Map(); + // The turn that settled last on each native thread. While the root is + // idle, a running subagent's frames are projected through it at once + // instead of waiting in the wake buffer for a continuation run. + const settledTurnByNativeThread = new Map(); // Subagent frames can precede the task_started that registers their // subagent (the same race rememberPendingClaudeSubagentLaunch covers). // They wait here and replay once task_started registers the owner. @@ -4025,7 +4037,7 @@ export function makeClaudeAdapterV2( // tracked tool call is a resume. if ( resume.toolUseId === undefined || - !resume.context.toolCalls.has(resume.toolUseId) || + findToolCall(resume.context, resume.toolUseId) === undefined || resume.context.subagentsByTaskId.has(resume.taskId) || (yield* Ref.get(sessionSubagentsByTaskId)).has(resume.taskId) ) { @@ -4146,6 +4158,23 @@ export function makeClaudeAdapterV2( ) { return; } + // A call the subagent left open never returns once it ends. It ends + // first, while the run that owns the child thread still takes its + // events. + if (existingSubagent !== undefined && input.status !== "running") { + const openCalls = [...subagentToolCalls.values()].filter( + (toolCall) => toolCall.threadId === existingSubagent.childThreadId, + ); + yield* endToolCalls({ + context: input.context, + toolCalls: openCalls, + status: input.status === "cancelled" ? "interrupted" : "failed", + completedAt: yield* DateTime.now, + }); + for (const toolCall of openCalls) { + subagentToolCalls.delete(toolCall.nativeItemId); + } + } // A task_started under a tool call other than the current run's is // a resume: SendMessage re-emits task_started for the same task id // under its own tool_use_id, with the sent message as the prompt. @@ -4647,6 +4676,12 @@ export function makeClaudeAdapterV2( return registered ?? context.subagentsByToolUseId.get(toolUseId); }); + const findToolCall = (context: ActiveClaudeTurnContext, nativeItemId: string) => + context.toolCalls.get(nativeItemId) ?? subagentToolCalls.get(nativeItemId); + // Only a subagent's calls have no run: they belong to its child thread. + const toolCallsFor = (context: ActiveClaudeTurnContext, toolCall: ActiveClaudeToolCall) => + toolCall.runId === null ? subagentToolCalls : context.toolCalls; + const ensureToolCallStarted = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; @@ -4655,7 +4690,7 @@ export function makeClaudeAdapterV2( readonly parentToolUseId: string | null; readonly presentation?: ClaudeToolPresentation | undefined; }) { - const existing = input.context.toolCalls.get(input.nativeItemId); + const existing = findToolCall(input.context, input.nativeItemId); if (existing !== undefined) { // The permission callback can start a call before its assistant // frame arrives with the tool's display name and icon. @@ -4663,7 +4698,7 @@ export function makeClaudeAdapterV2( return existing; } const presented = { ...existing, presentation: input.presentation }; - input.context.toolCalls.set(input.nativeItemId, presented); + toolCallsFor(input.context, presented).set(input.nativeItemId, presented); const updatedAt = yield* DateTime.now; yield* emitToolCallArtifacts( buildToolCallArtifacts({ @@ -4713,7 +4748,7 @@ export function makeClaudeAdapterV2( startedAt, ...(input.presentation === undefined ? {} : { presentation: input.presentation }), }; - input.context.toolCalls.set(input.nativeItemId, toolCall); + toolCallsFor(input.context, toolCall).set(input.nativeItemId, toolCall); yield* emitToolCallArtifacts( buildToolCallArtifacts({ context: input.context, @@ -4891,6 +4926,57 @@ export function makeClaudeAdapterV2( }), }); + // Ends open calls whose results will never arrive. + const endToolCalls = Effect.fnUntraced(function* (input: { + readonly context: ActiveClaudeTurnContext; + readonly toolCalls: ReadonlyArray; + readonly status: Extract< + OrchestrationV2ProviderTurn["status"], + "interrupted" | "failed" | "cancelled" + >; + readonly completedAt: DateTime.Utc; + }) { + for (const toolCall of input.toolCalls) { + yield* emitToolCallArtifacts( + buildToolCallArtifacts({ + context: input.context, + nativeItemId: toolCall.nativeItemId, + toolName: toolCall.toolName, + classification: toolCall.classification, + toolInput: toolCall.input, + threadId: toolCall.threadId, + runId: toolCall.runId, + rootNodeId: toolCall.rootNodeId, + parentNodeId: toolCall.parentNodeId, + ordinal: toolCall.ordinal, + output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, + status: input.status, + startedAt: toolCall.startedAt, + updatedAt: input.completedAt, + presentation: toolCall.presentation, + }), + ); + } + }); + // Ends the calls a subagent kept open past the root's last turn, once + // the CLI process that ran them is gone. + const endSubagentCallsOfClosedQuery = Effect.fnUntraced(function* ( + nativeThreadId: string, + status: "interrupted" | "failed", + ) { + const settled = settledTurnByNativeThread.get(nativeThreadId); + if (settled === undefined || subagentToolCalls.size === 0) { + return; + } + yield* endToolCalls({ + context: settled, + toolCalls: [...subagentToolCalls.values()], + status, + completedAt: yield* DateTime.now, + }); + subagentToolCalls.clear(); + }); + const finalizeActiveTurn = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly status: Extract< @@ -4903,29 +4989,34 @@ export function makeClaudeAdapterV2( readonly result?: SDKResultMessage; }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); - for (const toolCall of input.context.toolCalls.values()) { - const artifacts = buildToolCallArtifacts({ - context: input.context, - nativeItemId: toolCall.nativeItemId, - toolName: toolCall.toolName, - classification: toolCall.classification, - toolInput: toolCall.input, - threadId: toolCall.threadId, - runId: toolCall.runId, - rootNodeId: toolCall.rootNodeId, - parentNodeId: toolCall.parentNodeId, - ordinal: toolCall.ordinal, - output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, - // A stopped turn cuts its open tools short; only a turn that - // ended on its own leaves them failed. - status: input.status === "completed" ? "failed" : input.status, - startedAt: toolCall.startedAt, - updatedAt: input.completedAt, - presentation: toolCall.presentation, - }); - yield* emitToolCallArtifacts(artifacts); - } + // A subagent still running in the background keeps its open calls: + // their results arrive after this turn. One left by an earlier CLI + // process is gone and never reports them. + const liveQuery = yield* Ref.get(queryContext); + const runningChildThreadIds = new Set( + [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => + subagent.task.status === "running" && + liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true + ? [subagent.childThreadId] + : [], + ), + ); + const endingSubagentCalls = [...subagentToolCalls.values()].filter( + (toolCall) => + input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId), + ); + yield* endToolCalls({ + context: input.context, + toolCalls: [...input.context.toolCalls.values(), ...endingSubagentCalls], + // A stopped turn cuts its open tools short; only a turn that + // ended on its own leaves them failed. + status: input.status === "completed" ? "failed" : input.status, + completedAt: input.completedAt, + }); input.context.toolCalls.clear(); + for (const toolCall of endingSubagentCalls) { + subagentToolCalls.delete(toolCall.nativeItemId); + } if ( input.context.assistant.emittedNativeItemIds.size === 0 && @@ -5132,6 +5223,11 @@ export function makeClaudeAdapterV2( yield* Ref.update(activeTurn, (current) => current?.providerTurnId === input.context.providerTurnId ? null : current, ); + if (input.status === "completed") { + settledTurnByNativeThread.set(input.context.nativeThreadId, input.context); + } else { + settledTurnByNativeThread.delete(input.context.nativeThreadId); + } yield* Ref.update(interruptedTurns, (current) => { const next = new Set(current); next.delete(input.context.providerTurnId); @@ -5539,6 +5635,84 @@ export function makeClaudeAdapterV2( return true; }); + // A background subagent keeps working after the root turn settles. Its + // own frames and progress reach its thread and card at once, through + // the turn that settled last; root output and wake evidence still wait + // in the wake buffer for a continuation run. + const settledTurnForSubagentFrame = Effect.fnUntraced(function* ( + nativeThreadId: string, + message: SDKMessage, + ) { + const settled = settledTurnByNativeThread.get(nativeThreadId); + if (settled === undefined) { + return null; + } + // The result of a call already shown as its row only ends that row, + // so it never waits. + const userResults = claudeToolResultBlocksFromUserMessage(message); + if ( + message.type === "user" && + userResults.length > 0 && + userResults.length === message.message.content.length && + userResults.every( + (toolResult) => findToolCall(settled, toolResult.tool_use_id) !== undefined, + ) + ) { + return settled; + } + // A subagent's task_started still in the wake buffer registers or + // re-opens it only when a continuation drains it, and only that run + // stores the work that follows. So that subagent's own frames, and + // the result of the Agent call that launched it, wait behind it. + const bufferedStarts = ( + (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [] + ).flatMap((entry) => + entry.type === "system" && + entry.subtype === "task_started" && + !isClaudeNonSubagentTask(entry) + ? [entry] + : [], + ); + const parentToolUseId = parentToolUseIdFromSdkMessage(message); + const taskId = + message.type === "system" && message.subtype === "task_progress" + ? message.task_id + : parentToolUseId === null + ? undefined + : (yield* Ref.get(sessionSubagentTaskIdsByToolUseId)).get(parentToolUseId); + const returnedToolUseIds = new Set( + [...claudeToolResultBlocksFromAssistantMessage(message), ...userResults].map( + (toolResult) => toolResult.tool_use_id, + ), + ); + if ( + bufferedStarts.some( + (start) => + start.task_id === taskId || + (start.tool_use_id !== undefined && + (start.tool_use_id === parentToolUseId || + returnedToolUseIds.has(start.tool_use_id))), + ) + ) { + return null; + } + // A frame whose owner is named later, by progress, waits in the hold + // used during a turn, keyed by that owner, so its later frames queue + // behind it there and are released in order. + if (taskId === undefined) { + return parentToolUseId === null ? null : settled; + } + // Without the launching call's id, any result may be its launch. + if ( + returnedToolUseIds.size > 0 && + bufferedStarts.some((start) => start.tool_use_id === undefined) + ) { + return null; + } + const subagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + return subagent?.task.status === "running" ? settled : null; + }); + const handleSdkMessageFrame = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; @@ -5662,7 +5836,9 @@ export function makeClaudeAdapterV2( } return; } - const context = yield* Ref.get(activeTurn); + const context = + (yield* Ref.get(activeTurn)) ?? + (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. @@ -5958,7 +6134,7 @@ export function makeClaudeAdapterV2( const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); if (subagent === undefined) { rememberPendingClaudeSubagentLaunch( - context.pendingSubagentLaunchesByToolUseId, + pendingSubagentLaunchesByToolUseId, parentToolUseId, { model }, ); @@ -6027,9 +6203,9 @@ export function makeClaudeAdapterV2( const launch = message.tool_use_id === undefined ? undefined - : context.pendingSubagentLaunchesByToolUseId.get(message.tool_use_id); + : pendingSubagentLaunchesByToolUseId.get(message.tool_use_id); if (message.tool_use_id !== undefined) { - context.pendingSubagentLaunchesByToolUseId.delete(message.tool_use_id); + pendingSubagentLaunchesByToolUseId.delete(message.tool_use_id); } const owner = launch?.ownerToolUseId === undefined @@ -6162,6 +6338,7 @@ export function makeClaudeAdapterV2( if (toolUse.name === "Agent") { rememberClaudeSubagentLaunch( context, + pendingSubagentLaunchesByToolUseId, toolUse.id, nativeToolInput, parentToolUseIdFromSdkMessage(message), @@ -6201,13 +6378,19 @@ export function makeClaudeAdapterV2( } for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { - const subagent = context.subagentsByToolUseId.get(toolResult.tool_use_id); + // Session scope: while the root is idle, this context is the turn + // that settled last, which never saw a subagent an earlier turn + // launched (a nested one whose Agent call returns now). + const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); // A resume task_started reuses the resuming tool call's // tool_use_id (e.g. SendMessage), whose tool_result only // acknowledges delivery. Only the Agent launch's tool_result may // terminalize the subagent, and Agent tool_uses never enter // toolCalls (they project as subagent rows instead). - if (subagent !== undefined && !context.toolCalls.has(toolResult.tool_use_id)) { + if ( + subagent !== undefined && + findToolCall(context, toolResult.tool_use_id) === undefined + ) { // A background Agent launch resolves its tool_use immediately // with an async-launch ACK while the task keeps running; only // the eventual task_notification terminalizes the subagent. @@ -6226,7 +6409,7 @@ export function makeClaudeAdapterV2( } const parentToolUseId = parentToolUseIdFromSdkMessage(message); const toolCall = - context.toolCalls.get(toolResult.tool_use_id) ?? + findToolCall(context, toolResult.tool_use_id) ?? (yield* ensureToolCallStarted({ context, nativeItemId: toolResult.tool_use_id, @@ -6263,7 +6446,7 @@ export function makeClaudeAdapterV2( presentation: toolCall.presentation, }); yield* emitToolCallArtifacts(artifacts); - context.toolCalls.delete(toolCall.nativeItemId); + toolCallsFor(context, toolCall).delete(toolCall.nativeItemId); } const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message); @@ -6730,7 +6913,13 @@ export function makeClaudeAdapterV2( // ExitPlanMode plan) in whichever run it is released to. const heldForEcho = context.heldRootFrames.length > 0; if (toolName === "Agent") { - rememberClaudeSubagentLaunch(context, nativeRequestId, nativeToolInput, null); + rememberClaudeSubagentLaunch( + context, + pendingSubagentLaunchesByToolUseId, + nativeRequestId, + nativeToolInput, + null, + ); } else if (!heldForEcho) { yield* ensureToolCallStarted({ context, @@ -7235,6 +7424,10 @@ export function makeClaudeAdapterV2( yield* finalizeActiveTurnAfterQueryExit( exit._tag === "Failure" ? exit.cause : undefined, ); + yield* endSubagentCallsOfClosedQuery( + context.nativeThreadId, + context.stopping ? "interrupted" : "failed", + ); } }), ), @@ -7280,6 +7473,7 @@ export function makeClaudeAdapterV2( } const context: ActiveClaudeTurnContext = { input: turnInput, + nativeThreadId, nativeTurnId, nativeMessageCursor: null, providerTurnId, @@ -7309,7 +7503,6 @@ export function makeClaudeAdapterV2( subagentsByTaskId: new Map(), subagentsByToolUseId: new Map(), subagentNodesByTaskId: new Map(), - pendingSubagentLaunchesByToolUseId: new Map(), promptUuid, promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending", gatedFramesBeforeEcho: 0, @@ -7598,9 +7791,14 @@ export function makeClaudeAdapterV2( providerSessionId: input.providerSessionId, nativeThreadId, }); - yield* Ref.update(queryContext, (current) => - current?.query === existing.query ? null : current, + // Once ownership is dropped here, the old stream's exit skips the + // subagent's open calls, so they end now. + const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => + current?.query === existing.query ? [true, null] : [false, current], ); + if (ownedLiveQuery) { + yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted"); + } yield* Deferred.succeed(existing.closed, undefined); }); yield* Effect.addFinalizer(() => closeSession()); diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index dd417ef162e0..56c823810413 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -27,6 +27,7 @@ import { messageRestartInput } from "./fixtures/message_steering/input.ts"; import { assertProviderNativeSubagentRootTurns, materializeFixtureInput, + projectionFor, type OrchestratorFixtureInput, type ProviderOrchestratorReplayVariant, } from "./fixtures/shared.ts"; @@ -286,6 +287,165 @@ describe("orchestrator replay fixtures", () => { }), }), ); + + // A background subagent opens a WebFetch or Write, then stops without + // returning its result. The continuation that drains the stop must leave the + // child's tool row and node terminal, whether the call opened before the + // root settled or while it was idle. + const afterRootFixture = ORCHESTRATOR_REPLAY_FIXTURES.find( + (candidate) => candidate.name === "claude_background_subagent_after_root", + ); + const afterRootProvider = afterRootFixture?.providers[0]; + if (afterRootFixture !== undefined && afterRootProvider !== undefined) { + const OPEN_TOOL_ID = "toolu_01QDa5jV5g1H9h6QyDfeJohD"; + const SUBAGENT_TASK_ID = "a2995bfced8019363"; + + const moveOpenToolBeforeRootResult = ( + move: boolean, + entries: ReadonlyArray, + ): ReadonlyArray => { + if (!move) return entries; + const toolIndex = entries.findIndex( + (entry) => + entry.type === "emit_inbound" && + (entry.frame as Record).message?.content?.[0]?.id === OPEN_TOOL_ID, + ); + const resultIndex = entries.findIndex( + (entry) => entry.type === "emit_inbound" && entry.label === "result", + ); + if (toolIndex < 0 || resultIndex < 0) throw new Error("transcript shape changed"); + const toolEntry = entries[toolIndex]!; + const rest = entries.filter((_, index) => index !== toolIndex); + return [...rest.slice(0, resultIndex), toolEntry, ...rest.slice(resultIndex)]; + }; + it.effect.each( + (["WebFetch", "Write"] as const).flatMap((tool) => + (["before root settles", "while root is idle"] as const).flatMap((opens) => + (["stopped", "failed"] as const).map((status) => [tool, opens, status] as const), + ), + ), + )( + "ends a background subagent's open %s (opened %s) when its notification is %s", + ([tool, opens, notificationStatus]) => + runFixtureProviderWithRegisteredHarness({ + fixtureName: afterRootFixture.name, + buildInput: afterRootFixture.buildInput, + driver: { + ...afterRootProvider, + assertOutput: (result) => { + const parent = projectionFor(result, afterRootFixture.name); + assert.deepEqual( + parent.runs.map((run) => run.status), + ["completed", "completed"], + ); + const subagent = parent.subagents[0]; + assert.equal( + subagent?.status, + notificationStatus === "stopped" ? "cancelled" : "failed", + ); + const childThreadId = subagent?.childThreadId; + assert.exists(childThreadId); + const child = result.projections.get(childThreadId); + assert.exists(child); + const fetches = child.turnItems.filter( + (item) => item.type === (tool === "WebFetch" ? "web_search" : "file_change"), + ); + assert.lengthOf(fetches, 1, `the ${tool} reached the child thread`); + // The stored tool row and every child node end terminal. + assert.deepEqual( + { + openTool: fetches.map((item) => item.status), + openChildNodes: child.nodes + .filter( + (node) => + node.status === "running" || + node.status === "pending" || + node.status === "waiting", + ) + .map((node) => `${node.kind}:${node.status}`), + }, + { + openTool: [notificationStatus === "stopped" ? "interrupted" : "failed"], + openChildNodes: [], + }, + ); + }, + }, + transformTranscript: (transcript) => ({ + ...transcript, + entries: moveOpenToolBeforeRootResult( + opens === "before root settles", + transcript.entries.flatMap((entry): ReadonlyArray => { + if (entry.type !== "emit_inbound") return [entry]; + const frame = entry.frame as Record; + // The subagent's second step becomes a call that never returns. + if ( + frame.type === "assistant" && + frame.message?.content?.[0]?.id === OPEN_TOOL_ID + ) { + return [ + { + ...entry, + frame: { + ...frame, + message: { + ...frame.message, + content: [ + { + ...frame.message.content[0], + name: tool, + input: + tool === "WebFetch" + ? { url: "https://example.com", prompt: "Summarize" } + : { file_path: "notes.txt", content: "SUB_DONE_2" }, + }, + ], + }, + }, + }, + ]; + } + // Drop the Bash background task, the tool result, and the final report. + if ( + (frame.type === "system" && + (frame.subtype === "task_started" || frame.subtype === "task_notification") && + frame.tool_use_id === OPEN_TOOL_ID) || + (frame.type === "user" && + frame.message?.content?.[0]?.tool_use_id === OPEN_TOOL_ID) || + (frame.type === "assistant" && + frame.parent_tool_use_id !== null && + frame.message?.content?.[0]?.text === "SUB_FINAL_REPORT") + ) { + return []; + } + if (frame.type === "system" && frame.subtype === "task_updated") { + return [ + { + ...entry, + frame: { + ...frame, + patch: { + ...frame.patch, + status: notificationStatus === "stopped" ? "killed" : "failed", + }, + }, + }, + ]; + } + if ( + frame.type === "system" && + frame.subtype === "task_notification" && + frame.task_id === SUBAGENT_TASK_ID + ) { + return [{ ...entry, frame: { ...frame, status: notificationStatus } }]; + } + return [entry]; + }), + ), + }), + }), + ); + } }); /** The same event with an envelope this build cannot decode, as a newer OpenCode may send. */ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts index 48c8931aa1da..ef848f1b3ec4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts @@ -14,10 +14,10 @@ export function claudeBackgroundSubagentAfterRootInput(): OrchestratorFixtureInp return { steps: [ { type: "message", text: CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT }, - // The subagent's frames all arrive after the root settles, so they wait - // in the wake buffer; its completion starts continuation run 2, which - // drains them. The wake result is held until run 2 has drained the - // buffered wake reply, so the recording cannot end the session first. + // The subagent's frames all arrive after the root settles. They are + // stored as they arrive; its completion starts continuation run 2, + // which drains the wake reply. The wake result is held until run 2 has + // drained that reply, so the recording cannot end the session first. { type: "await_run_status", targetRunIndex: 2, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts index 66ff70725954..c2a9f4fba2c5 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/output.ts @@ -1,5 +1,9 @@ import { assert } from "@effect/vitest"; -import type { OrchestrationV2ThreadProjection, ProviderReplayTranscript } from "@t3tools/contracts"; +import type { + OrchestrationV2DomainEvent, + OrchestrationV2ThreadProjection, + ProviderReplayTranscript, +} from "@t3tools/contracts"; import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; import { @@ -12,6 +16,11 @@ import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "./input.ts"; const SUBAGENT_TEXTS = ["SUB_STEP_1", "SUB_STEP_2", "SUB_FINAL_REPORT"]; const SUBAGENT_COMMANDS = ["sleep 3 && echo SUB_DONE_1", "sleep 3 && echo SUB_DONE_2"]; +// The subagent's own task_progress, one per step, as the recording reports it. +const SUBAGENT_PROGRESS = [ + "Running Sleep 3 seconds then echo SUB_DONE_1", + "Running Sleep 3 seconds then echo SUB_DONE_2", +]; function assistantTexts(projection: OrchestrationV2ThreadProjection): ReadonlyArray { return projection.turnItems.flatMap((item) => @@ -26,8 +35,8 @@ function commandTexts(projection: OrchestrationV2ThreadProjection): ReadonlyArra } // Every frame the background subagent emits arrives after the root turn's -// result, so it reaches the adapter through the wake buffer and drains into -// the continuation turn. None of it may land in the parent thread. +// result, while the root is idle. Its work reaches its child thread as it +// runs, before its end wakes the root, and none of it lands in the parent. export function assertClaudeBackgroundSubagentAfterRootOutput( result: OrchestratorV2ScenarioResult, transcript: ProviderReplayTranscript, @@ -97,4 +106,62 @@ export function assertClaudeBackgroundSubagentAfterRootOutput( `subagent command ${command} must be in the child thread`, ); } + // The subagent works while the root is idle, so its steps and progress are + // stored as they happen. Its end starts continuation run 2, which drains + // the wake buffer; none of the subagent's work may wait there for it. + const continuationStartIndex = result.domainEvents.findIndex( + (event) => + event.type === "run.updated" && + event.payload.id === projection.runs[1]?.id && + event.payload.status === "starting", + ); + assert.isAtLeast(continuationStartIndex, 0); + const assertStoredWhileIdle = ( + label: string, + matches: (event: OrchestrationV2DomainEvent) => boolean, + ) => { + const index = result.domainEvents.findIndex(matches); + assert.isAtLeast(index, 0, `${label} is stored`); + assert.isBelow(index, continuationStartIndex, `${label} is stored before the root wakes`); + }; + for (const text of SUBAGENT_TEXTS) { + assertStoredWhileIdle( + `subagent text ${text}`, + (event) => + event.type === "message.updated" && + event.payload.threadId === subagent.childThreadId && + event.payload.text.trim() === text, + ); + } + for (const command of SUBAGENT_COMMANDS) { + assertStoredWhileIdle( + `completed subagent command ${command}`, + (event) => + event.type === "turn-item.updated" && + event.payload.threadId === subagent.childThreadId && + event.payload.type === "command_execution" && + event.payload.status === "completed" && + event.payload.input.includes(command), + ); + } + for (const progress of SUBAGENT_PROGRESS) { + assertStoredWhileIdle( + `subagent progress "${progress}"`, + (event) => + event.type === "subagent.updated" && + event.payload.id === subagent.id && + event.payload.progress === progress, + ); + } + // The drain stores nothing in the child thread, so nothing is stored twice. + assert.isFalse( + result.domainEvents + .slice(continuationStartIndex) + .some( + (event) => + (event.type === "message.updated" || event.type === "turn-item.updated") && + event.payload.threadId === subagent.childThreadId, + ), + "the continuation run stores none of the subagent's work", + ); } From 8d564a3e5c77d283994119eca82881f8c37a4a4d Mon Sep 17 00:00:00 2001 From: Charlie J Date: Wed, 7 Oct 2026 18:06:29 +0100 Subject: [PATCH 005/530] fix(mobile): hide threads from switched-off environments (#16886) --- .../src/state/threadShell.test.ts | 24 +++++++++++++++++++ .../client-runtime/src/state/threadShell.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 74c93edc3a9a..d3653d30035d 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -103,6 +103,30 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("leaves switched-off environments out of navigation", () => { + const { registry, threads, catalogValueAtom } = makeHarness([ + environmentId, + remoteEnvironmentId, + ]); + const dispose = registry.mount(threads.navigationThreadShellsAtom); + const environmentsOf = () => + new Set(registry.get(threads.navigationThreadShellsAtom).map((t) => t.environmentId)); + expect(environmentsOf()).toEqual(new Set([environmentId, remoteEnvironmentId])); + + const setRemoteEnabled = (enabled: boolean) => { + const catalog = registry.get(catalogValueAtom); + const entries = new Map(catalog.entries); + entries.set(remoteEnvironmentId, { ...entries.get(remoteEnvironmentId)!, enabled }); + registry.set(catalogValueAtom, { ...catalog, entries }); + }; + setRemoteEnabled(false); + expect(environmentsOf()).toEqual(new Set([environmentId])); + setRemoteEnabled(true); + expect(environmentsOf()).toEqual(new Set([environmentId, remoteEnvironmentId])); + dispose(); + registry.dispose(); + }); + it("shares point and list values without retaining an atom for every listed thread", () => { const harness = makeHarness(); const snapshot = { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index a59fbb2375d3..bc6d444f471a 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -202,7 +202,7 @@ export function createEnvironmentThreadShellAtoms(input: { let previousNavigationShells: ReadonlyArray = []; const navigationThreadShellsAtom = Atom.make((get) => { const next: EnvironmentThreadShell[] = []; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { for (const thread of get(environmentThreadsAtom(environmentId))) { if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") continue; From 87c366b1206b13701fbcc38e459d457f0f9da2df Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:14:57 -0400 Subject: [PATCH 006/530] fix(server): a refused Claude turn no longer throws away its session (#16287) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ContextHandoffDelivery.ts | 15 ++- .../ProviderTurnStartService.ts | 22 +++- .../ProviderSwitch.integration.test.ts | 114 +++++++++++++++++- 3 files changed, 144 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 2ef3a5a5cd89..52a80448f8c3 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -7,7 +7,11 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { historyCost, renderHistory, selectHistory } from "./ContextHandoffBudget.ts"; -/** Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. */ +/** + * Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. + * Run `unsent` when the provider refused the turn before reading the prompt, so that + * refusal does not leave an ambiguous marker behind. + */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { readonly handoffs: ReadonlyArray; @@ -28,7 +32,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH handoff.delivery.status === "pending", ); if (pending.length === 0 || (input.deferInline && input.inject === undefined)) - return { context: "", delivered: Effect.void }; + return { context: "", delivered: Effect.void, unsent: Effect.void }; const budget = typeof input.budget === "number" ? input.budget : yield* input.budget; let coverage = pending .map( @@ -71,7 +75,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH budget, }); if (historyCost(selected.messages, selected.context) > budget) { - if (input.deferInline) return { context: "", delivered: Effect.void }; + if (input.deferInline) return { context: "", delivered: Effect.void, unsent: Effect.void }; return yield* new ContextHandoffBudgetError(); } const omittedItemIds = new Set(selected.omittedItemIds); @@ -122,7 +126,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH }); if (injected) { yield* persist("injected"); - return { context: "", delivered: Effect.void }; + return { context: "", delivered: Effect.void, unsent: Effect.void }; } } else { // Text-only delivery can also be accepted before a connection drops. @@ -132,11 +136,12 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH // Compaction APIs cannot accept an inline transcript. Keep it available // for the next ordinary turn when native injection is unsupported. yield* Effect.forEach(pending, input.persist, { discard: true }); - return { context: "", delivered: Effect.void }; + return { context: "", delivered: Effect.void, unsent: Effect.void }; } return { context: renderHistory(selected.messages, selected.context), delivered: persist("inline"), + unsent: Effect.forEach(pending, input.persist, { discard: true }), }; }, ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 086abc6c58eb..227aab2256e2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -64,6 +65,11 @@ export class ProviderTurnStartError extends Schema.TaggedError + Predicate.isTagged(error, "ClaudeBackgroundWorkBlocksQueryReplacementError") || + (Predicate.hasProperty(error, "cause") && refusedBeforePrompt(error.cause)); + export interface ProviderTurnStartServiceV2Shape { /** * Starts the run's provider turn. When `willRetry` is true, a session open @@ -1176,7 +1182,21 @@ export const layer: Layer.Layer< ...turnInput.message, text: context === "" ? userText : `${context}\n\nUser message:\n${userText}`, }, - }); + }).pipe( + // A pending marker would make the next turn abandon this native + // session, though the refused prompt never reached it. + Effect.tapError((error) => + refusedBeforePrompt(error) + ? delivery.unsent.pipe( + Effect.catchCause(() => + Effect.logWarning("Failed to restore unsent context handoffs", { + runId: run.id, + }), + ), + ) + : Effect.void, + ), + ); // The provider already accepted the turn. A stale pending marker // can force a fresh thread later, but must not stop live ingestion. yield* delivery.delivered.pipe( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index d55f74ce9c83..9bfb4bf154d6 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -34,7 +34,10 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; -import { ClaudeProviderCapabilitiesV2 } from "../Adapters/ClaudeAdapterV2.ts"; +import { + ClaudeBackgroundWorkBlocksQueryReplacementError, + ClaudeProviderCapabilitiesV2, +} from "../Adapters/ClaudeAdapterV2.ts"; import { CodexProviderCapabilitiesV2, canReuseCodexContextUsage, @@ -53,6 +56,7 @@ import { type ProviderAdapterV2Event, type ProviderAdapterV2HistoricalContext, ProviderAdapterProtocolError, + ProviderAdapterTurnStartError, type ProviderAdapterV2Shape, type ProviderAdapterV2SessionRuntime, } from "../ProviderAdapter.ts"; @@ -104,6 +108,8 @@ function makeTestAdapter(input: { readonly capturedTurns: Ref.Ref>; readonly injectedHistory?: Ref.Ref>; readonly failStartOnce?: Ref.Ref; + /** Starts left to refuse the way Claude does while background work runs. */ + readonly refuseStarts?: Ref.Ref; readonly failInjectionOnce?: Ref.Ref; readonly nativeThreadGeneration?: Ref.Ref; readonly failResume?: boolean; @@ -226,6 +232,17 @@ function makeTestAdapter(input: { (yield* Ref.getAndSet(input.failStartOnce, false)) ) return yield* unimplemented(input.driver, "turn start failed after injection"); + if ( + input.refuseStarts !== undefined && + (yield* Ref.getAndUpdate(input.refuseStarts, (left) => Math.max(0, left - 1))) > 0 + ) + return yield* new ProviderAdapterTurnStartError({ + driver: input.driver, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause: new ClaudeBackgroundWorkBlocksQueryReplacementError(), + }); yield* Effect.yieldNow; yield* Ref.update(input.capturedTurns, (turns) => [ ...turns, @@ -1188,6 +1205,101 @@ describe("orchestration v2 provider switching", () => { ), ); + it.live("keeps the native session after turns refused before reaching the provider", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("refused-start-keeps-native"); + const capturedTurns = yield* Ref.make>([]); + const refuseStarts = yield* Ref.make(0); + const generation = yield* Ref.make(0); + const registry = ProviderAdapterRegistry.makeLayer([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + refuseStarts, + nativeThreadGeneration: generation, + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const run = Effect.fn("run")(function* (ordinal: number) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`refused-start:${ordinal}`), + threadId, + messageId: MessageId.make(`refused-start:${ordinal}`), + createdBy: "user", + creationSource: "web", + text: `Request ${ordinal}`, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator.streamStoredEvents.pipe( + Stream.filter( + ({ event }) => + event.type === "run.updated" && + event.payload.ordinal === ordinal && + (event.payload.status === "completed" || event.payload.status === "failed"), + ), + Stream.runHead, + ); + yield* worker.drain(); + return (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status; + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("refused-start:create"), + threadId, + projectId, + createdBy: "user", + creationSource: "web", + title: "Refused start", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + assert.equal(yield* run(1), "completed"); + // Run 3 carries run 2's missed request as a handoff and is refused too. + yield* Ref.set(refuseStarts, 2); + assert.equal(yield* run(2), "failed"); + assert.equal(yield* run(3), "failed"); + assert.equal(yield* run(4), "completed"); + + const turns = yield* Ref.get(capturedTurns); + assert.equal(turns.length, 2); + // Nothing reached the provider, so the next turn continues the same + // native session instead of replacing it with a summary. + assert.equal(yield* Ref.get(generation), 1); + assert.equal(turns[1]?.nativeThreadId, turns[0]?.nativeThreadId); + assert.include(turns[1]?.text, "Request 2"); + assert.include(turns[1]?.text, "Request 3"); + }).pipe( + Effect.provide( + makeOrchestratorV2ReplayLayerWithRegistry( + { + name: "refused-start-keeps-native", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + }, + registry, + ), + ), + ); + }), + ), + ); + it.live.each( (["failed", "interrupted"] as const).flatMap((status) => [false, true].flatMap((queued) => From db46c42744874ca123bc170a90c1faa312f20119 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 10:21:24 -0700 Subject: [PATCH 007/530] fix(web): onboarding Continue no longer locks on computers that won't connect (#16887) Co-authored-by: Claude Opus 5.5 (1M context) --- .../onboarding/WelcomeWizard.import.test.tsx | 2 +- .../WelcomeWizard.terminal.test.tsx | 2 + .../onboarding/WelcomeWizard.test.tsx | 1 + .../components/onboarding/WelcomeWizard.tsx | 37 ++++++++------- .../targetEnvironment.logic.test.ts | 47 +++++++++++++++++++ .../src/onboarding/targetEnvironment.logic.ts | 28 +++++++++++ 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx index a9403887d9e8..8b052d76168a 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx @@ -64,7 +64,7 @@ vi.mock("../../state/environments", () => { environmentId, label: environmentId, connection: { phase: "connected" }, - entry: { target: { _tag: "DirectConnectionTarget" } }, + entry: { enabled: true, target: { _tag: "DirectConnectionTarget" } }, }); const primary = environment("primary"); return { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx index e59a837d9adf..cf7c38ec5875 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx @@ -151,6 +151,7 @@ const primaryEnvironment = { label: "This computer", connection: { phase: "connected" }, entry: { + enabled: true, target: new PrimaryConnectionTarget({ environmentId: primaryId, label: "This computer", @@ -164,6 +165,7 @@ const remoteEnvironment = { label: "Paired computer", connection: { phase: "connected" }, entry: { + enabled: true, target: new BearerConnectionTarget({ environmentId: remoteId, label: "Paired computer", diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index 9c357213c369..439a2cc579de 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -38,6 +38,7 @@ vi.mock("../../state/environments", () => { environmentId: "test-env", label: "Computer", connection: { phase: "connected" }, + entry: { enabled: true }, }; return { useEnvironments: () => ({ environments: [environment] }), diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 9d1c96e19eed..e94078271799 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -58,7 +58,10 @@ import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; import { readProjects, useProjects } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; -import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment.logic"; +import { + isOnboardingRelayEnvironment, + resolveOnboardingSetup, +} from "../../onboarding/targetEnvironment.logic"; import { useProjectScans } from "../../onboarding/useProjectScans"; import { projectEnvironment } from "../../state/projects"; import { serverEnvironment } from "../../state/server"; @@ -78,6 +81,7 @@ import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { presentSavedCloudEnvironmentConnection } from "../cloud/cloudEnvironmentConnectionPresentation"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { T3Wordmark } from "../T3Wordmark"; import { Alert, AlertDescription } from "../ui/alert"; @@ -141,11 +145,14 @@ export function WelcomeWizard({ for (const environment of newComputers) { autoSelectedComputers.current.add(environment.environmentId); } + // A computer the user switched off stays unselected until they pick it. + const enabledComputers = newComputers.filter((environment) => environment.entry.enabled); + if (enabledComputers.length === 0) return; setSelection( (current) => new Set([ ...(current ?? []), - ...newComputers.map((environment) => environment.environmentId), + ...enabledComputers.map((environment) => environment.environmentId), ]), ); }, [environments]); @@ -262,11 +269,7 @@ export function WelcomeWizard({ }) } onContinue={() => - startSetup( - environments - .filter((environment) => selectedIds.has(environment.environmentId)) - .map((environment) => environment.environmentId), - ) + startSetup(resolveOnboardingSetup(environments, selectedIds).environmentIds) } onPaired={(environmentId) => { setSelection(new Set([...selectedIds, environmentId])); @@ -315,14 +318,10 @@ function ConnectionStep({ ); const [pairingOpen, setPairingOpen] = useState(expandPairingInitially); const [isPairing, setIsPairing] = useState(false); - const ready = - selectedIds.size > 0 && - [...selectedIds].every((id) => - environments.some( - (environment) => - environment.environmentId === id && environment.connection.phase === "connected", - ), - ); + const { ready, skippedIds } = resolveOnboardingSetup(environments, selectedIds); + const skippedLabels = environments + .filter((environment) => skippedIds.includes(environment.environmentId)) + .map((environment) => environment.label); const continueRef = useRef(null); useEffect(() => { if ( @@ -365,7 +364,7 @@ function ConnectionStep({ {environment.label} - {environment.connection.phase === "connected" ? "Connected" : "Connectingโ€ฆ"} + {presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel} {environment.displayUrl ? ( @@ -421,6 +420,12 @@ function ConnectionStep({
    + {skippedLabels.length > 0 ? ( +

    + Not connected, so setup skips {skippedLabels.join(", ")}. You can set{" "} + {skippedLabels.length === 1 ? "it" : "them"} up later from Settings. +

    + ) : null}

    T3 Code collects anonymous usage data to help us improve it. To read more about how your diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts index 9928f83b27db..c92f4cf4441c 100644 --- a/apps/web/src/onboarding/targetEnvironment.logic.test.ts +++ b/apps/web/src/onboarding/targetEnvironment.logic.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { isOnboardingRelayEnvironment, + resolveOnboardingSetup, resolveOnboardingTargetEnvironment, } from "./targetEnvironment.logic"; @@ -209,3 +210,49 @@ describe("isOnboardingRelayEnvironment", () => { ).toEqual([olderRelay]); }); }); + +describe("resolveOnboardingSetup", () => { + const computer = (id: string, phase: string) => ({ + environmentId: EnvironmentId.make(id), + connection: { phase }, + }); + const local = computer("local", "connected"); + + it("skips selected computers that will not connect on their own", () => { + const environments = [ + local, + computer("switched-off", "available"), + computer("offline", "offline"), + computer("failing", "reconnecting"), + computer("old-client", "unsupported"), + ]; + expect( + resolveOnboardingSetup( + environments, + new Set(environments.map((environment) => environment.environmentId)), + ), + ).toEqual({ + ready: true, + environmentIds: [local.environmentId], + skippedIds: environments.slice(1).map((environment) => environment.environmentId), + }); + }); + + it("waits for a first connection attempt to settle", () => { + const connecting = computer("new", "connecting"); + expect( + resolveOnboardingSetup( + [local, connecting], + new Set([local.environmentId, connecting.environmentId]), + ).ready, + ).toBe(false); + }); + + it("is not ready without a connected selection", () => { + expect(resolveOnboardingSetup([local], new Set()).ready).toBe(false); + expect( + resolveOnboardingSetup([computer("off", "available")], new Set([EnvironmentId.make("off")])) + .ready, + ).toBe(false); + }); +}); diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts index 665590ec990d..d382ffe32f39 100644 --- a/apps/web/src/onboarding/targetEnvironment.logic.ts +++ b/apps/web/src/onboarding/targetEnvironment.logic.ts @@ -47,3 +47,31 @@ export function resolveOnboardingTargetEnvironment>, + selectedIds: ReadonlySet, +): { + readonly ready: boolean; + readonly environmentIds: ReadonlyArray; + readonly skippedIds: ReadonlyArray; +} { + const selected = environments.filter((environment) => selectedIds.has(environment.environmentId)); + const idsInPhase = (keep: (phase: string) => boolean) => + selected + .filter((environment) => keep(environment.connection.phase)) + .map((environment) => environment.environmentId); + const environmentIds = idsInPhase((phase) => phase === "connected"); + const settling = selected.some((environment) => environment.connection.phase === "connecting"); + return { + ready: environmentIds.length > 0 && !settling, + environmentIds, + skippedIds: idsInPhase((phase) => phase !== "connected" && phase !== "connecting"), + }; +} From d9068d38099fe5cb4f3fed4cb5455fee878db491 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:21:38 -0400 Subject: [PATCH 008/530] fix(server): worktree cleanup no longer deletes files hidden by showUntrackedFiles=no (#15834) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 33 ++++++++++++++++++-- apps/server/src/vcs/GitVcsDriverCore.ts | 4 ++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c6e24caa6250..1238477d4cbe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1351,7 +1351,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { _tag: "GitCommandError", operation: "GitVcsDriver.removeWorktree", command: "git", - argumentCount: 3, + argumentCount: 5, cwd, }); assert.notProperty(error, "cause"); @@ -3217,6 +3217,34 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps a worktree whose untracked files status is configured to hide", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "hidden"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/hidden", + }); + yield* git(cwd, ["config", "status.showUntrackedFiles", "no"]); + yield* writeTextFile(worktreePath, "notes.txt", "draft\n"); + assert.equal(yield* git(worktreePath, ["status", "--porcelain"]), ""); + + const result = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath })); + + assert.isTrue(Result.isFailure(result)); + const fileSystem = yield* FileSystem.FileSystem; + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "notes.txt")), + "draft\n", + ); + }), + ); + it.effect("allows worktree removal to run longer than the default command timeout", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -3225,8 +3253,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { if ( ChildProcess.isStandardCommand(command) && - command.args[0] === "worktree" && - command.args[1] === "remove" + command.args.join(" ").includes("worktree remove") ) { yield* Deferred.succeed(removalStarted, undefined); yield* Effect.sleep("31 seconds"); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 140786cce0a4..169325330961 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3743,7 +3743,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { - const args = ["worktree", "remove"]; + // Git refuses to remove a worktree with untracked files unless forced, but + // its check honors `status.showUntrackedFiles=no` and would delete them. + const args = ["-c", "status.showUntrackedFiles=normal", "worktree", "remove"]; if (input.force) { args.push("--force"); } From 93e864042cf066f4cdb3efe65dbefaaf0d168894 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Wed, 7 Oct 2026 18:21:43 +0100 Subject: [PATCH 009/530] fix(server): merged-worktree cleanup removes worktrees after squash merges (#14847) --- apps/server/src/git/GitManager.test.ts | 6 +++ apps/server/src/git/GitManager.ts | 5 ++ .../src/sourceControl/GitHubCli.test.ts | 9 +++- apps/server/src/sourceControl/GitHubCli.ts | 3 +- .../GitHubSourceControlProvider.test.ts | 2 + .../GitHubSourceControlProvider.ts | 1 + .../src/sourceControl/gitHubPullRequests.ts | 5 ++ apps/server/src/storageCleanup.test.ts | 52 ++++++++++++++++++- apps/server/src/storageCleanup.ts | 37 +++++++++++-- docs/user/project-settings.md | 5 +- packages/contracts/src/sourceControl.ts | 2 + 11 files changed, 119 insertions(+), 8 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index adef6ab42afe..61dd000633ef 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1368,6 +1368,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/t3code/pull/216", baseRefName: "main", headRefName: "feature/saved-branch", + headRefOid: "a".repeat(40), state: "OPEN", updatedAt: "2026-04-03T15:00:00Z", }, @@ -1387,6 +1388,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/t3code/pull/216", baseRef: "main", headRef: "feature/saved-branch", + headSha: "a".repeat(40), state: "open", closedAt: null, mergedAt: null, @@ -1664,6 +1666,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/220", baseRefName: "main", headRefName: "feature/shared-pr-cache", + headRefOid: "a".repeat(40), state: "MERGED", updatedAt: "2026-04-07T15:00:00Z", }, @@ -1675,6 +1678,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/221", baseRefName: "main", headRefName: "feature/shared-pr-cache", + headRefOid: "b".repeat(40), state: "OPEN", updatedAt: "2026-04-08T15:00:00Z", }, @@ -1691,6 +1695,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(status.pr?.state).toBe("merged"); expect(pullRequest?.state).toBe("merged"); + expect(pullRequest?.headSha).toBe("a".repeat(40)); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); const refreshed = yield* manager.branchPullRequest( { cwd: repoDir, branch: "feature/shared-pr-cache" }, @@ -1699,6 +1704,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(refreshed).toMatchObject({ number: 221, state: "open", + headSha: "b".repeat(40), repositoryKey: "github.com/pingdotgg/codething-mvp", }); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4f68f669fe6e..cbc0490a95c1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -95,6 +95,8 @@ export type GitBranchPullRequest = NonNullable & { readonly updatedAt: string | null; readonly closedAt?: string | null; readonly mergedAt?: string | null; + /** The pull request's head commit, when the host read reports it. */ + readonly headSha?: string | null; }; interface SourceControlTextGenerationSettings { @@ -201,6 +203,7 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { closedAt?: string | null; mergedAt?: string | null; updatedAt: Option.Option; + headSha?: string | undefined; } const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( @@ -460,6 +463,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, + ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), closedAt: summary.closedAt ?? null, @@ -2321,6 +2325,7 @@ export const make = Effect.gen(function* () { ...toStatusPr(latest), closedAt: latest.closedAt ?? null, mergedAt: latest.mergedAt ?? null, + headSha: latest.headSha ?? null, // Hosting CLIs can select an upstream repository instead of origin. // The returned PR URL names the repository that actually owns it. repositoryKey: pullRequestRepositoryKey(latest.url), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 7e2b03fb1446..3954617008a5 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -260,7 +260,12 @@ describe("GitHubCli.listPullRequestsByHead", () => { Effect.sync(() => { documents.push(input); return encodeJson({ - data: { repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } } }, + data: { + repository: { + h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, + h1: { nodes: [] }, + }, + }, }); }), }, @@ -288,8 +293,10 @@ describe("GitHubCli.listPullRequestsByHead", () => { first?.map((pr) => pr.number), [7], ); + assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); assert.deepStrictEqual(second, []); assert.strictEqual(documents.length, 1); + assert.include(documents[0]!.query, "headRefOid"); assert.deepStrictEqual(documents[0]!.variables, { owner: "acme", name: "web", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 72cb8e261c69..d12e5f334feb 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -183,6 +183,7 @@ export interface GitHubPullRequestSummary { readonly url: string; readonly baseRefName: string; readonly headRefName: string; + readonly headSha?: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; readonly closedAt?: string | null; @@ -337,7 +338,7 @@ type PullRequestListState = "open" | "closed" | "merged" | "all"; /** The pull request fields every read selects, in GraphQL. */ const PULL_REQUEST_NODE_SELECTION = - "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; + "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; const GRAPHQL_STATES: Record> = { open: ["OPEN"], closed: ["CLOSED"], diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index ea6624aef892..f3f7f31ed4e3 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -142,6 +142,7 @@ it.effect("lists change request history through the batched head lookup", () => url: "https://enterprise.test/acme/web/pull/7", baseRefName: "main", headRefName: "feature/merged", + headSha: "a".repeat(40), state: "merged", mergedAt: "2026-01-01T00:00:00Z", updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), @@ -171,6 +172,7 @@ it.effect("lists change request history through the batched head lookup", () => }); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40)); assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); assert.deepStrictEqual( changeRequests[0]?.updatedAt, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 0b868ba3f439..cf06c6efdfcd 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -48,6 +48,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, + ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), closedAt: summary.closedAt ?? null, diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 2309993f0300..6c89bee35fa2 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -13,6 +13,8 @@ export interface NormalizedGitHubPullRequestRecord { readonly url: string; readonly baseRefName: string; readonly headRefName: string; + /** The head commit, when the read asked for `headRefOid`. */ + readonly headSha?: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; readonly closedAt?: string | null; @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ url: TrimmedNonEmptyString, baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, + headRefOid: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), closedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -90,6 +93,7 @@ function normalizeGitHubPullRequestRecord( (headRepositoryOwnerLogin && headRepositoryName ? `${headRepositoryOwnerLogin}/${headRepositoryName}` : null); + const headSha = trimOptionalString(raw.headRefOid); return { number: raw.number, @@ -97,6 +101,7 @@ function normalizeGitHubPullRequestRecord( url: raw.url, baseRefName: raw.baseRefName, headRefName: raw.headRefName, + ...(headSha ? { headSha } : {}), state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), closedAt: raw.closedAt ?? null, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index e95d8401ae32..3bc9fb63465c 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -7,7 +7,11 @@ import { type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { storageCleanupActivityAt, storageCleanupThreadIdle } from "./storageCleanup.ts"; +import { + storageCleanupActivityAt, + storageCleanupPullRequestMerged, + storageCleanupThreadIdle, +} from "./storageCleanup.ts"; const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; @@ -104,3 +108,49 @@ describe("V2 storage cleanup eligibility", () => { return { ...candidate(), status }; } }); + +describe("merged pull request cleanup", () => { + const HEAD_SHA = "a".repeat(40); + const integrated = { + branch: "feature", + defaultBranch: "main", + headSha: HEAD_SHA, + integrated: true, + }; + const squashed = { ...integrated, integrated: false }; + const pullRequest = ( + overrides: Partial[0]>> = {}, + ) => ({ + state: "merged" as const, + headRef: "feature", + baseRef: "main", + headSha: HEAD_SHA, + ...overrides, + }); + + it("removes a worktree whose head reached the default branch through a merged pull request", () => { + expect(storageCleanupPullRequestMerged(pullRequest({ headSha: null }), integrated)).toBe(true); + }); + + it("removes a squash-merged worktree when the pull request names its exact head", () => { + expect(storageCleanupPullRequestMerged(pullRequest(), squashed)).toBe(true); + }); + + it.each([ + ["has a later commit than the merged head", { headSha: "c".repeat(40) }], + ["was merged into a release branch", { baseRef: "release" }], + ["was merged into its stack parent", { baseRef: "stack-parent" }], + ["was merged without a reported head commit", { headSha: null }], + ["belongs to a different branch", { headRef: "other" }], + ["is still open", { state: "open" }], + ["was closed without merging", { state: "closed" }], + ] as const)("keeps a squash worktree whose pull request %s", (_name, overrides) => { + expect(storageCleanupPullRequestMerged(pullRequest(overrides), squashed)).toBe(false); + }); + + it("keeps a worktree with no pull request, or one that is not merged", () => { + expect(storageCleanupPullRequestMerged(null, squashed)).toBe(false); + expect(storageCleanupPullRequestMerged(null, integrated)).toBe(false); + expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false); + }); +}); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index f9ff03a9ef7c..40ebd9cc3b44 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -106,6 +106,32 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu ); } +/** + * Whether the host's pull request proves this worktree's head was merged. A + * squash or rebase merge leaves the head outside the default branch, so the + * merged pull request then has to name this exact commit. + */ +export function storageCleanupPullRequestMerged( + pullRequest: Pick< + GitManager.GitBranchPullRequest, + "state" | "headRef" | "baseRef" | "headSha" + > | null, + worktree: { + readonly branch: string; + readonly defaultBranch: string; + readonly headSha: string; + readonly integrated: boolean; + }, +): boolean { + return ( + pullRequest?.state === "merged" && + (worktree.integrated || + (pullRequest.headRef === worktree.branch && + pullRequest.baseRef === worktree.defaultBranch && + pullRequest.headSha === worktree.headSha)) + ); +} + export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const settingsService = yield* Settings.ServerSettingsService; @@ -295,14 +321,19 @@ export const make = Effect.gen(function* () { args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha], allowNonZeroExit: true, }); - if (ancestor.exitCode !== 0) return; - eligible = settings.worktreeUnchanged; + const integrated = ancestor.exitCode === 0; + eligible = integrated && settings.worktreeUnchanged; if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { const pullRequest = yield* gitManager.branchPullRequest( { cwd: worktreePath, branch: thread.branch }, { refresh: true }, ); - eligible = pullRequest?.state === "merged"; + eligible = storageCleanupPullRequestMerged(pullRequest, { + branch: thread.branch, + defaultBranch: branch, + headSha: head.commitSha, + integrated, + }); } } if (!eligible) return; diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index e1e8aaaf2fbe..598ef5cb5e2e 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -145,8 +145,9 @@ Worktrees can be removed after a chosen number of inactive days, after merging, have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` prevent removal. Branches and thread history stay; starting another turn recreates the checkout. -Merge cleanup requires the commits to be included in the remote default branch, so squash merges -may need the inactivity rule instead. +Merge cleanup requires a merged pull request whose commits are included in the remote default +branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default +branch and the worktree is still at the pull request's last commit. Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last thread is deleted, including archived threads and worktrees left by earlier deletions. The diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index f9717b45c4c3..06934e93c740 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({ url: Schema.String, baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, + /** The head commit, when the provider's read includes it. */ + headSha: Schema.optional(TrimmedNonEmptyString), state: ChangeRequestState, /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), From e0201d3ab51b4593eff3c41d03ab9c9b2dc07471 Mon Sep 17 00:00:00 2001 From: ANSH SINGH Date: Wed, 7 Oct 2026 22:55:05 +0530 Subject: [PATCH 010/530] fix(server): free worktrees for terminal thread statuses (#15150) --- apps/server/src/storageCleanup.test.ts | 42 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 7 ++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 3bc9fb63465c..c66aee5302d6 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -3,6 +3,7 @@ import { ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; @@ -84,6 +85,47 @@ describe("V2 storage cleanup eligibility", () => { }, ); + it.each(["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"] as const)( + "allows cleanup once its thread is %s", + (status) => { + expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(true); + }, + ); + + it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( + "retains %s while background work is pending", + (status) => { + expect( + storageCleanupThreadIdle( + { + ...candidateWithStatus(status), + pendingBackgroundTasks: [{ taskId: "task-1", kind: "command" }], + }, + NOW_MS, + ), + ).toBe(false); + }, + ); + + it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( + "retains %s while a runtime request is pending", + (status) => { + expect( + storageCleanupThreadIdle( + { + ...candidateWithStatus(status), + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-1"), + kind: "command", + createdAt: at(0), + }, + }, + NOW_MS, + ), + ).toBe(false); + }, + ); + it("retains an active run even if the shell status is idle", () => { expect( storageCleanupThreadIdle({ ...candidate(), activeRunId: RunId.make("run") }, NOW_MS), diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 40ebd9cc3b44..d823e84e45dd 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -86,7 +86,12 @@ export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now thread.branch !== null && thread.worktreePath !== null && thread.activeRunId === null && - (thread.status === "idle" || thread.status === "failed") && + (thread.status === "idle" || + thread.status === "completed" || + thread.status === "interrupted" || + thread.status === "failed" || + thread.status === "cancelled" || + thread.status === "rolled_back") && (thread.pendingBackgroundTasks?.length ?? 0) === 0 && thread.pendingRuntimeRequest === null && !threadHasQueuedTurnStart(thread, now) From 4ce6a51a4c4bbc9a2fc9e371ecb446851f953482 Mon Sep 17 00:00:00 2001 From: Roger Maxwell Date: Wed, 7 Oct 2026 12:27:53 -0500 Subject: [PATCH 011/530] fix(server): Windows worktrees with long paths no longer fail or strand (#14917) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 92 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 57 ++++++++++-- 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 1238477d4cbe..a71226c06aee 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -35,6 +35,7 @@ import { makeGitVcsDriverCore, parseGitCheckoutProgressLine, splitNullSeparatedGitStdoutPaths, + windowsLongPathConfigEnv, } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -3955,3 +3956,94 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); }); }); + +describe("Windows long path configuration", () => { + const readGitConfig = Effect.fn("readGitConfig")(function* ( + platform: NodeJS.Platform, + key: string, + ) { + const layer = GitVcsDriver.layer.pipe( + Layer.provide(layerServerConfig), + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(HostProcessPlatform, platform)), + ); + return yield* Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir("git-longpath-test-"); + const result = yield* driver.execute({ + operation: "GitVcsDriverTest.readGitConfig", + cwd, + args: ["config", "--get", key], + // Replaces the suite's own core.longpaths=true so it cannot mask a + // missing injection. Entry 2 sits past the count and must stay unread. + env: { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "inherited-name", + GIT_CONFIG_KEY_1: "core.longpaths", + GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "user.name", + GIT_CONFIG_VALUE_2: "outside-count", + }, + }); + return result.stdout.trim(); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("enables long paths for every Git command on Windows", () => + Effect.gen(function* () { + assert.equal(yield* readGitConfig("win32", "core.longpaths"), "true"); + assert.equal(yield* readGitConfig("win32", "user.name"), "inherited-name"); + }), + ); + + it.effect("leaves Git config untouched on other platforms", () => + Effect.gen(function* () { + assert.equal(yield* readGitConfig("linux", "core.longpaths"), "false"); + assert.equal(yield* readGitConfig("linux", "user.name"), "inherited-name"); + }), + ); + + it("extends an inherited count spelled in a different case", () => { + // Two spellings collapse to one on spawn (the uppercase one wins), so the + // count must be bumped under the spelling that is already there. + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { git_config_count: "2" }), { + git_config_count: "3", + GIT_CONFIG_KEY_2: "core.longpaths", + GIT_CONFIG_VALUE_2: "true", + }); + }); + + it("appends after inherited entries, parsing the count as Git does", () => { + for (const [count, next] of [ + [undefined, 0], + ["", 0], + ["0", 0], + ["-0", 0], + ["2", 2], + [" 2", 2], + ["+2", 2], + ["02", 2], + ] as const) { + assert.deepStrictEqual( + windowsLongPathConfigEnv("win32", count === undefined ? {} : { GIT_CONFIG_COUNT: count }), + { + GIT_CONFIG_COUNT: String(next + 1), + [`GIT_CONFIG_KEY_${next}`]: "core.longpaths", + [`GIT_CONFIG_VALUE_${next}`]: "true", + }, + `GIT_CONFIG_COUNT=${JSON.stringify(count)}`, + ); + } + }); + + it("leaves a count Git would reject for Git to report", () => { + for (const count of ["nope", "2x", "-1", "1.5", " ", "1 "]) { + assert.deepStrictEqual( + windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: count }), + {}, + `GIT_CONFIG_COUNT=${JSON.stringify(count)}`, + ); + } + }); +}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 169325330961..002311fda2cf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -899,12 +899,52 @@ const collectOutput = Effect.fnUntraced(function* ( }; }); +/** + * Git on Windows cannot create or delete a path longer than MAX_PATH (260) + * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not + * cover it, and git leaves it off by default, so deep worktrees fail to check + * out and fail midway through removal. + * + * Passed through `GIT_CONFIG_*` rather than `-c` so argv stays the same on every + * platform and nothing is written to the user's config. The entry is appended + * after any inherited ones. Windows env names are case-insensitive, so the + * inherited count is found regardless of casing and updated under its own name. + * A count git would reject is left alone so git still reports it. + */ +export const windowsLongPathConfigEnv = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv => { + if (platform !== "win32") { + return {}; + } + const countKey = + Object.keys(env).find((key) => key.toUpperCase() === "GIT_CONFIG_COUNT") ?? "GIT_CONFIG_COUNT"; + const inherited = env[countKey]; + if ( + inherited !== undefined && + inherited !== "" && + // Git parses the count with strtoul: leading whitespace, an optional sign + // (only -0 is non-negative), digits, and nothing after them. + /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited + ) { + return {}; + } + const count = inherited === undefined || inherited === "" ? 0 : Number.parseInt(inherited, 10); + return { + [countKey]: String(count + 1), + [`GIT_CONFIG_KEY_${count}`]: "core.longpaths", + [`GIT_CONFIG_VALUE_${count}`]: "true", + }; +}; + export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig.ServerConfig; const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -929,18 +969,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ); + const env = { + ...process.env, + // Status polling runs beside the user's own git commands; without this, + // `git status` takes index.lock to save its refreshed index. + GIT_OPTIONAL_LOCKS: "0", + ...input.env, + ...trace2Monitor.env, + }; const child = yield* commandSpawner .spawn( ChildProcess.make("git", commandInput.args, { cwd: commandInput.cwd, - env: { - ...process.env, - // Status polling runs beside the user's own git commands; without this, - // `git status` takes index.lock to save its refreshed index. - GIT_OPTIONAL_LOCKS: "0", - ...input.env, - ...trace2Monitor.env, - }, + env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, }), ) .pipe( From 41985b44ff1b3a353ab805a31d4e4810842cbf2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 10:40:19 -0700 Subject: [PATCH 012/530] fix(server): main typechecks again after a test used renamed helpers (#16895) Co-authored-by: Claude Opus 5.5 (1M context) --- .../testkit/ProviderSwitch.integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 9bfb4bf154d6..40569de3c7b5 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -1212,7 +1212,7 @@ describe("orchestration v2 provider switching", () => { const capturedTurns = yield* Ref.make>([]); const refuseStarts = yield* Ref.make(0); const generation = yield* Ref.make(0); - const registry = ProviderAdapterRegistry.makeLayer([ + const registry = ProviderAdapterRegistry.layerFromAdapters([ makeTestAdapter({ instanceId: CLAUDE_MODEL_SELECTION.instanceId, driver: CLAUDE_DRIVER, @@ -1283,7 +1283,7 @@ describe("orchestration v2 provider switching", () => { assert.include(turns[1]?.text, "Request 3"); }).pipe( Effect.provide( - makeOrchestratorV2ReplayLayerWithRegistry( + ProviderReplayHarness.layerWithRegistry( { name: "refused-start-keeps-native", runtimePolicyOverride: { From f570bd21663f56ce94c41829d3b7d72886e25a34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 10:51:10 -0700 Subject: [PATCH 013/530] fix(server): Claude prompts no longer hang on a uuid the session already holds (#16897) Co-authored-by: saphid Co-authored-by: Claude Opus 5.5 (1M context) --- .../Adapters/ClaudeAdapterV2.test.ts | 217 +++++++++++++----- .../Adapters/ClaudeAdapterV2.testkit.ts | 7 +- .../Adapters/ClaudeAdapterV2.ts | 76 ++++-- 3 files changed, 220 insertions(+), 80 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b57dffad2c15..595fe28b1b9e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2229,6 +2229,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { processQueues, offerAndWait, offeredMessages, + // The uuid Claude echoes on the turn answering the nth offered prompt. + promptUuid: (index: number) => { + const uuid = offeredMessages[index]?.uuid; + if (uuid === undefined) throw new Error(`No prompt offered at index ${index}.`); + return uuid; + }, permissionModeChanges, continuationRequests, events, @@ -4421,12 +4427,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3"); const planToolUseId = "toolu_01WakePlanExitPlanMode"; const planMarkdown = "# Wake plan\n\n1. Report the background result."; - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); const planToolUse = claudeSdkFrame({ type: "assistant", @@ -4479,12 +4484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimePolicy, }), ); - yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt)); + yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4541,22 +4546,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { // The prompt's own turn follows and echoes its uuid. yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000743", text: "USER_REPLY", }), - userAttempt, + 1, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000744", result: "USER_REPLY", }), - userAttempt, + 1, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal"); @@ -4721,12 +4726,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1"); const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2"); const bashToolUseId = "toolu_01WakeApprovalBash"; - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -4741,16 +4745,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }), - firstAttempt, + 0, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4845,12 +4849,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1"); const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2"); - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -4865,16 +4868,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { // The first turn echoes on its first frame: this process echoes early. yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }), - firstAttempt, + 0, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4899,16 +4902,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }), - secondAttempt, + 1, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }), - secondAttempt, + 1, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); @@ -4944,12 +4947,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); const assistantTexts = () => harness.events.flatMap((event) => event.type === "message.updated" && event.message.role === "assistant" @@ -4969,22 +4971,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - assert.equal( - harness.offeredMessages[0]?.uuid, - yield* ClaudeAdapterV2.claudePromptUuid(firstAttempt), - ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), - firstAttempt, + 0, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -5018,6 +5016,118 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("gives the same run attempt a fresh prompt uuid on every offer", () => + Effect.scoped( + Effect.gen(function* () { + // Two servers sharing a database copy allocate identical run attempt + // ids and resume the same Claude session. Claude acks a prompt whose + // uuid its transcript already holds without ever running a turn. + const original = yield* makeWakeHarness; + const copy = yield* makeWakeHarness; + const now = yield* DateTime.now; + for (const harness of [original, copy]) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-shared-copy"), + text: "Continue where you left off.", + attachments: [], + }), + ); + } + assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("fails a prompt Claude completes without starting a turn for it", () => + Effect.scoped( + Effect.gen(function* () { + // Claude answers a prompt whose uuid its transcript already holds with + // a lone completed lifecycle frame and never runs a turn for it. + 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-claude-duplicate-prompt"), + text: "Bump this PR to the latest main.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "command_lifecycle", + command_uuid: harness.promptUuid(0), + state: "completed", + uuid: "00000000-0000-4000-8000-000000000790", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + const [terminal] = harness.terminalEvents(); + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.threadDisposition, "reusable"); + assert.include(terminal.failure.message, "never started a turn"); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("keeps a started prompt running until its result", () => + Effect.scoped( + 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-claude-started-prompt"), + text: "Bump this PR to the latest main.", + attachments: [], + }), + ); + const lifecycle = (state: string, uuid: string) => + claudeSdkFrame({ + type: "command_lifecycle", + command_uuid: harness.promptUuid(0), + state, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + yield* Queue.offer( + harness.sdkMessages, + lifecycle("queued", "00000000-0000-4000-8000-000000000791"), + ); + yield* Queue.offer( + harness.sdkMessages, + lifecycle("started", "00000000-0000-4000-8000-000000000792"), + ); + // The CLI can report the prompt completed before its turn's result. + yield* Queue.offer( + harness.sdkMessages, + lifecycle("completed", "00000000-0000-4000-8000-000000000793"), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000794", result: "Done." }), + user_message_uuid: harness.promptUuid(0), + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("buffers wake output and requests a single continuation run", () => Effect.scoped( Effect.gen(function* () { @@ -6031,12 +6141,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2"); const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3"); const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -6048,12 +6157,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt)); + yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -6120,16 +6229,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), wakeResult, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000776", text: "USER_REPLY", }), - userAttempt, + 1, ), - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }), - userAttempt, + 1, ), ]; for (const frame of wakeFrames) { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 2f73c0db11e6..3e70e3d9a8ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -1577,11 +1578,13 @@ async function recordClaudeStreamingQuery(input: { }; try { for (const [index, prompt] of input.prompts.entries()) { - // Like the adapter, give each prompt a uuid Claude echoes on its turn. + // Like the adapter, give each prompt a fresh uuid Claude echoes on its turn. const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt, uuid: await Effect.runPromise( - ClaudeAdapterV2.claudePromptUuid(`${input.sessionId}:prompt:${index + 1}`).pipe( + Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.filterOrFail(ClaudeAdapterV2.isClaudePromptUuid), Effect.provide(NodeServices.layer), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6cedbb153d80..26246b2038ba 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -71,7 +71,6 @@ import { import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Hex from "effect/encoding/Hex"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -1417,19 +1416,9 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: } satisfies SDKUserMessage; }); -// Stable per run attempt, so a replayed prompt offer matches its recording. -// Claude echoes it back as user_message_uuid on the turn that answers it. -export const claudePromptUuid = Effect.fn("claudePromptUuid")(function* (attemptId: string) { - const crypto = yield* Crypto.Crypto; - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`t3-claude-prompt:${attemptId}`)) - .pipe(Effect.orDie); - const hex = Hex.encode(digest); - const variant = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16); - const uuid: NonNullable = - `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; - return uuid; -}); +export function isClaudePromptUuid(value: string): value is NonNullable { + return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value); +} type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number]; type ClaudeToolUseContentBlock = Extract< @@ -2477,14 +2466,17 @@ function claudeEchoedPromptUuids(message: SDKMessage): ReadonlyArray { return typeof uuid === "string" ? [uuid] : []; } -// The prompt uuid a command_lifecycle frame (queued, started, completed) +// The prompt a command_lifecycle frame (queued, started, completed) // acknowledges. A CLI that sends them also echoes that uuid on the result of // the turn answering the prompt; one that does not keeps the old path. -function claudeAcknowledgedPromptUuid(message: SDKMessage): string | null { +function claudePromptLifecycle( + message: SDKMessage, +): { readonly uuid: string; readonly state: string } | null { const type: unknown = Reflect.get(message, "type"); if (type !== "command_lifecycle") return null; const uuid: unknown = Reflect.get(message, "command_uuid"); - return typeof uuid === "string" ? uuid : null; + const state: unknown = Reflect.get(message, "state"); + return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null; } // A result that answers a turn other than the pending prompt's. An echo @@ -2750,6 +2742,8 @@ interface ActiveClaudeTurnContext { // prompt's turn echoes this uuid (see handleSdkMessage). readonly promptUuid: string | null; promptEcho: "pending" | "confirmed"; + // Claude acknowledged starting the prompt (command_lifecycle "started"). + promptStarted: boolean; // Root frames seen before the echo; held only when the CLI echoes early. gatedFramesBeforeEcho: number; readonly heldRootFrames: Array; @@ -6801,11 +6795,32 @@ export function makeClaudeAdapterV2( yield* handleRoutedSdkMessage(input); return; } - if ( - liveQuery.promptEchoMode === "unknown" && - claudeAcknowledgedPromptUuid(message) === context.promptUuid - ) { - liveQuery.promptEchoMode = "acknowledged"; + const lifecycle = claudePromptLifecycle(message); + if (lifecycle?.uuid === context.promptUuid) { + if (liveQuery.promptEchoMode === "unknown") { + liveQuery.promptEchoMode = "acknowledged"; + } + if (lifecycle.state === "started") { + context.promptStarted = true; + } + // Claude finishes a prompt whose uuid its transcript already + // holds without starting it, and no turn will ever answer it. + if (lifecycle.state === "completed" && !context.promptStarted) { + yield* Effect.logWarning("orchestration-v2.claude-prompt-completed-without-turn", { + providerTurnId: context.providerTurnId, + promptUuid: context.promptUuid, + }); + yield* finalizeActiveTurn({ + context, + status: "failed", + completedAt: yield* DateTime.now, + failure: makeProviderFailure({ + class: "provider_error", + message: "Claude acknowledged the message but never started a turn for it.", + }), + }); + return; + } } if (!isClaudePromptEchoGatedFrame(message)) { // Frames the held turn produced through its own tool uses (a @@ -7442,10 +7457,22 @@ export function makeClaudeAdapterV2( const startedAt = yield* DateTime.now; const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); const nativeTurnId = `turn:${turnInput.attemptId}`; + // Fresh for every offer: Claude acks a prompt whose uuid its + // transcript already holds without running a turn, and a session + // can outlive this database (or be resumed from a copy of it), so + // a uuid derived from run ids can repeat. const promptUuid = isClaudeProviderContinuationTurn(turnInput) ? null - : yield* claudePromptUuid(turnInput.attemptId).pipe( - Effect.provideService(Crypto.Crypto, crypto), + : yield* crypto.randomUUIDv4.pipe( + Effect.filterOrFail(isClaudePromptUuid), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Failed to allocate a Claude prompt uuid.", + payload: cause, + }), + ), ); const providerTurnId = idAllocator.derive.providerTurn({ driver: CLAUDE_PROVIDER, @@ -7505,6 +7532,7 @@ export function makeClaudeAdapterV2( subagentNodesByTaskId: new Map(), promptUuid, promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending", + promptStarted: false, gatedFramesBeforeEcho: 0, heldRootFrames: [], }; From 588d32e22b10932404856d9bdd9f2f39c7e38070 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 11:30:29 -0700 Subject: [PATCH 014/530] feat(models): add Claude Haiku 5.5 and retire Sonnet 5 and Opus 5 to legacy (#16903) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/provider/model-manifest.json | 63 +++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 5e63f842ba30..da2ef052344d 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-02T19:45:00Z", + "updatedAt": "2026-10-07T18:00:00Z", "compatibility": [ { "driver": "codex", @@ -96,7 +96,7 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-sonnet-5"], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"], "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { @@ -666,6 +666,42 @@ } } }, + "haiku-5-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, "haiku-4-5": { "capabilities": { "optionDescriptors": [ @@ -685,7 +721,7 @@ { "slug": "claude-opus-5-5", "name": "Claude Opus 5.5", - "aliases": ["opus-5.5", "claude-opus-5.5"], + "aliases": ["opus", "opus-5.5", "claude-opus-5.5"], "status": "current", "badge": "new", "profile": "opus-5-5", @@ -694,7 +730,7 @@ { "slug": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5", - "aliases": ["sonnet-5.5", "claude-sonnet-5.5"], + "aliases": ["sonnet", "sonnet-5.5", "claude-sonnet-5.5"], "status": "current", "badge": "new", "profile": "sonnet-5", @@ -726,8 +762,8 @@ { "slug": "claude-opus-5", "name": "Claude Opus 5", - "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], - "status": "current", + "aliases": ["opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "legacy", "profile": "opus-5", "adapter": { "claudeCode": { @@ -775,8 +811,8 @@ { "slug": "claude-sonnet-5", "name": "Claude Sonnet 5", - "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], - "status": "current", + "aliases": ["sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "legacy", "profile": "sonnet-5" }, { @@ -786,10 +822,19 @@ "status": "legacy", "profile": "sonnet-4-6" }, + { + "slug": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "aliases": ["haiku", "haiku-5.5", "claude-haiku-5.5"], + "status": "current", + "badge": "new", + "profile": "haiku-5-5", + "adapter": { "claudeCode": { "minVersion": "2.1.293" } } + }, { "slug": "claude-haiku-4-5", "name": "Claude Haiku 4.5", - "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "aliases": ["haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], "status": "legacy", "profile": "haiku-4-5" } From ba0ea3d152150f399827d36a5b4b86c5bbda2d7f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:36:18 -0400 Subject: [PATCH 015/530] fix(web): iPhone Duo folds animate, center on the hinge, and keep the phone's orientation (#16885) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/device/DeviceDuoControls.tsx | 33 +++- .../components/device/DeviceDuoViewport.tsx | 2 +- .../src/device/duoControl.test.ts | 43 ++++- .../client-runtime/src/device/duoControl.ts | 25 +++ .../src/device/duoViewer.test.ts | 148 +++++++++++++++++- .../client-runtime/src/device/duoViewer.ts | 66 ++++++-- 6 files changed, 289 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/device/DeviceDuoControls.tsx b/apps/web/src/components/device/DeviceDuoControls.tsx index 57922a0243d3..824af5a644fc 100644 --- a/apps/web/src/components/device/DeviceDuoControls.tsx +++ b/apps/web/src/components/device/DeviceDuoControls.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import { duoFoldState, + duoHoldOrientation, type DuoCommand, type DuoControlState, } from "@t3tools/client-runtime/device/duo-control"; @@ -31,7 +33,18 @@ export function DeviceDuoControls(props: { onCommand: (command: DuoCommand) => void; }) { const { screen } = props; - const { fold, stand, phoneVertical } = duoFoldState(screen); + const { fold, stand, phoneVertical: reportedVertical, settled } = duoFoldState(screen); + // A fold never changes how the phone is held. Keep the last settled reading + // through display handoffs, whose interim orientation belongs to the other display. + const [settledVertical, setSettledVertical] = useState(reportedVertical); + if (settled && settledVertical !== reportedVertical) setSettledVertical(reportedVertical); + // Stands rotate the device. Folding out of one returns it to how it was held before. + const [standVertical, setStandVertical] = useState(settledVertical); + const phoneVertical = stand ? standVertical : settledVertical; + // Folding out of a stand sends the rotation back, then the fold, and only one command can + // wait. Hold the fold buttons until a stand, or anything queued on one, has landed; the + // rotation must also be read in the frame of the display the stand settles on. + const foldWaits = props.state.pending && (stand || props.state.requested?.control === "pose"); const foldLabels = { closed: "Closed", half: phoneVertical ? "Book" : "Laptop", @@ -43,6 +56,7 @@ export function DeviceDuoControls(props: { pressed: boolean, onClick: () => void, glyph: React.ReactNode, + waits = false, ) => ( props.onCommand({ control: "angle", value }), + () => { + if (stand) + props.onCommand({ + control: "orientation", + value: duoHoldOrientation(standVertical, screen.screenId), + }); + props.onCommand({ control: "angle", value }); + }, , + foldWaits, ), )}

    @@ -84,7 +106,10 @@ export function DeviceDuoControls(props: { id, label, screen.hingePose === id, - () => props.onCommand({ control: "pose", value: id }), + () => { + if (!stand) setStandVertical(settledVertical); + props.onCommand({ control: "pose", value: id }); + }, , ), )} diff --git a/apps/web/src/components/device/DeviceDuoViewport.tsx b/apps/web/src/components/device/DeviceDuoViewport.tsx index f168ceccc30d..125ff9a03cfc 100644 --- a/apps/web/src/components/device/DeviceDuoViewport.tsx +++ b/apps/web/src/components/device/DeviceDuoViewport.tsx @@ -127,7 +127,7 @@ export function DeviceDuoViewport(props: { (screenRef.current?.screenId === 1 ? 0 : 180), contains: (x, y) => !!screenRef.current && viewer.beginHinge(x, y), change: (angle) => { - viewer.setHingePreview(angle ?? previewRef.current); + viewer.setHingePreview(angle ?? previewRef.current, true); if (angle !== null) client.current?.controlDuo({ control: "angle", value: angle }); }, }); diff --git a/packages/client-runtime/src/device/duoControl.test.ts b/packages/client-runtime/src/device/duoControl.test.ts index f325d3a25de7..2755e1975939 100644 --- a/packages/client-runtime/src/device/duoControl.test.ts +++ b/packages/client-runtime/src/device/duoControl.test.ts @@ -1,5 +1,11 @@ import { afterEach, expect, it, vi } from "vite-plus/test"; -import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts"; +import { + createDuoControl, + createDuoPinch, + duoFoldState, + duoHoldOrientation, + type DuoCommand, +} from "./duoControl.ts"; afterEach(() => vi.useRealTimers()); it("keeps a failed send visible, including a disconnect while draining queued motion", () => { @@ -136,13 +142,27 @@ it.each([ ], ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true], ] as const)("reads a %s", (_name, screen, fold, phoneVertical) => { - expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical }); + expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical, settled: true }); +}); + +it("marks a display handoff unsettled while the cover reports the inner display's orientation", () => { + // Recorded from the iPhone Duo simulator opening a closed vertical phone to a book. + const handoff = [ + { screenId: 1, orientation: "portrait", hingeAngle: 90 }, + { screenId: 1, orientation: "landscape_left", hingeAngle: 90 }, + { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, + ] as const; + expect(handoff.map((screen) => duoFoldState(screen).settled)).toEqual([false, false, true]); + // Closing hands back to the cover the same way. + expect(duoFoldState({ screenId: 3, orientation: "landscape_left", hingeAngle: 0 }).settled).toBe( + false, + ); }); it("marks native stands so the fold group does not also claim them", () => { expect( duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }), - ).toEqual({ fold: "half", stand: true, phoneVertical: false }); + ).toEqual({ fold: "half", stand: true, phoneVertical: false, settled: true }); }); it("falls back like the 3D view when hinge fields are missing", () => { @@ -156,3 +176,20 @@ it("falls back like the 3D view when hinge fields are missing", () => { phoneVertical: true, }); }); + +it("rotates a stand back to how the phone was held, in the frame of the display receiving it", () => { + // Recorded from the iPhone Duo simulator: Tent can rest on either display. + // Rotating the inner display to landscape_left, or the cover to portrait, + // leaves a phone that opens and closes vertical. + expect(duoHoldOrientation(true, 3)).toBe("landscape_left"); + expect(duoHoldOrientation(true, 1)).toBe("portrait"); + // A quarter turn apart, these describe the same horizontal phone. + expect(duoHoldOrientation(false, 1)).toBe("landscape_left"); + expect(duoHoldOrientation(false, 3)).toBe("portrait_upside_down"); + for (const screenId of [1, 3]) + for (const vertical of [true, false]) + expect( + duoFoldState({ screenId, orientation: duoHoldOrientation(vertical, screenId) }) + .phoneVertical, + ).toBe(vertical); +}); diff --git a/packages/client-runtime/src/device/duoControl.ts b/packages/client-runtime/src/device/duoControl.ts index e5be353ca625..3a4026b97650 100644 --- a/packages/client-runtime/src/device/duoControl.ts +++ b/packages/client-runtime/src/device/duoControl.ts @@ -20,6 +20,17 @@ export type DuoControlState = { error: string | null; }; +/** + * Whether the reporting display is the one the fold shows: the cover when closed, the inner + * display when open. A fold across the closed position hands off in steps, and the outgoing + * display first reports the incoming one's orientation, so an unsettled screen's orientation + * does not say how the phone is held. Stands such as Tent can also rest unsettled on the cover. + */ +export function duoScreenSettled(screen: Pick) { + const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180); + return (screen.screenId === 1) === (angle === 0); +} + /** * The fold the device is in and the way it is held. Missing hinge fields fall back the same way * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a @@ -34,9 +45,23 @@ export function duoFoldState( fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half", stand: screen.hingePose === "laptop" || screen.hingePose === "tent", phoneVertical: screen.screenId === 1 ? !landscape : landscape, + settled: duoScreenSettled(screen), } as const; } +/** + * The rotation that holds the phone vertical or horizontal. Native rotation is + * read in the frame of the display active when it arrives, and the inner panel + * is mounted a quarter turn from the cover. + */ +export function duoHoldOrientation( + vertical: boolean, + screenId: number | undefined, +): DuoOrientation { + if (screenId === 1) return vertical ? "portrait" : "landscape_left"; + return vertical ? "landscape_left" : "portrait_upside_down"; +} + /** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */ export function createDuoControl(options: { send: (request: { requestId: number; command: DuoCommand }) => boolean; diff --git a/packages/client-runtime/src/device/duoViewer.test.ts b/packages/client-runtime/src/device/duoViewer.test.ts index b85d306bcfd6..abe3da650650 100644 --- a/packages/client-runtime/src/device/duoViewer.test.ts +++ b/packages/client-runtime/src/device/duoViewer.test.ts @@ -331,6 +331,108 @@ it("faces a display handed off by native rotation without requesting another sen viewer.dispose(); }); +// Recorded from the iPhone Duo simulator: a fold button across the closed +// position lets the cover report the inner display's orientation before handing +// off. A button's pending angle reaches the viewer as a hinge preview first. +it("presents fold buttons centered on the hinge: book, tablet, and an upright closed phone", async () => { + // Configurations arrive while the view is still easing; time only advances to settle. + let now = 0; + vi.stubGlobal("performance", { now: () => now }); + const { viewer, draw, state, onOrientationRequested } = fixture(false); + const loaded = asset(); + models.resolve({ asset: loaded, dispose: vi.fn() }); + await Promise.resolve(); + viewer.resize(500, 700, 2); + const cover = { width: 1398, height: 2034, screenId: 1, hingePose: null } as const; + const inner = { width: 2007, height: 2853, screenId: 3, hingePose: null } as const; + const press = ( + angle: number, + configs: ReadonlyArray<{ orientation: "portrait" | "landscape_left"; screenId: 1 | 3 }>, + ) => { + viewer.setHingePreview(angle); + configs.forEach((config, index) => { + viewer.setScreen({ + ...(config.screenId === 1 ? cover : inner), + ...config, + hingeAngle: angle, + }); + // The command reply follows the first configuration. + if (index === 0) viewer.setHingePreview(null); + draw(); + }); + now += 5_000; + draw(); + draw(); + }; + const expectInnerFacing = (minimum: number) => { + for (const name of ["inner-display-left", "inner-display-right"]) { + const mesh = loaded.getObjectByName(name) as Mesh; + const normal = new Vector3() + .fromBufferAttribute(mesh.geometry.getAttribute("normal"), 0) + .transformDirection(mesh.matrixWorld); + expect(normal.z).toBeGreaterThan(minimum); + } + }; + const upright = new Rotation().setFromEuler(new Euler(0, Math.PI / 2, 0, "YXZ")); + const closeAndExpectUpright = () => { + press(0, [ + { screenId: 3, orientation: "landscape_left" }, + { screenId: 1, orientation: "portrait" }, + ]); + expect(state.views.at(-1)!.quaternion.angleTo(upright)).toBeLessThan(0.00001); + }; + viewer.setScreen({ ...inner, orientation: "landscape_left", hingeAngle: 180 }); + draw(); + closeAndExpectUpright(); + press(90, [ + { screenId: 1, orientation: "portrait" }, + { screenId: 1, orientation: "landscape_left" }, + { screenId: 3, orientation: "landscape_left" }, + // Election can flap back to the cover once before settling. + { screenId: 1, orientation: "landscape_left" }, + { screenId: 3, orientation: "landscape_left" }, + ]); + expectInnerFacing(0.45); + // Opening the book flat faces the whole tablet; folding back centers the book again. + press(180, [{ screenId: 3, orientation: "landscape_left" }]); + expectInnerFacing(0.99); + press(90, [{ screenId: 3, orientation: "landscape_left" }]); + expectInnerFacing(0.45); + closeAndExpectUpright(); + expect(onOrientationRequested).not.toHaveBeenCalled(); + viewer.dispose(); +}); + +it("eases a fold button's hinge while a pinch follows the fingers", async () => { + let now = 0; + vi.stubGlobal("performance", { now: () => now }); + const { viewer, draw } = fixture(false); + const loaded = asset(); + models.resolve({ asset: loaded, dispose: vi.fn() }); + await Promise.resolve(); + viewer.resize(500, 700, 2); + viewer.setScreen({ + width: 2007, + height: 2853, + orientation: "landscape_left", + screenId: 3, + hingeAngle: 180, + }); + draw(); + const leaf = loaded.getObjectByName("left-half")!; + const open = ((180 - 90) * Math.PI) / 360; + viewer.setHingePreview(90); + draw(); + expect(leaf.rotation.y).toBeGreaterThan(0); + expect(leaf.rotation.y).toBeLessThan(open * 0.9); + now += 1_000; + draw(); + expect(leaf.rotation.y).toBeCloseTo(open); + viewer.setHingePreview(30, true); + expect(leaf.rotation.y).toBeCloseTo(((180 - 30) * Math.PI) / 360); + viewer.dispose(); +}); + it.each(["book", "laptop", "open"] as const)( "faces native cover readback for %s even when configuration arrives before the model", async (hingePose) => { @@ -402,7 +504,7 @@ it("keeps default-zoom orbits framed across folds and fits the current assembly" viewer.dispose(); }); -it("previews hinge articulation while preserving the laptop lid orientation and blocking unconfirmed input", async () => { +it("pinches the hinge while preserving the laptop lid orientation and blocking unconfirmed input", async () => { const { viewer, draw, pending } = fixture(); const loaded = asset(); models.resolve({ asset: loaded, dispose: vi.fn() }); @@ -420,7 +522,7 @@ it("previews hinge articulation while preserving the laptop lid orientation and const lid = loaded.getObjectByName("right-half")!; const presentation = lid.getWorldQuaternion(new Rotation()); for (const value of [127, 50, 170]) { - viewer.setHingePreview(value); + viewer.setHingePreview(value, true); draw(); expect(loaded.getObjectByName("left-half")!.rotation.y).toBeCloseTo( ((180 - value) * Math.PI) / 360, @@ -438,7 +540,7 @@ it("previews hinge articulation while preserving the laptop lid orientation and draw(); expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001); } - viewer.setHingePreview(null); + viewer.setHingePreview(null, true); draw(); expect(pending.size).toBe(0); expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001); @@ -480,6 +582,42 @@ it("does not restart an animated preset on duplicate native configurations", asy viewer.dispose(); }); +it.each([true, false])( + "stands Tent on both edges when the cover reads it back (configuration before model: %s)", + async (configurationFirst) => { + // Recorded from the iPhone Duo simulator: Tent can rest with the cover active. + const { viewer, draw } = fixture(); + viewer.resize(500, 700, 2); + const tent = { + width: 1398, + height: 2034, + orientation: "portrait", + screenId: 1, + hingeAngle: 80, + hingePose: "tent", + } as const; + if (configurationFirst) viewer.setScreen(tent); + const loaded = asset(); + models.resolve({ asset: loaded, dispose: vi.fn() }); + await Promise.resolve(); + if (!configurationFirst) viewer.setScreen(tent); + draw(); + draw(); + // Each leaf runs from the hinge down to the surface it rests on. + for (const [name, outward] of [ + ["left-half", -1], + ["right-half", 1], + ] as const) { + const leaf = loaded.getObjectByName(name)!; + const down = new Vector3(outward, 0, 0).applyQuaternion( + leaf.getWorldQuaternion(new Rotation()), + ); + expect(down.y).toBeLessThan(-0.4); + } + viewer.dispose(); + }, +); + it("lets standalone rotation leave Laptop and Tent and stand a closed device upright after hinge edits", async () => { const { viewer, draw, state } = fixture(); models.resolve({ asset: asset(), dispose: vi.fn() }); @@ -672,7 +810,7 @@ it.each(["left", "right"] as const)( const partner = loaded.getObjectByName("left-half")!; const partnerStart = partner.getWorldQuaternion(new Rotation()); for (const value of [150, 90, 30, 0, 30, 90, 180]) { - viewer.setHingePreview(value); + viewer.setHingePreview(value, true); draw(); expect(leaf.getWorldQuaternion(new Rotation()).angleTo(orientation)).toBeLessThan(1e-6); if (value === 90) @@ -682,7 +820,7 @@ it.each(["left", "right"] as const)( ? { ...inner, width: 1398, height: 2034, screenId: 1, hingeAngle: value, hingePose: null } : { ...inner, hingeAngle: value, hingePose: null }; viewer.setScreen(next); - viewer.setHingePreview(null); + viewer.setHingePreview(null, true); draw(); viewer.setScreen({ ...next }); // Late native readback cannot change the view. draw(); diff --git a/packages/client-runtime/src/device/duoViewer.ts b/packages/client-runtime/src/device/duoViewer.ts index 7feca7452e0d..6e4b443da317 100644 --- a/packages/client-runtime/src/device/duoViewer.ts +++ b/packages/client-runtime/src/device/duoViewer.ts @@ -29,11 +29,12 @@ import { createDeviceMotion } from "./deviceMotion.ts"; import { duoViewSnaps, nearestDuoView, type DuoRestFace } from "./duoSnap.ts"; import { createRenderScheduler } from "./renderScheduler.ts"; import type { DeviceScreenSize } from "./stream.ts"; -import type { DuoPose } from "./duoControl.ts"; +import { duoScreenSettled, type DuoPose } from "./duoControl.ts"; export interface DuoViewer { readonly setScreen: (screen: DeviceScreenSize | null) => void; - readonly setHingePreview: (angle: number | null) => void; + /** A pinch holds the leaf under it and follows the fingers; a fold button eases the hinge and re-presents the device. */ + readonly setHingePreview: (angle: number | null, pinch?: boolean) => void; readonly setInteractionActive: (active: boolean, mode?: "touch" | "orbit") => void; readonly rejectOrientation: () => void; readonly frameUpdated: (panel: DuoPanelId, primary?: HTMLCanvasElement) => void; @@ -186,6 +187,7 @@ export function createDuoViewer(options: { const framing = createDeviceFraming(); const framingBounds = new Box3(); let firstPose = true; + let foldPending = false; let physicalPose: DuoPose = "open"; let presentationAngle = 180; let targetPresentation = new Quaternion(); @@ -291,7 +293,7 @@ export function createDuoViewer(options: { if (!model || disposed) return; scene.add(model.root); applyPose(); - if (screen?.screenId === 1) { + if (screen?.screenId === 1 && physicalPose !== "tent") { const snap = nearestDuoView(orbit.rotation, activeSnaps()); if (snap) { restFace = snap.face; @@ -323,13 +325,25 @@ export function createDuoViewer(options: { const previous = screen; const ownedHandoff = requestedPanel !== null; if (!next || next.screenId === requestedPanel) clearHandoff(); - const ownedRotation = requestedOrientation !== null && next?.screenId === previous?.screenId; + const requestedRotation = requestedOrientation !== null; + const ownedRotation = requestedRotation && next?.screenId === previous?.screenId; const changedDisplay = next?.screenId !== previous?.screenId; requestedOrientation = null; screen = next; const changedPose = next?.hingePose && next.hingePose !== previous?.hingePose; const folding = hingeLeaf !== null && !changedPose; + // A hinge change no held leaf owns re-presents the device, centered on the + // hinge, once the display that shows the new fold reports. Across the closed + // position that takes steps: the outgoing display first reports the incoming + // one's orientation. + const angleBefore = previous?.hingeAngle ?? (previous?.screenId === 1 ? 0 : 180); + const angleAfter = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); + if (!next || folding || changedPose) foldPending = false; + else if (previous && angleBefore !== angleAfter) foldPending = true; + const landedFold = foldPending && !!next && duoScreenSettled(next) && !ownedHandoff; + if (next && duoScreenSettled(next)) foldPending = false; const rotated = + !foldPending && next?.screenId === previous?.screenId && next?.hingeAngle === previous?.hingeAngle && next?.orientation !== previous?.orientation; @@ -340,16 +354,21 @@ export function createDuoViewer(options: { !folding && !next?.hingePose && previewAngle === null; - if (firstPose || changedPose || leftPhysicalPose) { + if (firstPose || changedPose || leftPhysicalPose || landedFold) { physicalPose = next?.hingePose ?? (next?.screenId === 1 ? "closed" : "open"); presentationAngle = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); } // A command reply/config confirms hinge state. Display identity, never an angle heuristic, owns input. targetAngle = previewAngle ?? next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); - // Native angle commands clear hingePose. They change articulation only; - // preserve the viewing pose, including laptop/tent and user orbit. A - // separate rotation clears the physical preset and follows panel orientation. - if (firstPose || changedPose || (rotated && !ownedRotation && !ownedHandoff && !folding)) { + // Native angle commands clear hingePose. While a leaf is held (a pinch, or a + // fold on a stand) they change articulation only and preserve the viewing + // pose. A separate rotation clears the physical preset and follows panel orientation. + if ( + firstPose || + changedPose || + landedFold || + (rotated && !ownedRotation && !ownedHandoff && !folding) + ) { hingeLeaf = null; viewOrientation = next?.orientation ?? null; const poseAngle = presentationAngle; @@ -371,9 +390,12 @@ export function createDuoViewer(options: { targetPresentation.premultiply( new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), roll), ); - if (next?.screenId === 1) { + if (next?.screenId === 1 && !landedFold && physicalPose !== "tent") { // Native readback can retain the cover even for an open physical preset. // A screen-facing rest must use that display's actual hinged normal. + // A landed fold is shut, so its pose already faces the cover; the + // hinge is still moving and its current normal would be stale. + // Tent stands on both edges with the cover outward; facing it flat would tip it over. const snap = nearestDuoView(targetPresentation, activeSnaps()); if (snap) targetPresentation.copy(snap.rotation); } @@ -381,9 +403,18 @@ export function createDuoViewer(options: { restFace = next?.screenId === 1 ? "cover" : physicalPose === "laptop" ? "right" : "inside"; orbit.setPose(targetPresentation, performance.now(), firstPose); firstPose = false; - } else if (next && changedDisplay && !ownedHandoff && !folding) { + } else if ( + next && + changedDisplay && + !ownedHandoff && + !folding && + (restFace === "cover") !== (next.screenId === 1) && + (requestedRotation || duoScreenSettled(next)) + ) { // A sensor rotation can hand ownership to the other native display. // Face that display without sending another rotation and creating a feedback loop. + // Native election can briefly flap to the display a fold does not show and + // back; neither is a handoff, and the view may still be easing into place. const snap = nearestDuoView(orbit.rotation, activeSnaps()); if (snap) { restFace = snap.face; @@ -395,19 +426,24 @@ export function createDuoViewer(options: { fit(); scheduler.invalidate(); }, - setHingePreview(next) { + setHingePreview(next, pinch = false) { if (disposed || (next !== null && (!Number.isFinite(next) || next < 0 || next > 180))) return; if (!moving()) lastTime = performance.now(); previewAngle = next; - if (next !== null && !hingeLeaf && model) { + if (next !== null && !pinch) { + // A fold button holds no leaf, so the device is presented, centered on + // the hinge, once the fold lands. + hingeLeaf = null; + } else if (next !== null && !hingeLeaf && model) { clearHandoff(); requestedOrientation = null; hingeLeaf = screen?.screenId === 1 && angle > 20 ? "left" : "right"; orbit.setPose(orbit.rotation.clone(), performance.now(), true); } - orbit.hold(interactionActive || next !== null, performance.now()); + orbit.hold(interactionActive || (pinch && next !== null), performance.now()); targetAngle = next ?? screen?.hingeAngle ?? (screen?.screenId === 1 ? 0 : 180); - if (next !== null) { + // A pinch follows the fingers; anything else eases toward the target. + if (next !== null && pinch) { angle = next; applyPose(); fit(); From 9f48092ec95dd9eab78931601de95be0107a930c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:09:52 -0700 Subject: [PATCH 016/530] fix(server): Claude 5-series models always run 1M context (#16908) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/claudeModelOptions.test.ts | 11 +- .../Adapters/ClaudeAdapterV2.ts | 10 +- .../server/src/provider/ClaudeModelCatalog.ts | 2 +- apps/server/src/provider/model-manifest.json | 110 ++---------------- 4 files changed, 19 insertions(+), 114 deletions(-) diff --git a/apps/server/src/claudeModelOptions.test.ts b/apps/server/src/claudeModelOptions.test.ts index 005609008f33..e03ac150652a 100644 --- a/apps/server/src/claudeModelOptions.test.ts +++ b/apps/server/src/claudeModelOptions.test.ts @@ -17,15 +17,16 @@ describe("compileClaudeModelSelection", () => { it("compiles context, effort, and settings together", () => { expect( compileClaudeModelSelection( - selection("claude-fable-5", [ + selection("claude-opus-4-6", [ { id: "contextWindow", value: "1m" }, - { id: "effort", value: "ultracode" }, + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, ]), ), ).toMatchObject({ - apiModelId: "claude-fable-5[1m]", - effort: "xhigh", - settings: { ultracode: true }, + apiModelId: "claude-opus-4-6[1m]", + effort: "max", + settings: { fastMode: true }, }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 26246b2038ba..1ad916ec2548 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -97,7 +97,6 @@ import { } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, - resolveClaudeCatalogContextWindow, resolveClaudeCatalogContextWindowTokens, } from "../../provider/ClaudeModelCatalog.ts"; import { @@ -138,12 +137,9 @@ export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; function claudeContextWindow(modelSelection: ModelSelection): number | null { - if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") { - return 1_000_000; - } - return resolveClaudeCatalogContextWindow(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) === "1m" - ? 1_000_000 - : 200_000; + return ( + resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null + ); } export function claudeProviderTurnTokenUsage( diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 1fb80cb4a57b..e601e9dc9392 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -215,7 +215,7 @@ export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined return effort === "ultracode"; } -export function resolveClaudeCatalogContextWindow( +function resolveClaudeCatalogContextWindow( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): string | undefined { diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index da2ef052344d..0ac967281144 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-07T18:00:00Z", + "updatedAt": "2026-10-07T19:00:00Z", "compatibility": [ { "driver": "codex", @@ -145,22 +145,6 @@ } ], "promptInjectedValues": ["ultrathink"] - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { - "id": "200k", - "label": "200k" - }, - { - "id": "1m", - "label": "1M", - "isDefault": true - } - ] } ] }, @@ -170,15 +154,7 @@ "ultracode": "xhigh", "ultrathink": null }, - "modelSuffixes": { - "contextWindow": { - "1m": "[1m]" - } - }, - "contextWindowTokens": { - "200k": 200000, - "1m": 1000000 - } + "fixedContextWindowTokens": 1000000 } } }, @@ -204,23 +180,13 @@ ], "promptInjectedValues": ["ultrathink"] }, - { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { "id": "200k", "label": "200k" }, - { "id": "1m", "label": "1M", "isDefault": true } - ] - } + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } ] }, "adapter": { "claudeCode": { "effortMap": { "ultracode": "xhigh", "ultrathink": null }, - "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, - "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + "fixedContextWindowTokens": 1000000 } } }, @@ -269,22 +235,6 @@ "id": "fastMode", "label": "Fast Mode", "type": "boolean" - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { - "id": "200k", - "label": "200k" - }, - { - "id": "1m", - "label": "1M", - "isDefault": true - } - ] } ] }, @@ -294,15 +244,7 @@ "ultracode": "xhigh", "ultrathink": null }, - "modelSuffixes": { - "contextWindow": { - "1m": "[1m]" - } - }, - "contextWindowTokens": { - "200k": 200000, - "1m": 1000000 - } + "fixedContextWindowTokens": 1000000 } } }, @@ -524,7 +466,7 @@ ] }, "adapter": { - "claudeCode": {} + "claudeCode": { "fixedContextWindowTokens": 200000 } } }, "sonnet-5": { @@ -562,22 +504,6 @@ } ], "promptInjectedValues": ["ultrathink"] - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { - "id": "200k", - "label": "200k", - "isDefault": true - }, - { - "id": "1m", - "label": "1M" - } - ] } ] }, @@ -586,15 +512,7 @@ "effortMap": { "ultrathink": null }, - "modelSuffixes": { - "contextWindow": { - "1m": "[1m]" - } - }, - "contextWindowTokens": { - "200k": 200000, - "1m": 1000000 - } + "fixedContextWindowTokens": 1000000 } } }, @@ -682,23 +600,13 @@ { "id": "ultrathink", "label": "Ultrathink" } ], "promptInjectedValues": ["ultrathink"] - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { "id": "200k", "label": "200k" }, - { "id": "1m", "label": "1M", "isDefault": true } - ] } ] }, "adapter": { "claudeCode": { "effortMap": { "ultrathink": null }, - "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, - "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + "fixedContextWindowTokens": 1000000 } } }, @@ -713,7 +621,7 @@ ] }, "adapter": { - "claudeCode": {} + "claudeCode": { "fixedContextWindowTokens": 200000 } } } }, From 73e87f683ae1455feb663481f1fa588f01b5784b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:42:53 -0700 Subject: [PATCH 017/530] fix(desktop): setup prompts name a t3 that runs on desktop installs (#16676) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/app/DesktopCliShim.test.ts | 181 ++++++++++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 179 +++++++++++++++++ .../backend/DesktopBackendConfiguration.ts | 10 +- apps/server/src/cli/invocation.test.ts | 102 ++++++++-- apps/server/src/cli/invocation.ts | 46 ++++- docs/user/remote-access.md | 3 +- 6 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCliShim.test.ts create mode 100644 apps/desktop/src/app/DesktopCliShim.ts diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts new file mode 100644 index 000000000000..c5a1bb109e4d --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -0,0 +1,181 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts"; + +const directories: Array = []; +afterEach(() => { + for (const directory of directories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +const tempRoot = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-")); + directories.push(root); + return root; +}; + +const writeExecutable = (path: string, content: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content, { mode: 0o755 }); +}; + +/** Writes the launcher for `target` at `/bin/t3`, with `/home` as its T3 home. */ +const writeShim = (root: string, target: CliShimTarget) => { + const shim = NodePath.join(root, "bin", "t3"); + writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); + return shim; +}; + +const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = {}) => + NodeChildProcess.spawnSync(shim, args, { + encoding: "utf8", + env: { PATH: process.env.PATH, ...env }, + }); + +/** An app executable that reports its environment and arguments. */ +const REPORTER = + '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; + +/** + * A stand-in AppImage whose image holds the reporter. `--appimage-mount` + * prints the mount and stays up until killed, recording that it was; with + * `canMount` false it fails like a host without FUSE. `--appimage-extract` + * unpacks into ./squashfs-root. + */ +const fakeAppImage = (canMount = true) => { + const root = tempRoot(); + const image = NodePath.join(root, "image"); + writeExecutable(NodePath.join(image, "t3code"), REPORTER); + const appImage = NodePath.join(root, "T3 Code.AppImage"); + writeExecutable( + appImage, + [ + "#!/bin/sh", + `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, + '[ "$1" = --appimage-mount ] || exit 9', + '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', + canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', + `trap 'touch "${root}/unmounted"; exit 0' TERM`, + "while :; do sleep 0.05; done", + "", + ].join("\n"), + ); + const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" }); + return { root, image, appImage, shim }; +}; + +describe("renderCliShim", () => { + it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { + const { root, image, shim } = fakeAppImage(); + const result = run(shim, ["browser", "setup", "a b"]); + expect(result.status).toBe(3); + expect(result.stdout.split("\n")).toEqual([ + `node=1 cli=${shim} home=${root}/home`, + `${image}/resources/app.asar/apps/server/dist/bin.mjs`, + "browser", + "setup", + "a b", + "", + ]); + // The trap kills the mount helper as the launcher exits. + NodeChildProcess.spawnSync("sh", [ + "-c", + `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, + ]); + expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); + }); + + it("extracts the AppImage where it cannot be mounted", () => { + const { shim } = fakeAppImage(false); + // Extract-and-run is ignored too, so the runtime never starts the app. + const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); + expect(result.status).toBe(3); + expect(result.stdout).toMatch( + /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/, + ); + }); + + it("keeps the install's T3 home under sudo, unless one is set", () => { + const root = tempRoot(); + writeExecutable(NodePath.join(root, "app"), REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "app"), + entry: "/bin.mjs", + }); + // sudo clears the environment, so the launcher supplies its own. + expect(run(shim, []).stdout).toContain(`home=${root}/home`); + expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); + // Run by a relative path, it still names itself absolutely. + const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); + expect(relative.stdout).toContain(`cli=${shim}`); + }); + + it("runs an installed app's server directly", () => { + const root = tempRoot(); + const executable = NodePath.join(root, "opt", "T3 Code", "t3code"); + writeExecutable(executable, REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable, + entry: "/opt/T3's/app.asar/bin.mjs", + }); + expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([ + "/opt/T3's/app.asar/bin.mjs", + "browser", + "setup", + "", + ]); + }); + + it("tells a person whose app moved to reopen it", () => { + const root = tempRoot(); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), + entry: "/bin.mjs", + }); + const result = run(shim, ["--version"]); + expect(result.status).toBe(127); + expect(result.stderr).toContain("Open the app once to update this command."); + }); + + it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { + const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; + const script = renderCliShim({ + target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" }, + shimPath: "C:\\Users\\Josรฉ\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\Josรฉ\\.t3", + }); + const lines = script.split("\r\n"); + expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion"); + expect(lines).toContain("chcp 65001 >nul"); + expect(lines).toContain( + 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', + ); + // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. + const bare = lines.filter( + (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), + ); + expect(bare).toEqual([]); + expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\Josรฉ\\.t3\\bin\\t3.cmd"'); + }); + + it("switches the Windows console to UTF-8 only when a path needs it", () => { + const ascii = renderCliShim({ + target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, + shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\me\\.t3", + }); + // A Ctrl-C that ends the batch would leave the console switched. + expect(ascii).not.toContain("chcp"); + expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); + }); +}); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts new file mode 100644 index 000000000000..c7dd42e2bf50 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -0,0 +1,179 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// A desktop install puts no `t3` on PATH, so commands the server asks a person +// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small +// launcher for its bundled CLI in the T3 home, which is never on PATH and so +// never shadows another `t3`, and the server names it by absolute path in those +// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, +// so its launcher mounts the AppImage itself instead of pointing into it. +const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); + +const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; + +/** Server entry inside the app, relative to its server root (an asar archive when packaged). */ +const SERVER_ENTRY = "apps/server/dist/bin.mjs"; + +const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; +/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */ +const cmdText = (value: string) => value.replaceAll("%", "%%"); +const cmdWord = (value: string) => `"${cmdText(value)}"`; + +const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; + +export type CliShimTarget = + | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } + | { readonly kind: "direct"; readonly executable: string; readonly entry: string } + | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; + +/** + * The launcher script. Electron runs the server as plain Node with + * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive. + * The launcher's own path and the app's T3 home are written in, so the command + * the server shows is absolute and `sudo`, which clears the environment, still + * runs against this install's home. + */ +export const renderCliShim = (input: { + readonly target: CliShimTarget; + readonly shimPath: string; + readonly t3Home: string; +}) => { + const { target } = input; + if (target.kind === "windows") { + const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) => + [...value].some((character) => character.codePointAt(0)! > 0x7f), + ); + const restore = utf8 ? ["chcp %t3_codepage% >nul"] : []; + return [ + "@echo off", + `rem ${MARKER}`, + // Delayed expansion would rewrite `!name!` in paths and arguments. + "setlocal EnableExtensions DisableDelayedExpansion", + // The file is UTF-8. Only a non-ASCII path needs the console switched to + // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the + // switch is left out for the common all-ASCII install. + ...(utf8 + ? [ + `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, + "chcp 65001 >nul", + ] + : []), + `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, + `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, + 'set "ELECTRON_RUN_AS_NODE=1"', + // A goto, not a parenthesized block: "Program Files (x86)" would close the block early. + `if exist ${cmdWord(target.executable)} goto run`, + ...restore, + `echo ${MOVED} 1>&2`, + "exit /b 127", + ":run", + `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`, + 'set "t3_exit=%ERRORLEVEL%"', + ...restore, + "exit /b %t3_exit%", + "", + ].join("\r\n"); + } + const header = [ + "#!/bin/sh", + `# ${MARKER}`, + `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`, + `home=${shellWord(input.t3Home)}`, + 'export T3CODE_HOME="${T3CODE_HOME:-$home}"', + "export ELECTRON_RUN_AS_NODE=1", + `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`, + 'if [ ! -x "$app" ]; then', + ` echo "${MOVED}" >&2`, + " exit 127", + "fi", + ]; + if (target.kind === "direct") { + return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n"); + } + const run = (root: string) => + `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`; + return [ + ...header, + // Extract-and-run would make the runtime start the app instead of mounting it. + "unset APPIMAGE_EXTRACT_AND_RUN", + // The AppImage runtime prints its mount point, then stays mounted until + // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + "work=$(mktemp -d) || exit 1", + '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', + "mounter=$!", + `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, + "trap 'exit 130' INT TERM", + "mount=", + 'while kill -0 "$mounter" 2>/dev/null; do', + ' mount=$(head -n 1 "$work/mount")', + ' [ -d "$mount" ] && break', + " mount=", + " sleep 0.05", + "done", + // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works. + 'if [ -z "$mount" ]; then', + ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', + ' mount="$work/squashfs-root"', + "fi", + run("$mount"), + "", + ].join("\n"); +}; + +/** + * Writes the packaged app's launcher to `/bin` and returns its path + * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout + * and get none. + */ +export const install = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (!environment.isPackaged) return Option.none(); + const fs = yield* FileSystem.FileSystem; + const path = environment.path; + const windows = environment.platform === "win32"; + const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); + const entry = path.join(environment.serverRoot, SERVER_ENTRY); + const target: CliShimTarget = windows + ? { kind: "windows", executable: process.execPath, entry } + : Option.match(environment.appImagePath, { + onSome: (appImage) => ({ + kind: "appimage" as const, + appImage, + executableName: path.basename(process.execPath), + }), + // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. + onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), + }); + const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); + + return yield* Effect.gen(function* () { + const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); + if (Option.isSome(existing) && !existing.value.includes(MARKER)) { + // Someone else's file; leave it, and let commands fall back to plain `t3`. + yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); + return Option.none(); + } + if (Option.getOrUndefined(existing) !== content) { + yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); + // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. + const staging = `${shimPath}.${process.pid}.tmp`; + yield* fs.writeFileString(staging, content, { mode: 0o755 }); + yield* fs.rename(staging, shimPath); + yield* logInfo("installed t3 launcher", { shimPath }); + } + return Option.some(shimPath); + }).pipe( + // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. + Effect.catchCause((cause) => + logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan("desktop.cliShim.install"), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 289e3fb321f0..be923b47c5af 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import serverPackageJson from "../../../server/package.json" with { type: "json" }; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopCliShim from "../app/DesktopCliShim.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly cliPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + // The server names this launcher in commands it asks a person to run. + T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, @@ -912,7 +916,11 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + const cliPath = yield* DesktopCliShim.install.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 56e4b9c06434..bf2dcba2b9f4 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; import { HostProcessArguments, + HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, HostProcessPlatform, @@ -102,28 +103,87 @@ it("formats serve suggestions to match the launching command", () => { ); }); -it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => - Effect.gen(function* () { - const command = (node: string, entry: string) => - resolveRootCliCommand("browser setup").pipe( - Effect.provideService(HostProcessExecutablePath, node), - Effect.provideService(HostProcessArguments, [node, entry]), - ); - const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; - // sudo's secure_path already has a system Node. - expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup"); - // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. - expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe( - 'sudo env "PATH=$PATH" npx t3 browser setup', +it.layer(NodeServices.layer)("root CLI commands", (it) => { + /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */ + const rootCommand = (input: { + readonly node: string; + readonly entry: string; + readonly path?: string; + readonly env?: Record; + readonly executable?: boolean; + }) => + resolveRootCliCommand("browser setup").pipe( + Effect.provideService(HostProcessExecutablePath, input.node), + Effect.provideService(HostProcessArguments, [input.node, input.entry]), + Effect.provideService(HostProcessIsExecutable, input.executable ?? false), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { PATH: input.path ?? "", ...input.env }), ); - expect( - yield* command( - "/home/theo/.local/node/bin/node", - "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", - ), - ).toBe('sudo env "PATH=$PATH" t3 browser setup'); - }), -); + + /** A directory holding an executable `t3`, to stand in for one on PATH. */ + const pathWithT3 = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bin = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + return bin; + }); + + it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => + Effect.gen(function* () { + const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; + // sudo's secure_path already has a system Node. + expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe( + "sudo npx t3 browser setup", + ); + // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. + expect( + yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }), + ).toBe('sudo env "PATH=$PATH" npx t3 browser setup'); + expect( + yield* rootCommand({ + node: "/home/theo/.local/node/bin/node", + entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", + path: yield* pathWithT3, + }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + }).pipe(Effect.scoped), + ); + + it.effect("names this install's launcher when t3 is not on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const shim = path.join(home, ".local bin", "t3"); + yield* fs.makeDirectory(path.dirname(shim), { recursive: true }); + yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 }); + const desktop = { + node: "/tmp/.mount_T3abc/t3code", + entry: "/tmp/.mount_T3abc/resources/app.asar/apps/server/dist/bin.mjs", + env: { T3CODE_CLI_PATH: shim }, + }; + // The desktop app's shim, quoted for the shell and run as root as is. + expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); + // A `t3` the person put on PATH still wins. + expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( + 'sudo env "PATH=$PATH" t3 browser setup', + ); + // A standalone binary names itself. + expect( + yield* rootCommand({ + node: "/opt/t3/t3", + entry: "/opt/t3/t3", + executable: true, + }), + ).toBe("sudo /opt/t3/t3 browser setup"); + // A stale shim path, then no launcher at all, fall back to plain `t3`. + expect( + yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + }).pipe(Effect.scoped), + ); +}); it.layer(NodeServices.layer)("manual server installation ownership", (it) => { it.effect("recognizes runner caches for both script and executable packages", () => diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index cbfc4dff8b19..59843abd7289 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,15 +1,18 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { ServerInstallation } from "@t3tools/contracts"; import { HostProcessArguments, + HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; @@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) => }), ); +/** Quotes `value` for a POSIX shell when it needs it. */ +const shellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`; + +/** + * The launcher a person can type to run this install when `t3` is not on + * PATH: the desktop app's `t3` shim, which the app and the shim itself name in + * `T3CODE_CLI_PATH`, or a standalone binary's own path. Script installs (a + * repo checkout) have no single launcher and keep plain `t3`. + */ +const resolveInstallLauncher = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const shim = (yield* HostProcessEnvironment).T3CODE_CLI_PATH?.trim(); + if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) { + return Option.some(shim); + } + return (yield* HostProcessIsExecutable) + ? Option.some(yield* HostProcessExecutablePath) + : Option.none(); +}); + +/** + * `t3 ` for a person to run on this host: `t3` when it is on PATH, + * the package runner this process came from, or else the absolute path of the + * launcher for this install, such as the one the desktop app installs. + */ +const resolveHostCliCommand = (subcommand: string) => + Effect.gen(function* () { + const command = yield* resolveCliCommand(subcommand); + if (command !== `t3 ${subcommand}`) return { command, launcher: false }; + if (yield* isCommandAvailable("t3")) return { command, launcher: false }; + const launcher = yield* resolveInstallLauncher; + return Option.isSome(launcher) + ? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true } + : { command, launcher: false }; + }); + /** * `t3 ` as root, for setup a person runs once on the host. `sudo` * resets PATH on most distributions, which drops a user-installed Node (nvm, * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries - * PATH through unless Node is on root's PATH too. + * PATH through unless Node is on root's PATH too. An absolute launcher needs + * neither. */ export const resolveRootCliCommand = (subcommand: string) => Effect.gen(function* () { - const command = yield* resolveCliCommand(subcommand); + const { command, launcher } = yield* resolveHostCliCommand(subcommand); + if (launcher) return `sudo ${command}`; const executablePath = yield* HostProcessExecutablePath; const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => executablePath.startsWith(`${directory}/`), diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 491470bfa2f0..87c8ee9bea67 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -213,7 +213,8 @@ sudo t3 browser setup The server shows the exact line for how you started it, such as `sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only -for your user. It allows Chrome's sandbox with an AppArmor profile and installs +for your user. Where `t3` is not on your `PATH`, such as with only the +desktop app installed, it names the full path of the app's own `t3` instead. It allows Chrome's sandbox with an AppArmor profile and installs any missing libraries with apt. It is safe to run again. Without `sudo`, it only reports what it would change. From 0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:42:53 -0700 Subject: [PATCH 018/530] feat(desktop): install the t3 command from Settings (#16683) Co-authored-by: Claude Opus 5.5 (1M context) --- .../desktop/src/app/DesktopCliCommand.test.ts | 243 ++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 261 ++++++++++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 12 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/cliCommand.ts | 34 +++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 5 + .../settings/CliCommandSettingsRow.tsx | 76 +++++ .../components/settings/SettingsPanels.tsx | 2 + .../src/components/settings/settingsSearch.ts | 7 + docs/user/install.md | 9 + packages/contracts/src/ipc.ts | 17 ++ 13 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCliCommand.test.ts create mode 100644 apps/desktop/src/app/DesktopCliCommand.ts create mode 100644 apps/desktop/src/ipc/methods/cliCommand.ts create mode 100644 apps/web/src/components/settings/CliCommandSettingsRow.tsx diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts new file mode 100644 index 000000000000..dc2e7fb962d1 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -0,0 +1,243 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentFor = ( + path: Path.Path, + input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean }, +) => + DesktopEnvironment.DesktopEnvironment.of({ + path, + platform: input.platform ?? "linux", + isPackaged: input.isPackaged ?? true, + homeDirectory: input.home, + baseDir: input.baseDir, + stateDir: path.join(input.baseDir, "userdata"), + serverRoot: "/opt/T3 Code/resources/app.asar", + appImagePath: Option.none(), + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +/** The service for a packaged app; the launcher is written on Install. */ +const commandIn = ( + input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, + spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const baseDir = input.baseDir ?? path.join(input.home, ".t3"); + yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true }); + const make = DesktopCliCommand.make.pipe( + Effect.provideService( + DesktopEnvironment.DesktopEnvironment, + environmentFor(path, { ...input, baseDir }), + ), + ); + return yield* spawner + ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) + : make; + }); + +/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ +const fakePowerShell = (initial: string) => { + const registry = { path: initial, failReads: false, writes: 0 }; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const { options } = command as unknown as { + readonly options: { readonly env?: Record }; + }; + const env = options.env ?? {}; + let exitCode = 0; + let stdout = ""; + if (env.T3_SET === "1") { + registry.path = env.T3_PATH ?? ""; + registry.writes += 1; + } else if (registry.failReads) { + exitCode = 1; + } else { + stdout = Buffer.from(registry.path, "utf8").toString("base64"); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { registry, spawner }; +}; + +it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { + it.effect("links the launcher onto PATH and removes only that link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const link = path.join(home, ".local", "bin", "t3"); + + expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + // Install writes the launcher itself, even when no local backend ever did. + const installed = yield* command.install; + expect(installed.installedPath).toBe(link); + expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); + expect((yield* command.install).installedPath).toBe(link); + + expect((yield* command.uninstall).installedPath).toBeNull(); + expect(yield* fs.exists(link)).toBe(false); + // The launcher itself stays for setup commands. + expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("never replaces or removes a t3 it did not create", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const theirs = path.join(home, ".local", "bin", "t3"); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.writeFileString(theirs, "npm's t3\n"); + // Even a broken link in the next folder is someone else's. + yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); + yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); + + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("Another t3 command is already installed"); + yield* command.uninstall; + expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + }).pipe(Effect.scoped), + ); + + it.effect("finds and removes a link left by a previous T3 home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); + const link = (yield* before.install).installedPath; + + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); + expect((yield* after.state).installedPath).toBe(link); + // Installing again points the link at this home's launcher. + expect((yield* after.install).installedPath).toBe(link); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + yield* after.uninstall; + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); + }).pipe(Effect.scoped), + ); + + it.effect("does not read a large binary another t3 links to", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + // A big executable that happens to contain the marker text is still not ours. + const binary = path.join(home, "native-t3"); + yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); + yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); + yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); + const command = yield* commandIn({ home }); + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); + }).pipe(Effect.scoped), + ); + + it.effect("reports when another t3 earlier on PATH would run instead", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + const previous = process.env.PATH; + process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); + yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); + expect(installed.onPath).toBe(false); + yield* fs.remove(path.join(shadow, "t3")); + expect((yield* command.state).onPath).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("leaves the Windows PATH alone when it cannot be read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools"; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + registry.failReads = true; + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("left unchanged"); + expect(registry).toMatchObject({ path: userPath, writes: 0 }); + + registry.failReads = false; + const launcherDir = DesktopCliShim.launcherPath( + environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }), + ).replace(/[\\/]t3\.cmd$/, ""); + yield* command.install; + expect(registry.path).toBe(`${userPath};${launcherDir}`); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("does not claim or remove a Windows PATH entry the user added", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcherDir = path.dirname( + DesktopCliShim.launcherPath( + environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }), + ), + ); + const userPath = `C:\\Tools;${launcherDir}`; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("offers nothing for a development build", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const command = yield* commandIn({ + home: yield* fs.makeTempDirectoryScoped(), + isPackaged: false, + }); + expect((yield* command.state).supported).toBe(false); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts new file mode 100644 index 000000000000..534af3716db7 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -0,0 +1,261 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +// Settings โ†’ Install `t3` command, like VS Code's "Install 'code' command". +// The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH +// by default. Installing links it into a folder on the user's PATH, or on +// Windows adds the launcher's folder to the user's PATH. Removing undoes only +// what installing did: a link that points at one of the app's launchers, or a +// PATH entry the app recorded adding. + +export class DesktopCliCommandError extends Schema.TaggedError()( + "DesktopCliCommandError", + { message: Schema.String }, +) {} + +/** User-writable folders that login shells commonly put on PATH, in preference order. */ +const unixCandidates = (home: string, platform: NodeJS.Platform) => + platform === "darwin" + ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] + : [`${home}/.local/bin`, `${home}/bin`]; + +const pathEntries = (value: string | undefined, separator: string) => + (value ?? "").split(separator).filter((entry) => entry.length > 0); + +/** Windows paths compare without case or a trailing separator. */ +const sameWindowsPath = (left: string, right: string) => + left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); + +/** + * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping + * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for + * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` + * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it + * opens see the change. A failed read exits nonzero rather than reading as + * empty, so a write never replaces the user's PATH wholesale. + */ +const WINDOWS_USER_PATH_SCRIPT = ` +$ErrorActionPreference = 'Stop' +if ($env:T3_SET -eq '1') { + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') + $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + if ($key.GetValueNames() -contains 'Path' -and $key.GetValueKind('Path') -eq 'String') { + $kind = [Microsoft.Win32.RegistryValueKind]::String + } + $key.SetValue('Path', $env:T3_PATH, $kind) + Add-Type -Namespace T3 -Name Env -MemberDefinition '[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);' + $result = [System.UIntPtr]::Zero + [void][T3.Env]::SendMessageTimeout([System.IntPtr]0xffff, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) +} else { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + $value = if ($key) { $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') } else { '' } + [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value))) +} +`; + +export class DesktopCliCommand extends Context.Service< + DesktopCliCommand, + { + readonly state: Effect.Effect; + readonly install: Effect.Effect; + readonly uninstall: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopCliCommand") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = environment.path; + const windows = environment.platform === "win32"; + const launcher = DesktopCliShim.launcherPath(environment); + const binDirectory = path.dirname(launcher); + /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ + const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); + + const fail = (message: string) => new DesktopCliCommandError({ message }); + const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + const writableDirectory = (directory: string) => + fs.access(directory, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + /** + * A link to one of the app's launchers, by the marker the launcher carries. + * This also finds links made under a previous T3 home, so Remove can clean + * them up and Install does not add a second. + */ + const isOurLink = (link: string) => + Effect.gen(function* () { + yield* fs.readLink(link); + // The launcher is a few KB; never read a large binary another `t3` links to. + const info = yield* fs.stat(link); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + const content = yield* fs.readFileString(link); + return content.includes(DesktopCliShim.MARKER); + }).pipe(Effect.orElseSucceed(() => false)); + + const powershell = (env: Record) => + Effect.scoped( + Effect.gen(function* () { + const root = process.env.SystemRoot ?? process.env.WINDIR; + const handle = yield* spawner.spawn( + ChildProcess.make( + root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], + { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, + ), + ); + const [stdout, exitCode] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`); + return stdout; + }), + ); + const readUserPath = powershell({}).pipe( + Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), + Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), + ); + const writeUserPath = (value: string) => + powershell({ T3_SET: "1", T3_PATH: value }).pipe( + Effect.asVoid, + Effect.mapError(() => fail("Could not update your PATH.")), + ); + + /** The `t3` a new shell runs, by PATH order, or none. */ + const firstOnPath = Effect.gen(function* () { + for (const directory of pathEntries(process.env.PATH, ":")) { + const candidate = path.join(directory, "t3"); + if (yield* exists(candidate)) return Option.some(candidate); + } + return Option.none(); + }); + + /** Where this app's command is installed now, if anywhere. */ + const installedAt = Effect.gen(function* () { + if (windows) { + if (!(yield* exists(ownedPathMarker))) return Option.none(); + const entries = pathEntries(yield* readUserPath, ";"); + return entries.some((entry) => sameWindowsPath(entry, binDirectory)) + ? Option.some(launcher) + : Option.none(); + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) return Option.some(link); + } + return Option.none(); + }); + + const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { + if (!environment.isPackaged) { + return { supported: false, installedPath: null, onPath: false } as const; + } + const installed = yield* installedAt; + if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; + // On Windows only terminals opened after the change see it. On Unix the + // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. + const first = yield* firstOnPath; + const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); + return { supported: true, installedPath: installed.value, onPath }; + }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + + /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ + const ensureLauncher = DesktopCliShim.install.pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)), + onSome: () => Effect.void, + }), + ), + ); + + const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () { + if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); + yield* ensureLauncher; + if (windows) { + const entries = pathEntries(yield* readUserPath, ";"); + if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { + yield* writeUserPath([...entries, binDirectory].join(";")); + yield* fs + .writeFileString(ownedPathMarker, `${binDirectory}\n`) + .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it."))); + } + return yield* state; + } + const existing = yield* installedAt; + if (Option.isSome(existing)) { + const target = yield* fs.readLink(existing.value).pipe(Effect.option); + if (Option.getOrUndefined(target) === launcher) return yield* state; + // A link to a previous T3 home's launcher: point it at this one instead. + yield* fs + .remove(existing.value) + .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); + } + const onPath = pathEntries(process.env.PATH, ":"); + const candidates = unixCandidates(environment.homeDirectory, environment.platform); + // Prefer a folder already on PATH that the user can write to without admin rights. + for (const directory of [ + ...candidates.filter((candidate) => onPath.includes(candidate)), + ...candidates.filter((candidate) => !onPath.includes(candidate)), + ]) { + const link = path.join(directory, "t3"); + const created = (yield* exists(directory)) + ? yield* writableDirectory(directory) + : yield* fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!created) continue; + // symlink fails if anything, even a broken link, is already there. + const linked = yield* fs.symlink(launcher, link).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (linked) return yield* state; + } + return yield* fail( + `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, + ); + }).pipe(Effect.withSpan("desktop.cliCommand.install")); + + const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { + if (windows) { + if (yield* exists(ownedPathMarker)) { + const entries = pathEntries(yield* readUserPath, ";"); + const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory)); + if (kept.length !== entries.length) yield* writeUserPath(kept.join(";")); + yield* fs.remove(ownedPathMarker).pipe(Effect.ignore); + } + return yield* state; + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) { + yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); + } + } + return yield* state; + }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); + + return DesktopCliCommand.of({ state, install, uninstall }); +}); + +export const layer = Layer.effect(DesktopCliCommand, make); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index c7dd42e2bf50..5cd942f66ced 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -14,7 +14,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // so its launcher mounts the AppImage itself instead of pointing into it. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); -const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; +export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ const SERVER_ENTRY = "apps/server/dist/bin.mjs"; @@ -125,6 +125,14 @@ export const renderCliShim = (input: { ].join("\n"); }; +/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ +export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => + environment.path.join( + environment.baseDir, + "bin", + environment.platform === "win32" ? "t3.cmd" : "t3", + ); + /** * Writes the packaged app's launcher to `/bin` and returns its path * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout @@ -136,7 +144,7 @@ export const install = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = environment.path; const windows = environment.platform === "win32"; - const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); + const shimPath = launcherPath(environment); const entry = path.join(environment.serverRoot, SERVER_ENTRY); const target: CliShimTarget = windows ? { kind: "windows", executable: process.execPath, entry } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a16535c4a5eb..a67424a795a9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -75,6 +75,11 @@ import { takeLegacyLocalStorage, } from "./methods/legacyLocalStorage.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { + getCliCommandState, + installCliCommand, + uninstallCliCommand, +} from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -149,6 +154,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); + yield* ipc.handle(getCliCommandState); + yield* ipc.handle(installCliCommand); + yield* ipc.handle(uninstallCliCommand); for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9ae71cab1df8..5b078314ac49 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -23,6 +23,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; +export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; +export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; diff --git a/apps/desktop/src/ipc/methods/cliCommand.ts b/apps/desktop/src/ipc/methods/cliCommand.ts new file mode 100644 index 000000000000..be9672c68251 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cliCommand.ts @@ -0,0 +1,34 @@ +import { DesktopCliCommandStateSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopCliCommand from "../../app/DesktopCliCommand.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getCliCommandState = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.getState")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).state; + }), +}); + +export const installCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.install")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).install; + }), +}); + +export const uninstallCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.uninstall")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2c56f6b39c8e..01c3d7892c66 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -38,6 +38,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -213,6 +214,7 @@ const layerDesktopApplication = Layer.mergeAll( layerDesktopAppActivation, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, + DesktopCliCommand.layer, DesktopShellEnvironment.layer, layerDesktopSsh, ).pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a97f224a1b41..0dd9604f9865 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -268,6 +268,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + cliCommand: { + getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), + install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), + uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL), + }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; diff --git a/apps/web/src/components/settings/CliCommandSettingsRow.tsx b/apps/web/src/components/settings/CliCommandSettingsRow.tsx new file mode 100644 index 000000000000..f7c12345d7db --- /dev/null +++ b/apps/web/src/components/settings/CliCommandSettingsRow.tsx @@ -0,0 +1,76 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +/** + * Settings โ†’ `t3` command: puts the desktop app's bundled CLI on PATH, or takes + * it off again. Hidden where the desktop build has no launcher to install. + */ +export function CliCommandSettingsRow() { + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge?.cliCommand; + const [state, setState] = useState(null); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!bridge) return; + let cancelled = false; + void bridge + .getState() + .then((next) => { + if (!cancelled) setState(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [bridge]); + + const change = useCallback( + (action: "install" | "uninstall") => { + if (!bridge || pending) return; + setPending(true); + void bridge[action]() + .then(setState) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: action === "install" ? "Could not install t3" : "Could not remove t3", + description: error instanceof Error ? error.message : "Something went wrong.", + }), + ); + }) + .finally(() => setPending(false)); + }, + [bridge, pending], + ); + + if (!bridge || !state?.supported) return null; + const installed = state.installedPath !== null; + const description = !installed + ? "Run T3 Code's CLI as `t3` from any terminal." + : state.onPath + ? `Installed at ${state.installedPath}. Open a new terminal to use it.` + : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`; + + return ( + change(installed ? "uninstall" : "install")} + > + {installed ? "Remove" : "Install"} + + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5e52b4827891..bee57d21b253 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,6 +55,7 @@ import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; +import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -509,6 +510,7 @@ function AboutVersionSection() { /> ) : null} {showNightlyMobileBeta ? : null} + {hasDesktopBridge ? : null} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 81b2d1248f19..853886d81fcd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -503,6 +503,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, + { + id: "cli-command", + title: "t3 command", + to: "/settings/general", + searchTerms: ["cli terminal shell path install command line"], + desktopOnly: true, + }, { id: "privacy-policy", title: "Privacy policy", diff --git a/docs/user/install.md b/docs/user/install.md index 980837199e9b..b9f2930776ae 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -76,6 +76,15 @@ The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### The `t3` command + +The desktop app includes the `t3` command-line tool. To run it from any +terminal, open **Settings โ†’ General โ†’ About** and choose **Install** next to +**t3 command**. On macOS and Linux it adds a `t3` link to a folder on your +`PATH`; on Windows it adds the app's command folder to your `PATH`. Open a new +terminal afterwards. **Remove** takes it off again. If you already have `t3` +from npm, it stays as it is. + ### Windows Subsystem for Linux Choose a WSL distro in **Settings โ†’ Connections** to run agents and projects diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e661bc2b187d..149f282194a5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -317,6 +317,17 @@ export const DesktopUpdateStateSchema = Schema.Struct({ canRetry: Schema.Boolean, }); +/** The desktop app's `t3` command on PATH, managed from Settings. */ +export const DesktopCliCommandStateSchema = Schema.Struct({ + /** Only installed builds have a launcher to put on PATH. */ + supported: Schema.Boolean, + /** The `t3` the app installed, or null when it is not installed. */ + installedPath: Schema.NullOr(Schema.String), + /** Whether a new terminal finds it; false when the folder is not on PATH yet. */ + onPath: Schema.Boolean, +}); +export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; + export interface DesktopUpdateActionResult { accepted: boolean; completed: boolean; @@ -1215,6 +1226,12 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Settings โ†’ `t3` command. Optional: older desktop builds lack it. */ + cliCommand?: { + getState: () => Promise; + install: () => Promise; + uninstall: () => Promise; + }; /** Present when the desktop shell accepts `t3 app` activation requests. */ appActivation?: { setReady: (ready: boolean) => Promise; From a9fb6a80635c77a1d4fd988e0018059e13628eff Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:11:04 +0300 Subject: [PATCH 019/530] fix(web): show live names in thread-read activity (#13140) Co-authored-by: Julius Marminge Co-authored-by: PR Batch Tester --- .../chat/MessagesTimeline.logic.test.ts | 46 ++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 37 +++++++++- .../src/components/chat/MessagesTimeline.tsx | 73 ++++++++++++++++++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0f22324e0ca1..e1ce7145d63a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -33,6 +33,9 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, + threadReadTargetTitle, type MessagesTimelineRow, resolveTimelineToolPresentation, workEntryDisplayLabel, @@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => { }); describe("work entry labels", () => { + it("uses live titles only for active thread shells", () => { + const shell = { title: " Review auth flow ", archivedAt: null, deletedAt: null }; + expect(threadReadTargetTitle(shell)).toBe("Review auth flow"); + expect(threadReadTargetTitle({ ...shell, title: "Harden session refresh" })).toBe( + "Harden session refresh", + ); + expect(threadReadTargetTitle({ ...shell, archivedAt: "2026-10-07T12:00:00Z" })).toBeNull(); + expect(threadReadTargetTitle({ ...shell, deletedAt: "2026-10-07T12:00:00Z" })).toBeNull(); + expect(threadReadTargetTitle({ ...shell, title: " " })).toBeNull(); + expect(threadReadTargetTitle(null)).toBeNull(); + }); + const entry = { id: "tool-1", createdAt: "2026-09-01T12:00:00Z", @@ -150,6 +165,37 @@ describe("work entry labels", () => { expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); }); + it.each([ + ["inProgress", true, "Reading thread"], + ["completed", false, "Read thread"], + ["failed", false, "Failed to read thread"], + ["declined", false, "Declined to read thread"], + ["stopped", false, "Stopped reading thread"], + ] as const)("names the read thread in the %s label", (toolLifecycleStatus, active, prefix) => { + const threadRead = { + ...entry, + itemType: "dynamic_tool" as const, + toolLifecycleStatus, + structuredPayload: { + type: "dynamic_tool", + toolName: "t3-code.t3_thread_read", + input: { threadId: " thread-child ", view: "activity" }, + } as never, + }; + expect(threadReadTargetId(threadRead)).toBe("thread-child"); + expect(threadReadLabelPrefix(liveWorkEntryLabel(threadRead, undefined, active))).toBe(prefix); + expect(threadReadLabelPrefix(workEntryDisplayLabel(threadRead, undefined))).toBe(prefix); + }); + + it("finds no target for other tools or thread reads without one", () => { + const payload = (toolName: string, input: unknown) => ({ + structuredPayload: { type: "dynamic_tool", toolName, input } as never, + }); + expect(threadReadTargetId(payload("t3-code.t3_thread_wait", { threadId: "t" }))).toBeNull(); + expect(threadReadTargetId(payload("t3-code.t3_thread_read", { threadId: " " }))).toBeNull(); + expect(threadReadTargetId(payload("t3-code.t3_thread_read", null))).toBeNull(); + }); + it("uses the active summary state for legacy tools without a lifecycle status", () => { const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5a2259e961f4..b619a347255b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -34,7 +34,12 @@ import { type TimelineEntry, type WorkLogEntry, } from "../../session-logic"; -import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; +import { + type ChatMessage, + type ProposedPlan, + type ThreadShell, + type TurnDiffSummary, +} from "../../types"; import { type MessageId, type WorktreeSetupSnapshot, @@ -169,6 +174,36 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; } +/** The trimmed thread id a `t3_thread_read` call targets, or null for any other entry. */ +export function threadReadTargetId(entry: Pick) { + const item = entry.structuredPayload; + if (item?.type !== "dynamic_tool") return null; + if (resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "thread-read") return null; + const input = item.input; + const threadId = + input !== null && typeof input === "object" && "threadId" in input ? input.threadId : null; + return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null; +} + +const THREAD_READ_OBJECT = " a T3 thread"; + +export function threadReadTargetTitle( + shell: Pick | null, +) { + if (!shell || shell.archivedAt !== null || shell.deletedAt !== null) return null; + return shell.title.trim() || null; +} + +/** + * Names the read thread in place of the generic object ("Read a T3 thread" becomes + * `Read thread โ€œTitleโ€`), keeping the label's tense. Null keeps the generic label. + */ +export function threadReadLabelPrefix(label: string) { + return label.endsWith(THREAD_READ_OBJECT) + ? `${label.slice(0, -THREAD_READ_OBJECT.length)} thread` + : null; +} + /** Inspectable read-file output is the path when we have one, otherwise nothing. */ export function workEntryReadOutput( entry: Pick< diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 463fbfd30b87..43f9746a6590 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -28,7 +28,7 @@ import { type ServerProvider, type ServerProviderSkill, type RunId, - type ThreadId, + ThreadId, type ToolActivityIcon, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -208,6 +208,9 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, + threadReadTargetTitle, toolGroupAction, workEntryDisplayLabel, workEntryReadOutput, @@ -3722,19 +3725,67 @@ function LiveActivityContent({ ); } +/** The thread a `t3_thread_read` call targets, titled from live shell state so renames show. */ +function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { + const rawThreadId = threadReadTargetId(entry); + const threadId = rawThreadId === null ? null : ThreadId.make(rawThreadId); + const shell = useThreadShell(threadId ? scopeThreadRef(environmentId, threadId) : null); + const title = threadReadTargetTitle(shell); + return threadId && title ? { threadId, title } : null; +} + +function threadReadLabel(label: string, target: ReturnType) { + const prefix = target && threadReadLabelPrefix(label); + return prefix ? { ...target, prefix, text: `${prefix} โ€œ${target.title}โ€` } : null; +} + +/** Only settled rows link the title; the live row is itself a button. */ +function ThreadReadLabel({ + label, + environmentId, + linked, +}: { + label: NonNullable>; + environmentId: EnvironmentId; + linked: boolean; +}) { + return ( + + {label.prefix} โ€œ + {linked ? ( + + {label.title} + + ) : ( + {label.title} + )} + โ€ + + ); +} + function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); const questionHeading = row.entry.questionAnswer ? getQuestionTextPreview(row.entry.questionAnswer) : ""; const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const threadLabel = threadReadLabel(label, threadTarget); + const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( ); } - return ( - } - > - {children} - - - - - ); + return control; } diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx index ec7d84aef49b..a9f1867f39d2 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx @@ -74,8 +74,6 @@ describe("ThreadDetailsPanel", () => { startFromOrigin: false, onStartFromOriginChange: vi.fn(), onComposerFocusRequest: vi.fn(), - versionMismatch: null, - onDismissVersionMismatch: vi.fn(), onRunProjectScript: vi.fn(), onAddProjectScript: vi.fn() as ThreadDetailsPanelProps["onAddProjectScript"], onUpdateProjectScript: vi.fn() as ThreadDetailsPanelProps["onUpdateProjectScript"], diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index b4a709297229..4bfcaa788118 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -5,23 +5,16 @@ import type { ResolvedKeybindingsConfig, ThreadId, } from "@t3tools/contracts"; -import { AlertTriangleIcon, XIcon } from "lucide-react"; import type { DraftId } from "../../composerDraftStore"; import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts"; -import { - shouldShowEnvironmentIndicator, - type EnvMode, - type EnvironmentOption, -} from "../BranchToolbar.logic"; +import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic"; import { BranchToolbar } from "../BranchToolbar"; -import { BranchToolbarEnvironmentSelector } from "../BranchToolbarEnvironmentSelector"; import GitActionsControl from "../GitActionsControl"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { Button } from "../ui/button"; import type { ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; @@ -29,12 +22,6 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; -interface VersionMismatchIssue { - readonly clientVersion: string; - readonly serverVersion: string; - readonly serverLabel: string; -} - export interface ThreadDetailsPanelProps extends Pick< ComponentProps, "anchor" | "handle" | "onPresentationChange" @@ -66,8 +53,6 @@ export interface ThreadDetailsPanelProps extends Pick< onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest: () => void; onOpenChanges?: () => void; - versionMismatch: VersionMismatchIssue | null; - onDismissVersionMismatch: () => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -82,14 +67,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.environmentId, props.activeProjectScripts ? props.gitCwd : null, ); - // Same rule as the composer strip: a lone remote machine still gets a row, - // shown as a static label because there is nothing to pick. - const canPickEnvironment = props.availableEnvironments.length > 1; - const showEnvironment = shouldShowEnvironmentIndicator({ - activeEnvironment: - props.availableEnvironments.find((env) => env.environmentId === props.environmentId) ?? null, - canPickEnvironment, - }); const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, @@ -128,46 +105,19 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { separated={false} showHeading={false} > - {props.versionMismatch ? ( -
    - -
    -

    Client and server versions differ

    -

    - Client {props.versionMismatch.clientVersion} ยท{" "} - {props.versionMismatch.serverLabel} {props.versionMismatch.serverVersion} -

    -
    - -
    - ) : null} -
    - {density === "full" && showEnvironment ? ( - ) : null} - {density === "full" ? ( - - ) : null} - {density !== "essential" && props.showOpenInPicker ? ( ) : null; - const openRow = (text: string, part: "row" | "link-primary") => ( + const openRow = (text: string, part: "row" | "primary") => (
    ) : watching ? ( -
    - {openRow(label, "link-primary")} +
    + {openRow(label, "primary")}
    diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index cd1265e46a60..ef96d4105880 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -59,7 +59,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { - THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; @@ -461,12 +461,9 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? ( -
    +
    openThread(threadId)} @@ -534,7 +531,7 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" disabled={node?.missing === true} onClick={() => openThread(threadId)} - part={canStop ? "group-row" : "row"} + part="row" /> } > diff --git a/apps/web/src/components/chat/threadDetailsPanelStyles.ts b/apps/web/src/components/chat/threadDetailsPanelStyles.ts index 33c454e4232b..bd74bd95cd5e 100644 --- a/apps/web/src/components/chat/threadDetailsPanelStyles.ts +++ b/apps/web/src/components/chat/threadDetailsPanelStyles.ts @@ -6,29 +6,20 @@ const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = "bg-transparent shadow-none"; const THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS = - "hover:!bg-black/[0.055] data-pressed:!bg-black/[0.055] dark:hover:!bg-white/[0.075] dark:data-pressed:!bg-white/[0.075]"; + "hover:!bg-black/[0.035] data-pressed:!bg-black/[0.035] dark:hover:!bg-white/[0.08] dark:data-pressed:!bg-white/[0.08]"; const THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`; +const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.035] dark:data-popup-open:!bg-white/[0.08]`; +const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.035] dark:has-[[data-popup-open]]:bg-white/[0.08]`; export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left"; -// The row supplies the first tint; the hovered or open segment adds a second tint. -const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.055] dark:data-popup-open:!bg-white/[0.075]`; - const THREAD_DETAILS_PANEL_CONTROL_CLASS = `h-8 min-w-0 rounded-lg border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80`; -const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.055] dark:has-[[data-popup-open]]:bg-white/[0.075]`; export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SELECT_ROW_CLASS = `${THREAD_DETAILS_PANEL_ROW_CLASS} pe-0 [&_[data-slot=select-icon]]:-me-px [&_[data-slot=select-icon]]:relative [&_[data-slot=select-icon]]:flex [&_[data-slot=select-icon]]:h-full [&_[data-slot=select-icon]]:w-8 [&_[data-slot=select-icon]]:shrink-0 [&_[data-slot=select-icon]]:items-center [&_[data-slot=select-icon]]:justify-center [&_[data-slot=select-icon]]:before:absolute [&_[data-slot=select-icon]]:before:-left-px [&_[data-slot=select-icon]]:before:top-1/2 [&_[data-slot=select-icon]]:before:h-4 [&_[data-slot=select-icon]]:before:w-px [&_[data-slot=select-icon]]:before:-translate-y-1/2 [&_[data-slot=select-icon]]:before:bg-border/65 [&_[data-slot=select-icon]>svg]:me-0 [&_[data-slot=select-icon]>svg]:size-4 [&_[data-slot=select-icon]>svg]:text-muted-foreground [&_[data-slot=select-icon]>svg]:opacity-100`; -// No transition on the group: the halves are Buttons whose background snaps (their base only -// transitions shadow), so an eased group tint would land frames later and the hover would -// visibly commit in two steps. -export const THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS = `group/thread-details-link flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; - -export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; - export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-8 w-full min-w-0 justify-start rounded-lg border border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80 sm:h-8 sm:text-[13px]`; export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; @@ -40,7 +31,7 @@ export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rou /** A small text control on a row's second line, such as a pull request's checks or next action. */ export const THREAD_DETAILS_PANEL_META_ACTION_CLASS = `h-6 gap-1 rounded-md border-transparent px-1.5 text-xs font-medium text-muted-foreground ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; -export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; +export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 133fe9176238..c6b0591ee257 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1843,7 +1843,6 @@ function PullRequestsRouteView() { threadPanelOpen={false} threadPanelPresentation="inline" threadPanelShortcutLabel={null} - threadPanelHasAttention={false} onToggleThreadPanel={() => undefined} rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} From 7ac5ec6b15593f5b682fe73b483bc8e24de76665 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:51:31 -0400 Subject: [PATCH 027/530] fix(web): Copy MCP URL shows up for environments reached over plain http (#16909) Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/ConnectionsSettings.tsx | 9 +-- docs/user/outside-agents.md | 9 ++- .../src/connection/presentation.test.ts | 76 ++++++++++++++----- .../src/connection/presentation.ts | 39 ++++++---- 4 files changed, 92 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5877f1bbda6c..625efb562f97 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1665,21 +1665,20 @@ function SavedBackendListRow({ ) { setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); } + const prepared = usePreparedConnection(environmentId); + const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; const mcpUrl = environmentMcpUrl({ entry: environment.entry, relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, + connectedTarget, }); const machineKind = resolveEnvironmentMachineKind( environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), ); - const prepared = usePreparedConnection(environmentId); const routeCount = connectionRoutes(environment.entry).length; const subtitleText = [ - environmentTransportLabel( - environment, - isConnected && prepared._tag === "Some" ? prepared.value.target : null, - ), + environmentTransportLabel(environment, connectedTarget), resumingServerUpdate ? "Restarting" : status.text, enabled && versionMismatch ? serverVersion : null, ] diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 70129139206f..86294d68dc3c 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -16,11 +16,12 @@ example: https:///mcp ``` -The address must use HTTPS, or `localhost` when the agent runs on the host -itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address. +The copied URL uses the route this device is connected over, so an agent on the +same device can reach it. -- **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or - `localhost` on the host. See [remote access](./remote-access.md). +- **An agent on your own computers** can use any address that computer reaches + the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on + the host. See [remote access](./remote-access.md). - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach the environment from the internet. Use the T3 Connect address. A Tailscale address only works from your own tailnet. diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e9f4fe0d57eb..61d6278a5305 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + type ConnectionRoute, +} from "./catalog.ts"; import { BearerConnectionTarget, ConnectionBlockedError, ConnectionTransientError, + RelayConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; import { @@ -72,25 +77,62 @@ describe("connection presentation", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); - it("offers an MCP address only where an MCP client can sign in", () => { - expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); - const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ + it("copies the MCP address of the route this device is connected over", () => { + const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { + const target = new BearerConnectionTarget({ ...TARGET, connectionId }); + return { + target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + }; + }; + const lan = route("lan", "http://192.168.4.53:3773/"); + const tailnet = route("tailnet", "http://100.115.1.44:3773/"); + const serve = route("serve", "https://machine.tailnet.ts.net/"); + const entry: ConnectionCatalogEntry = { ...ENTRY, - profile: Option.some( - new BearerConnectionProfile({ - connectionId: TARGET.connectionId, - environmentId: TARGET.environmentId, - label: TARGET.label, - httpBaseUrl, - wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), - }), - ), + target: lan.target, + profile: lan.profile, + alternateRoutes: [tailnet, serve], + }; + + expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( + "http://100.115.1.44:3773/mcp", + ); + // Not connected: the preferred route, plain http or not. + expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); + }); + + it("passes over routes without an address of their own", () => { + const relay = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, }); - expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( - "http://127.0.0.1:3773/mcp", + const entry: ConnectionCatalogEntry = { + ...ENTRY, + target: relay, + profile: Option.none(), + alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }], + }; + + // Relay discovery has not reported the tunnel address yet. + expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe( + "https://environment.example.test/mcp", ); - // A plain-http LAN or tailnet address is refused by MCP clients' token checks. - expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); + expect( + environmentMcpUrl({ + entry, + connectedTarget: relay, + relayHttpBaseUrl: "https://tunnel.example.test", + }), + ).toBe("https://tunnel.example.test/mcp"); }); it("distinguishes initial connection, reconnect, and retry errors", () => { diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index a409847f0b5e..9015a755133e 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { SupervisorConnectionState } from "./model.ts"; +import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; +import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; export type EnvironmentConnectionPhase = | "available" @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( /** * The address an agent outside T3 (Claude Code, Codex) uses to reach this - * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP - * clients refuse to sign in through a plain-http token endpoint elsewhere. - * SSH connections ride a local forward that disappears with the client, so - * they have no stable address to hand out. + * environment's MCP server: the route this device is connected over, since an + * agent beside this client can reach it too, else the first route in + * preference order that has an address. SSH connections ride a local forward + * that disappears with the client, so they have no stable address. */ export function environmentMcpUrl(input: { readonly entry: ConnectionCatalogEntry; readonly relayHttpBaseUrl?: string | undefined; + readonly connectedTarget?: ConnectionTarget | null | undefined; }): string | null { - const httpBaseUrl = - input.entry.target._tag === "RelayConnectionTarget" - ? (input.relayHttpBaseUrl ?? null) - : input.entry.target._tag === "SshConnectionTarget" - ? null - : connectionCatalogDisplayUrl(input.entry); - if (httpBaseUrl === null) return null; + const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; + const routes = connectionRoutes(input.entry); + const connectedRoute = routes.find( + (route) => connectionRouteId(route.target) === connectedRouteId, + ); + for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { + const httpBaseUrl = + route.target._tag === "RelayConnectionTarget" + ? (input.relayHttpBaseUrl ?? null) + : routeHttpBaseUrl(route); + const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl); + if (mcpUrl !== null) return mcpUrl; + } + return null; +} + +function mcpUrlFromBase(httpBaseUrl: string): string | null { let url: URL; try { url = new URL(httpBaseUrl); } catch { return null; } - const loopback = - url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; - if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null; url.pathname = "/mcp"; url.search = ""; url.hash = ""; From abbf5d5bcd045cdb6baf16128ef54d4df3a7f3fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 15:05:31 -0700 Subject: [PATCH 028/530] fix(web): C#, Java, PHP and 11 other languages get file icons (#16974) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/pierre-icons.test.ts | 24 +++++++++- apps/web/src/pierre-icons.ts | 79 +++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 6090d7ed9e72..c90b130b33ea 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -38,7 +38,10 @@ describe("Pierre file icons", () => { it("ships every custom icon referenced by the extended resolver", () => { const customIconNames = new Set( - Object.values(T3_PIERRE_ICONS.byFileName).filter((name) => name.startsWith("t3-")), + [ + ...Object.values(T3_PIERRE_ICONS.byFileName), + ...Object.values(T3_PIERRE_ICONS.byFileExtension), + ].filter((name) => name.startsWith("t3-")), ); for (const iconName of customIconNames) { assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); @@ -64,4 +67,23 @@ describe("Pierre file icons", () => { it("gives a dockerfile fence the Docker icon", () => { assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId("dockerfile"))); }); + + it.each([ + "csharp", + "dart", + "diff", + "elixir", + "haskell", + "java", + "kotlin", + "lua", + "php", + "powershell", + "r", + "scala", + "toml", + "xml", + ])("gives a %s fence a language icon", (language) => { + assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId(language))); + }); }); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 46f0099fedee..ead46e89d6ee 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -28,6 +28,51 @@ const T3_FILE_ICON_SPRITE = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; export const T3_PIERRE_ICONS = { @@ -41,9 +86,33 @@ export const T3_PIERRE_ICONS = { "pnpm-lock.yaml": "t3-file-icon-pnpm", "pnpm-workspace.yaml": "t3-file-icon-pnpm", }, - byFileExtension: Object.fromEntries( - VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]), - ), + byFileExtension: { + ...Object.fromEntries( + VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]), + ), + // Languages the complete set has no icon for. + cs: "t3-file-icon-csharp", + csx: "t3-file-icon-csharp", + dart: "t3-file-icon-dart", + diff: "t3-file-icon-diff", + patch: "t3-file-icon-diff", + ex: "t3-file-icon-elixir", + exs: "t3-file-icon-elixir", + hs: "t3-file-icon-haskell", + lhs: "t3-file-icon-haskell", + java: "t3-file-icon-java", + kt: "t3-file-icon-kotlin", + kts: "t3-file-icon-kotlin", + lua: "t3-file-icon-lua", + php: "t3-file-icon-php", + ps1: "t3-file-icon-powershell", + psm1: "t3-file-icon-powershell", + psd1: "t3-file-icon-powershell", + r: "t3-file-icon-r", + scala: "t3-file-icon-scala", + toml: "t3-file-icon-toml", + xml: "t3-file-icon-xml", + }, } satisfies FileTreeIcons; const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); @@ -51,11 +120,15 @@ const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); const LANGUAGE_EXTENSION_ALIASES: Record = { bash: "sh", csharp: "cs", + elixir: "ex", + haskell: "hs", javascript: "js", jsx: "jsx", + kotlin: "kt", markdown: "md", mdx: "mdx", plaintext: "txt", + powershell: "ps1", python: "py", ruby: "rb", rust: "rs", From 95ccbf406626a8a80fa545777a6388bdf5153086 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 15:06:03 -0700 Subject: [PATCH 029/530] feat(clients): live row shows the agent's latest thought (#16284) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 3 ++ .../src/features/threads/thread-work-log.tsx | 28 ++++++++-- apps/mobile/src/lib/threadActivity.test.ts | 40 ++++++++++++-- apps/mobile/src/lib/threadActivity.ts | 15 +++++- .../chat/MessagesTimeline.logic.test.ts | 54 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 6 +++ .../src/components/chat/MessagesTimeline.tsx | 42 +++++++++++++-- apps/web/src/components/chat/WorkLog.tsx | 8 ++- .../src/work-log/presentation.test.ts | 38 +++++++++++++ .../src/work-log/presentation.ts | 20 +++++++ 10 files changed, 241 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c18874b1cb03..b5f258a0889f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1628,6 +1628,7 @@ function renderFeedEntry( summaryToolIcon={entry.summaryToolIcon} hasFailure={entry.hasFailure} shimmer={entry.shimmer} + thought={entry.thought} onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": + // A live thought wraps up to four lines, so that row measures itself. + return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 4a9067529510..32e6d272b678 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: { readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; + readonly thought?: string | undefined; readonly onToggle: () => void; }) { - const accessibilityLabel = props.hasFailure - ? `${props.summary}, tool call failed` - : props.summary; + const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; + const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel; const icon = props.summaryToolIcon ?? (props.toolSurface @@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: { return ( + {props.thought ? ( + // The latest thought sits above the status line as an ordinary work-log + // row, wrapped up to four lines. + + + + + + {props.thought} + + + ) : null} row.type === "work-toggle")).toMatchObject({ - summary: "First paragraph. Second paragraph.", + summary: "Thinking", + thought: "First paragraph.", live: true, }); } else { @@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])( }, ); +it("keeps the latest thought under the live tool status", () => { + const at = "2026-06-20T00:00:02.000Z"; + const thought: OrchestrationV2TurnItem = { + ...base("found-thought", at, 1), + type: "reasoning", + status: "completed", + streaming: false, + text: "Found the cause: no commits yet. Checking the UI next.", + }; + const tool = { ...command(at), status: "running" as const, completedAt: null }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ + thought: "Found the cause: no commits yet.", + live: true, + }); + expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ + summary: "Thinking", + }); +}); + it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ @@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool", at, ); expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "first-thought", + summary: "Thinking", + thought: "first-thought", live: true, shimmer: true, }); @@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool", ]) { const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer); expect(live).toHaveLength(1); - expect(live[0]).toMatchObject({ - summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp", - }); + expect(live[0]).toMatchObject( + items.at(-1)!.type === "reasoning" + ? { summary: "Thinking", thought: "next-thought" } + : { summary: "Running vp" }, + ); } expect(first.status).toBe("running"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d88b1dba2ea2..2dc513e40f02 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/client-runtime/work-log/command-label"; import { contextCompactionLabel, + liveThoughtLine, toolItemForDisplay, workEntryDisplayIndicatesToolFailure, liveActivityToolStatus, @@ -213,6 +214,8 @@ type ThreadFeedEntryContent = readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; + /** First sentence of the latest thought, shown above the live status line. */ + readonly thought?: string; } | { readonly type: "run-fold"; @@ -1496,8 +1499,17 @@ function appendToolGroupRows( const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const latestThought = + live && !expanded + ? activities.findLast( + (activity) => + activity.workEntry.itemType === "reasoning" && + (activity.workEntry.detail?.trim() ?? "") !== "", + )?.workEntry.detail + : undefined; + const thought = latestThought ? liveThoughtLine(latestThought) : ""; const summary = live - ? expanded && latestActivity.workEntry.itemType === "reasoning" + ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" ? latestActivity.lifecycleStatus === "inProgress" ? "Thinking" : "Thought" @@ -1552,6 +1564,7 @@ function appendToolGroupRows( ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), ...(summaryToolIcon ? { summaryToolIcon } : {}), + ...(thought ? { thought } : {}), hasFailure: (() => { const lastToolLike = activities.findLast((activity) => activity.toolLike); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e1ce7145d63a..98339041f68b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); }); + it("carries the latest thought on the live row while a later tool runs", () => { + type WorkEntry = Extract< + Parameters[0]["timelineEntries"][number], + { kind: "work" } + >; + const work = ( + id: string, + at: string, + fields: Omit, + ): WorkEntry => ({ + id: `${id}-entry`, + kind: "work", + createdAt: at, + entry: { id, createdAt: at, runId: "turn-1" as never, label: id, ...fields }, + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + work("old-thought", "2026-01-01T00:00:01Z", { + itemType: "reasoning", + detail: "First idea.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("new-thought", "2026-01-01T00:00:02Z", { + itemType: "reasoning", + detail: "Found the cause.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("running-command", "2026-01-01T00:00:03Z", { + command: "rg cause", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }), + ], + latestRun: { + runId: "turn-1" as never, + status: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "running-command" }, + thought: { id: "new-thought" }, + }); + }); + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b619a347255b..74f3991396ef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -540,6 +540,8 @@ type MessagesTimelineRowContent = groupId: string; expanded: boolean; active: boolean; + /** Latest reasoning in the live group, shown above the status line. */ + thought?: WorkLogEntry; } | { kind: "working"; @@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: { latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); + const latestThoughtEntry = visibleActiveToolEntries.findLast( + (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "", + ); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed @@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, active: latestToolKeepsActivityLive, + ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}), }; })() : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43f9746a6590..6145098f6043 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { + liveThoughtLine, resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, @@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > + {thoughtLine ? ( + // The latest thought's first sentence sits above the status line as an + // ordinary work-log row, so a finding never hides behind the next tool call. + + + + } + label={{thoughtLine}} + wrapLabel + /> + ) : null} {label} diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 4358f30f4de3..8ac63e0dae46 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -46,7 +46,13 @@ type RowContent = { function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { return ( -
    +
    {icon ? ( {icon} ) : null} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 8a73dd70fb73..6e41a53478a1 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import { commandDetailRepeatsCommand, extractCommandOutputText, + liveThoughtLine, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -960,3 +961,40 @@ describe("device group summaries", () => { ).toBe("Used 1 tool"); }); }); + +describe("liveThoughtLine", () => { + it("keeps the first sentence, including a closing quote", () => { + expect( + liveThoughtLine( + 'Found the cause: the repo has no commits, so `git worktree add` fails with "invalid reference: main." Now checking the UI.', + ), + ).toBe( + 'Found the cause: the repo has no commits, so git worktree add fails with "invalid reference: main."', + ); + }); + + it("does not cut at dots inside file names or long dashes", () => { + expect( + liveThoughtLine("I read ThreadLaunchService.ts \u2014 it skips the fetch. Next step."), + ).toBe("I read ThreadLaunchService.ts \u2014 it skips the fetch."); + }); + + it("uses a bold-only opening line as the whole line", () => { + expect( + liveThoughtLine("**Narrowing dispatch files**\n\nI should check the adapter. Then more."), + ).toBe("Narrowing dispatch files"); + expect(liveThoughtLine("**Narrowing dispatch files**\r\n\r\nI should check the adapter.")).toBe( + "Narrowing dispatch files", + ); + }); + + it("returns unpunctuated text whole and flattens markdown", () => { + expect(liveThoughtLine("- Checking [the docs](https://x.dev) for **limits**")).toBe( + "Checking the docs for limits", + ); + expect(liveThoughtLine("This is *really* ~~not~~ _fine_ in snake_case_names.")).toBe( + "This is really not fine in snake_case_names.", + ); + expect(liveThoughtLine(" ")).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f5e9daab7490..8ef6f58be595 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -754,3 +754,23 @@ export function toolGroupSummaryKind( ); return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; } + +/** + * Plain-text line for the latest thought in the live activity row. A + * bold-only opening line (the Codex summary heading) wins; otherwise this is + * the first sentence of the reasoning text. Web and mobile both render it. + */ +export function liveThoughtLine(markdown: string): string { + const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1]; + const text = (heading ?? markdown) + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "") + .replace(/`+|\*\*|~~/g, "") + .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); + if (heading !== undefined) return text; + // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows. + const end = /[.?!]["'โ€โ€™)]?(?=\s)/.exec(text); + return end ? text.slice(0, end.index + end[0].length) : text; +} From 300f7f9d45ff19c01bc987dbf1c3bc3e9a3a59ee Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Wed, 7 Oct 2026 23:29:43 +0100 Subject: [PATCH 030/530] feat(web): block-level Markdown in the rich text composer (#14677) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/web/package.json | 5 + apps/web/src/components/ChatMarkdown.tsx | 81 +- .../components/ComposerPromptEditorTiptap.tsx | 422 ++++++- .../chat/ComposerCodeBlockLanguagePicker.tsx | 121 ++ .../chat/ComposerCodeBlockNodeView.tsx | 67 ++ .../composerCodeBlockHighlight.test.ts | 152 +++ .../components/composerCodeBlockHighlight.ts | 231 ++++ apps/web/src/composer-block-shortcuts.test.ts | 126 +++ apps/web/src/composer-code-block.test.ts | 391 +++++++ apps/web/src/composer-code-block.ts | 254 +++++ apps/web/src/composer-code-languages.test.ts | 59 + apps/web/src/composer-code-languages.ts | 97 ++ .../src/composer-list-continuation.test.ts | 19 +- apps/web/src/composer-list-continuation.ts | 21 +- apps/web/src/composer-list-split.test.ts | 265 +++++ apps/web/src/composer-rich-text-doc.test.ts | 529 +++++++++ apps/web/src/composer-rich-text-doc.ts | 1002 +++++++++++++++-- apps/web/src/index.css | 84 +- docs/internals/composer-editors.md | 15 + docs/user/composer.md | 17 + pnpm-lock.yaml | 15 + 21 files changed, 3798 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx create mode 100644 apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx create mode 100644 apps/web/src/components/composerCodeBlockHighlight.test.ts create mode 100644 apps/web/src/components/composerCodeBlockHighlight.ts create mode 100644 apps/web/src/composer-block-shortcuts.test.ts create mode 100644 apps/web/src/composer-code-block.test.ts create mode 100644 apps/web/src/composer-code-block.ts create mode 100644 apps/web/src/composer-code-languages.test.ts create mode 100644 apps/web/src/composer-code-languages.ts create mode 100644 apps/web/src/composer-list-split.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index d17795f15c5d..915466100bcf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,7 +32,12 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-code": "^3.31.3", + "@tiptap/extension-code-block": "^3.31.3", + "@tiptap/extension-heading": "^3.31.3", + "@tiptap/extension-horizontal-rule": "^3.31.3", + "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25..e595efdc5a07 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -989,6 +989,63 @@ function MarkdownDetails({ ); } +/** + * The code block frame, shared with the composer so an editable fence and a + * rendered one cannot drift apart: the wrapper, its header row with the + * language title, an optional actions slot, and the body as children. The + * composer passes Tiptap's node view wrapper as the outer element. + */ +export function MarkdownCodeBlockFrame({ + as: Wrapper = "div", + language, + fenceTitle, + theme, + wrapped = true, + title, + actions, + headerProps, + children, +}: { + as?: React.ElementType; + language: string; + fenceTitle: string | null; + theme: "light" | "dark"; + wrapped?: boolean; + /** Replaces the language title, as the composer does with its language picker. */ + title?: React.ReactNode; + actions?: React.ReactNode; + headerProps?: React.HTMLAttributes; + children: React.ReactNode; +}) { + return ( + +
    + {title ?? ( + + + + )} + {actions} +
    + {children} +
    + ); +} + /** * Filename titles render icon + text; language-only titles render just the * icon (redundant next to its own name) and fall back to the language text @@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({ } return ( -
    -
    - - - + {leadingActions} {canWrap ? ( @@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({ ) : null} {copyButton} -
    + } + > {children} -
    + ); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index bac965c0b97d..6e830991ab7e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,11 +1,17 @@ -import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; -import { TaskList } from "@tiptap/extension-task-list"; +import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { + type EditorState, + NodeSelection, + Plugin, + PluginKey, + TextSelection, +} from "@tiptap/pm/state"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; +import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; import type { AssistantCitation, ComposerContextClipboardFragment, @@ -45,19 +51,34 @@ import { } from "~/composer-editor-mentions"; import { buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, buildTiptapContent, collapsedToFlat, caretTakesMarksBefore, + convertBulletItemToTask, ComposerCodeExtension, ComposerTaskItemExtension, + ComposerTaskListExtension, flatToCollapsed, flatToMarkdown, flatToPm, pmToFlat, serializeEditorDoc, + serializeSelection, + splitOrLiftListItem, stepCaretAcrossStyledEdge, type SkillMeta, } from "~/composer-rich-text-doc"; +import { + convertCodeFenceOnEnter, + exitCodeBlockOnClosingFence, + exitCodeBlockOnTrailingBlankLines, + indentCodeBlock, + indentedNewlineInCodeBlock, + selectionInOneCodeBlock, +} from "~/composer-code-block"; import { COMPOSER_UNDO_GROUP_DELAY, type ComposerChangeKind, @@ -65,6 +86,7 @@ import { markAsClipboardEdit, } from "~/composer-undo-grouping"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { FileTagChipContent } from "./chat/FileTagChip"; @@ -82,6 +104,8 @@ import { import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; +import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; import { importPastedComposerText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { let range: StyledRange | null = null; let openLength = 0; for (const run of map.runs) { + // A fence is drawn as a block, not revealed a character at a time, so its + // delimiters never become marker widgets. + if (run.nodeName === "codeBlock") continue; if (run.openLen > 0) { range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; range.markers.push({ @@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { return ranges; } +/** + * A typed marker becomes a list item that remembers the marker it was typed + * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. + * + * `- ` alone is not enough for a dash: it waits for the first character after + * the space, which it carries into the new item. That is what lets the GFM + * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule, + * instead of being cut off by an instant bullet. A `- ` left on its own is + * still a bullet the next time the draft is rebuilt. + */ +function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { + return new InputRule({ + find, + handler: ({ state, range, match, chain }) => { + const marker = match.groups?.marker ?? "-"; + const space = match.groups?.space ?? " "; + const carried = match.groups?.carried ?? ""; + // Top-level paragraphs only: inside an item or a quote the new list + // would nest under a line the stored draft writes flat. + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + const command = chain() + .deleteRange(range) + .wrapInList( + listType, + listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, + ) + .updateAttributes("listItem", { marker, space }); + (carried ? command.insertContent(carried) : command).run(); + return undefined; + }, + }); +} + +/** + * `[ ] ` at the start of an existing bullet item turns it into a task, for + * items that were already a list when the checkbox was wanted. New tasks are + * typed whole, `- [ ] `, and reach the task rule directly. + */ +const bulletToTaskInputRule = new InputRule({ + find: /^\[([ xX])\] $/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + const item = $from.node(-1); + if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; + // Any bullet converts; the task grammar only knows `-`, so a `*` or `+` + // item comes back out as `- [ ]`. + if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null; + const checked = (match[1] ?? " ").toLowerCase() === "x"; + chain() + .command(({ tr }) => { + convertBulletItemToTask(tr, range.from, range.to, checked); + return true; + }) + .run(); + return undefined; + }, +}); + +/** `- [ ] ` or `- [x] ` at a top-level paragraph, for the same reason as the list markers. */ +function taskInputRule(type: NodeType): InputRule { + const rule = wrappingInputRule({ + find: /^- \[([ xX])\] $/, + type, + getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + }); + return new InputRule({ + find: rule.find, + handler: (props) => + props.state.doc.resolve(props.range.from).depth === 1 ? rule.handler(props) : null, + }); +} + +function hasAncestor($pos: ResolvedPos, name: string): boolean { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if ($pos.node(depth).type.name === name) return true; + } + return false; +} + +/** + * `> ` at the start of a top-level paragraph opens a quote. Not inside a list: + * a quote holds prose lines, and a list item is not one. + */ +const blockquoteInputRule = new InputRule({ + find: /^>(\s)$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` }) + .run(); + return undefined; + }, +}); + +/** + * `---` becomes a rule as the third dash lands; `***` and `___` need a space + * after them so typing bold or an underscore is not interrupted, matching + * Tiptap's own rule. Only at a top-level paragraph: the list and quote + * serializers have no line to write a rule into. The typed characters are + * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a + * rule at the end so the caret has somewhere to go. + */ +const horizontalRuleInputRule = new InputRule({ + find: /^(---|\*\*\*|___)\s?$/, + handler: ({ state, range, match, chain }) => { + const source = match[0] ?? "---"; + if (!source.startsWith("---") && !/\s$/.test(source)) return null; + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setHorizontalRule() + .command(({ tr }) => { + // The rule is the block before the caret's paragraph. + const $pos = tr.selection.$from; + const index = $pos.index(0) - 1; + if (index < 0) return true; + const rulePos = $pos.posAtIndex(index, 0); + const rule = tr.doc.nodeAt(rulePos); + if (rule?.type.name === "horizontalRule") { + tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source }); + } + return true; + }) + .run(); + return undefined; + }, +}); + +/** + * `# ` through `###### ` at a top-level paragraph make a heading. The space + * is required, which is exactly what keeps `#1234` a pull request reference + * with its picker rather than a heading. Not inside lists or quotes, whose + * serializers have no line for one. + */ +const headingInputRule = new InputRule({ + find: /^(#{1,6})(\s)$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " }) + .run(); + return undefined; + }, +}); + +/** Whether the caret sits inside a fenced code block. */ +function isInCodeBlock(view: EditorView): boolean { + return view.state.selection.$from.parent.type.spec.code === true; +} + const MarkerPluginKey = new PluginKey("composer-rich-markers"); const ComposerMarkerPlugin = new Plugin({ @@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { expandedCursor: nextExpandedCursor, contextIds: map.contextIds, }; - const cursorAdjacentToMention = + // A fence holds no chips, so nothing in it should summon the mention or + // command menu: `@` in code is a decorator, not a file. Suppressing the + // trigger here also keeps the store from inserting a link the block can + // only show as literal text, which the store would then count as a chip. + const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; + const suppressTrigger = + inCodeBlock || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - map.contextIds, - ); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); }, []); const editorAttributes = useMemo( @@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { gapcursor: false, trailingNode: false, code: false, + // The list extensions handle Backspace and Delete themselves. + listKeymap: false, undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, // Plain mode has no marks: typed markers stay literal characters. ...(richText ? {} : { bold: false, italic: false, strike: false }), @@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ...(richText ? [ ComposerCodeExtension, - TaskList, - ComposerTaskItemExtension.extend({ + ComposerCodeBlockExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { + // The header holds the language picker. Tiptap only shields + // events aimed at the button element itself, so a click on + // its icon or label would reach ProseMirror and move the + // selection; keep every header event away from the editor. + stopEvent: ({ event }) => + event.target instanceof Element && + event.target.closest(".chat-markdown-codeblock-header") !== null, + }); + }, + // Tiptap's own ``` + space rule would open a fence inside a + // list item or quote, where the serializer has no line for + // it. Enter on a fence line covers the gesture at top level. addInputRules() { - return [ - wrappingInputRule({ - find: /^- \[([ xX])\] $/, - type: this.type, - getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + return []; + }, + }), + composerCodeBlockHighlight({ + resolveTheme: () => + resolveDiffThemeName( + document.documentElement.classList.contains("dark") ? "dark" : "light", + ), + }), + ...ComposerBlockExtensions.map((extension) => + extension.name === "blockquote" + ? extension.extend({ + addInputRules() { + return [blockquoteInputRule]; + }, + }) + : extension.name === "horizontalRule" + ? extension.extend({ + addInputRules() { + return [horizontalRuleInputRule]; + }, + }) + : extension.name === "heading" + ? extension.extend({ + addInputRules() { + return [headingInputRule]; + }, + }) + : extension, + ), + ...ComposerListExtensions.map((extension) => + extension.name === "listItem" + ? extension + : extension.extend({ + addInputRules() { + return this.name === "bulletList" + ? [ + listMarkerInputRule(/^(?[*+])(?\s)$/, "bulletList"), + listMarkerInputRule( + /^(?-)(?[ \t]+)(?[^\s[])$/, + "bulletList", + ), + ] + : [ + listMarkerInputRule( + /^(?\d+[.)])(?\s)$/, + "orderedList", + ), + ]; + }, }), - ]; + ), + ComposerTaskListExtension, + ComposerTaskItemExtension.extend({ + addInputRules() { + return [taskInputRule(this.type), bulletToTaskInputRule]; }, }), ] @@ -982,23 +1229,71 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { event.preventDefault(); return true; } + // Inside a fence Tab belongs to the code, not to the composer's + // focus order or its autocomplete. + if ( + event.key === "Tab" && + !event.metaKey && + !event.ctrlKey && + selectionInOneCodeBlock(view.state) + ) { + event.preventDefault(); + event.stopPropagation(); + return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => + view.dispatch(tr), + ); + } + // A fence is multi-line by definition, so Enter and Shift+Enter + // belong to the code rather than to sending or splitting: inside a + // block they make a line (a generic split would cut the fence in + // two), and on a line that is only an opening fence they open the + // block. Sending from inside a fence is still Cmd/Ctrl+Enter, which + // falls through. + if ( + event.key === "Enter" && + richText && + !event.metaKey && + !event.ctrlKey && + !event.isComposing + ) { + if (selectionInOneCodeBlock(view.state)) { + event.preventDefault(); + event.stopPropagation(); + const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); + return ( + exitCodeBlockOnClosingFence(view.state, dispatch) || + exitCodeBlockOnTrailingBlankLines(view) || + indentedNewlineInCodeBlock(view.state, dispatch) || + newlineInCode(view.state, dispatch) + ); + } + if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + } const handler = onCommandKeyDownRef.current; if (event.key === "Enter") { const instance = editorHolder.current; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); - const handled = handler?.("Enter", event, isTaskItem) ?? false; + const isListItem = richText && (instance?.isActive("listItem") ?? false); + const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; if (handled) { event.preventDefault(); event.stopPropagation(); return true; } event.preventDefault(); + if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) { + return true; + } if ( - isTaskItem && + richText && instance && - (instance.commands.splitListItem("taskItem", { checked: false }) || - (view.state.selection.$from.parent.content.size === 0 && - instance.commands.liftListItem("taskItem"))) + hasAncestor(view.state.selection.$from, "blockquote") && + view.state.selection.$from.parent.content.size === 0 && + instance.commands.lift("blockquote") ) { return true; } @@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const pastedText = clipboardData.getData("text/plain"); if (!pastedText) return false; event.preventDefault(); + // A fence takes the clipboard verbatim. Running the markdown path + // here would split the block on newlines, nest a pasted fence and + // build chips the code block's schema cannot hold anyway. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } const importFragment = importFragmentRef.current; let text = importFragment ? importPastedComposerText(clipboardData, importFragment) @@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } const editorInstance = editorHolder.current; if (editorInstance) { - insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { - // Tagged on the same transaction insertContent builds, so the - // paste is one undo step of its own. - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - .insertContent(content) - .run(); - }); + // Inside a list item or quote, pasted block markup has nowhere to + // go: it stays literal lines the next rebuild reads back. Inline + // marks still apply. + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested }, + (content) => { + // Tagged on the same transaction insertContent builds, so the + // paste is one undo step of its own. + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + .insertContent(content) + .command(({ tr }) => { + // A paste ending in a rule leaves the rule selected, and + // the next keystroke would replace it. The caret goes to + // the line after it, which is added when there is none. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); scrollTiptapCaretIntoView(editorInstance); } return true; @@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const clipboardData = event.clipboardData; const { from, to } = editor.state.selection; if (from === to) return; - const { doc, schema } = editor.state; - const slice = doc.slice(from, to); - const first = slice.content.firstChild; - const content = first?.isInline - ? schema.nodes.paragraph!.create(null, slice.content) - : first?.type.name === "taskItem" - ? schema.nodes.taskList!.create(null, slice.content) - : slice.content; - const text = serializeEditorDoc(doc.type.create(null, content)).value; + const text = serializeSelection(editor.state.doc, from, to); const contextIds = Array.from(new Set(collectInlineContextIds(text))); const fragment = contextIds.length > 0 ? build?.(contextIds) : null; event.preventDefault(); @@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean }, + options: { styling: boolean; blocks?: boolean }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx new file mode 100644 index 000000000000..bf200bab53d8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx @@ -0,0 +1,121 @@ +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + CODE_BLOCK_LANGUAGES, + type CodeBlockLanguage, + codeLanguageEntry, + codeLanguageLabel, +} from "~/composer-code-languages"; +import { syntheticFileNameForLanguageId } from "~/pierre-icons"; + +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; +import { PierreEntryIcon } from "./PierreEntryIcon"; + +/** + * The language's file icon: plain text gets the text-file icon, and a + * language the icon set does not cover gets the default file icon, so every + * entry has one and the labels line up. + */ +function LanguageIcon(props: { language: string; theme: "light" | "dark" }) { + const fileName = props.language ? syntheticFileNameForLanguageId(props.language) : "file.txt"; + return ( + + ); +} + +/** + * The language control in a composer fence's header: shows what the fence + * declares and switches it from a searchable list. A language typed on the + * fence line that the list does not carry is offered as its own entry, so + * the current choice is always visible and checked. + */ +export function ComposerCodeBlockLanguagePicker(props: { + language: string; + theme: "light" | "dark"; + disabled: boolean; + onChange: (language: string) => void; +}) { + const [query, setQuery] = useState(""); + const current = codeLanguageEntry(props.language)?.id ?? props.language; + const items = useMemo( + () => + CODE_BLOCK_LANGUAGES.some((entry) => entry.id === current) + ? [...CODE_BLOCK_LANGUAGES] + : [{ id: current, label: current }, ...CODE_BLOCK_LANGUAGES], + [current], + ); + const filteredItems = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return items; + return items.filter( + (entry) => entry.label.toLowerCase().includes(needle) || entry.id.includes(needle), + ); + }, [items, query]); + const selected = items.find((entry) => entry.id === current) ?? null; + + return ( + // The trigger's pill pads its content by 8px (padding plus border) and the + // button pulls a leading icon back 2px; offsetting the difference lines + // the icon up with the code below while keeping the hover pill. + + entry.label} + isItemEqualToValue={(a, b) => a.id === b.id} + value={selected} + onOpenChange={(open) => { + if (!open) setQuery(""); + }} + onValueChange={(entry) => { + if (entry && entry.id !== current) props.onChange(entry.id); + }} + > + + } + > + + {codeLanguageLabel(props.language)} + + + + setQuery(event.target.value)} + /> + No matching languages. + + {(entry: CodeBlockLanguage) => ( + + + {entry.label} + {entry.id === current ? : null} + + )} + + + + + ); +} diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx new file mode 100644 index 000000000000..fcaf93af9413 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx @@ -0,0 +1,67 @@ +import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; + +import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; + +import { useTheme } from "../../hooks/useTheme"; +import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; +import { ComposerCodeBlockLanguagePicker } from "./ComposerCodeBlockLanguagePicker"; + +/** + * Draws a composer fence in the chat view's own code block frame, so a draft + * looks like the message it is about to become, with the language title + * swapped for a picker that rewrites the fence's language in place. + * + * The chrome stops at the language. Chat's wrap and copy buttons act on text + * the reader cannot change; here the text is the draft, and both are already + * a selection away. + */ +export function ComposerCodeBlockNodeView({ + node, + editor, + getPos, + updateAttributes, +}: NodeViewProps) { + const { resolvedTheme } = useTheme(); + const info = typeof node.attrs.language === "string" ? node.attrs.language : ""; + const language = languageOfInfoString(info); + + const changeLanguage = (next: string) => { + updateAttributes({ language: withInfoStringLanguage(info, next) }); + // Back into the code, at its end, the way a picker hands focus back. + const position = getPos(); + if (typeof position === "number") { + editor + .chain() + .focus(position + 1 + node.content.size) + .run(); + } + }; + + return ( + + } + > +
    +
    +          {/* A caret needs a line to sit on even before any code is typed. */}
    +          
    +            as="code"
    +            className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]"
    +          />
    +        
    +
    +
    + ); +} diff --git a/apps/web/src/components/composerCodeBlockHighlight.test.ts b/apps/web/src/components/composerCodeBlockHighlight.test.ts new file mode 100644 index 000000000000..bd765dbd7b44 --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.test.ts @@ -0,0 +1,152 @@ +import { Editor } from "@tiptap/core"; +import type { EditorView } from "@tiptap/pm/view"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc"; +import type { DiffThemeName } from "~/lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +import { + composerCodeBlockHighlight, + MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH, + shouldHighlightCodeBlock, + tokenizeBlock, +} from "./composerCodeBlockHighlight"; + +vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() })); + +const THEME = "github-dark" as DiffThemeName; + +/** + * Stands in for the Shiki highlighter so the offset arithmetic can be checked + * without loading a real grammar. Splits each line into whitespace-delimited + * tokens, which is enough shape for the flattening logic under test. + */ +function fakeHighlighter(colorFor: (content: string) => string | undefined) { + return { + codeToTokens(code: string) { + return { + tokens: code.split(/\r?\n/).map((line) => + line + .split(/(\s+)/) + .filter((part) => part.length > 0) + .map((content) => ({ content, color: colorFor(content) })), + ), + }; + }, + } as unknown as Parameters[0]; +} + +describe("composer code block highlighting", () => { + it("maps token offsets to positions within the block text", () => { + const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); + + const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); + + expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); + }); + + it("accounts for the newline between lines", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); + + // "one" is 3 characters, then the newline, so "two" starts at 4. + expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); + }); + + it("accounts for both characters of a CRLF line break", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME); + + expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]); + }); + + it("skips whitespace-only tokens so indentation is not decorated", () => { + const highlighter = fakeHighlighter(() => "#0000ff"); + + const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); + + expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); + }); + + it("falls back to plain text when the grammar throws", () => { + const throwing = { + codeToTokens() { + throw new Error("unsupported language"); + }, + } as unknown as Parameters[0]; + + expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); + }); +}); + +describe("the highlighting size cap", () => { + // Each keystroke re-tokenizes the whole fence on the main thread; past a + // few hundred lines that stalls typing, so large fences stay plain. + it("highlights a fence up to the cap", () => { + expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH))).toBe(true); + }); + + it("leaves a fence past the cap plain", () => { + expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false); + }); +}); + +describe("a highlighter that fails to load", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * Shiki can fail to initialize at all. The blocks it could not reach stay + * plain, the ones it could are still painted, and nothing is left rejected. + */ + it("still repaints the blocks whose highlighter loaded", async () => { + vi.mocked(getSyntaxHighlighterPromise).mockImplementation((language) => + language === "ts" + ? Promise.resolve(fakeHighlighter(() => "#ff0000")) + : Promise.reject(new Error("Shiki failed to initialize")), + ); + const editor = new Editor({ + extensions: [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, + composerCodeBlockHighlight({ resolveTheme: () => THEME }), + ], + content: { + type: "doc", + content: [ + { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "a" }] }, + { type: "codeBlock", attrs: { language: "py" }, content: [{ type: "text", text: "b" }] }, + ], + }, + }); + // The plugin watches for theme changes; the unit suite has no DOM. + vi.stubGlobal("document", { documentElement: {} }); + vi.stubGlobal( + "MutationObserver", + class { + observe() {} + disconnect() {} + }, + ); + // ProseMirror names each plugin after its key, though it does not type it. + const plugin = editor.extensionManager.plugins.find((candidate) => + (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"), + ); + const repainted = new Promise((resolve) => { + const view = { + get state() { + return editor.state; + }, + dispatch: () => resolve(), + } as unknown as EditorView; + plugin?.spec.view?.(view); + }); + + await expect(repainted).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts new file mode 100644 index 000000000000..68f024cb907a --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -0,0 +1,231 @@ +import { Extension } from "@tiptap/core"; +import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import type { DiffThemeName } from "~/lib/diffRendering"; +import { languageOfInfoString } from "~/composer-code-languages"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +interface HighlightedBlock { + /** Guards against repainting a block whose text and language are unchanged. */ + readonly signature: string; + readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; +} + +const composerCodeBlockHighlightKey = new PluginKey("composerCodeBlockHighlight"); + +/** + * Past this many characters a fence is left plain. Any edit changes a block's + * signature, so each keystroke re-tokenizes the whole block synchronously on + * the main thread: a few milliseconds for a couple of hundred lines, but ~80ms + * at a thousand, which stalls typing. 20k characters is roughly 400โ€“500 lines + * of ordinary code; a fence that shrinks back under it is highlighted again. + */ +export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000; + +export function shouldHighlightCodeBlock(code: string): boolean { + return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH; +} +function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { + // A separator that cannot occur in a language name keeps the parts distinct. + return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); +} + +/** + * The composer's code blocks, which are always top-level: list items and + * quotes cannot hold one. Scanning only the top level keeps a keystroke in a + * long draft from walking every node in it. + */ +function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { + const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; + state.doc.forEach((node, pos) => { + if (node.type.name === "codeBlock") blocks.push({ node, pos }); + }); + return blocks; +} + +/** + * Highlights composer code blocks with the same Shiki instance the chat view + * uses, painted as inline decorations so the text nodes stay editable. + * + * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the + * block that changed, and only after its highlighter has resolved. Blocks whose + * text and language are unchanged reuse their previous decorations. + */ +export function composerCodeBlockHighlight(options: { + resolveTheme: () => DiffThemeName; +}): Extension { + return Extension.create({ + name: "composerCodeBlockHighlight", + + addProseMirrorPlugins() { + // Every keystroke in a code block mints a new signature, so each scan + // drops the signatures the document no longer holds. The cache is then + // bounded by the document itself, and no block it holds is ever evicted. + const cache = new Map(); + // The latest scan's signatures, so a tokenize that resolves after its + // block was edited away does not put it back. + let live = new Set(); + + return [ + new Plugin({ + key: composerCodeBlockHighlightKey, + + state: { + init: () => DecorationSet.empty, + apply(transaction, value, _oldState, newState) { + if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { + return value.map(transaction.mapping, transaction.doc); + } + return buildDecorations(newState, cache, options.resolveTheme()); + }, + }, + + props: { + decorations(state) { + return composerCodeBlockHighlightKey.getState(state); + }, + }, + + view(view) { + let disposed = false; + let paintedTheme = options.resolveTheme(); + let scannedDoc: ProseMirrorNode | null = null; + + /** + * Tokenizes any block missing from the cache, then repaints once. + * Returning early when nothing is pending is what stops the + * dispatch below from re-triggering this on its own update. + */ + const refresh = () => { + const theme = options.resolveTheme(); + const themeChanged = theme !== paintedTheme; + paintedTheme = theme; + // A selection change cannot alter what needs tokenizing, and + // building a signature means concatenating every block's text. + if (!themeChanged && view.state.doc === scannedDoc) return; + scannedDoc = view.state.doc; + const blocks = collectCodeBlocks(view.state); + live = new Set(blocks.map(({ node }) => blockSignature(node, theme))); + for (const signature of cache.keys()) { + if (!live.has(signature)) cache.delete(signature); + } + const pending = blocks.filter( + ({ node }) => + shouldHighlightCodeBlock(node.textContent) && + !cache.has(blockSignature(node, theme)), + ); + if (pending.length === 0) { + // A theme switch keeps every signature but changes which one + // applies, so the painted decorations still have to be rebuilt. + if (themeChanged) { + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + } + return; + } + + // Settled rather than all: a block whose highlighter fails to + // load stays plain, and the others are still painted. + void Promise.allSettled( + pending.map(async ({ node }) => { + // The stored info string keeps everything after the language + // (`js title=x`); Shiki wants only the name. + const language = + languageOfInfoString(String(node.attrs.language ?? "")) || "text"; + const signature = blockSignature(node, theme); + const highlighter = await getSyntaxHighlighterPromise(language); + if (disposed || cache.has(signature) || !live.has(signature)) return; + cache.set(signature, { + signature, + decorations: tokenizeBlock(highlighter, node.textContent, language, theme), + }); + }), + ).then(() => { + if (disposed) return; + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + }); + }; + + refresh(); + // The theme lives on , outside the editor, so nothing would + // otherwise tell the view a repaint is due. + const themeObserver = new MutationObserver(refresh); + themeObserver.observe(document.documentElement, { + attributeFilter: ["class"], + }); + + return { + update: refresh, + destroy() { + disposed = true; + themeObserver.disconnect(); + }, + }; + }, + }), + ]; + }, + }); +} + +type Highlighter = Awaited>; + +/** Flattens Shiki's line/token structure into offsets within the block's text. */ +export function tokenizeBlock( + highlighter: Highlighter, + code: string, + language: string, + theme: DiffThemeName, +): ReadonlyArray<{ from: number; to: number; color: string }> { + let tokens; + try { + tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; + } catch { + // An unsupported language should read as plain text, not break the editor. + return []; + } + + const decorations: Array<{ from: number; to: number; color: string }> = []; + let offset = 0; + for (const [lineIndex, line] of tokens.entries()) { + // The separator between lines, which Shiki drops; CRLF is two characters. + if (lineIndex > 0) offset += code.startsWith("\r\n", offset) ? 2 : 1; + for (const token of line) { + const length = token.content.length; + if (token.color && token.content.trim()) { + decorations.push({ from: offset, to: offset + length, color: token.color }); + } + offset += length; + } + } + return decorations; +} + +function buildDecorations( + state: EditorState, + cache: Map, + theme: DiffThemeName, +): DecorationSet { + const decorations: Decoration[] = []; + for (const { node, pos } of collectCodeBlocks(state)) { + if (!shouldHighlightCodeBlock(node.textContent)) continue; + const highlighted = cache.get(blockSignature(node, theme)); + if (!highlighted) continue; + // Token offsets index the block's text, which lines up with document + // positions only because `codeBlock` is `text*` with no marks โ€” one + // unmarked text node, so `content.size === textContent.length`. Allowing + // marks or hard breaks in the code block would desync these silently. + if (node.content.size !== node.textContent.length) continue; + // +1 steps past the code block's opening token into its text. + const start = pos + 1; + for (const decoration of highlighted.decorations) { + decorations.push( + Decoration.inline(start + decoration.from, start + decoration.to, { + style: `color:${decoration.color}`, + }), + ); + } + } + return DecorationSet.create(state.doc, decorations); +} diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts new file mode 100644 index 000000000000..8ecd47d0e299 --- /dev/null +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -0,0 +1,126 @@ +import { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, + ComposerTaskItemExtension, + ComposerTaskListExtension, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +// The same modifier ProseMirror's keymap reads "Mod" as. +const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform); + +/** A key chord as the browser reports it: key, keyCode and modifiers. */ +function chord(spec: string) { + const parts = spec.split("-"); + const key = parts.pop()!; + const has = (name: string) => parts.includes(name); + const base = key.length === 1 ? key.toUpperCase() : key; + return { + key: has("Shift") && /^\d$/.test(key) ? "!@#$%^&*("[Number(key) - 1]! : key, + keyCode: base.length === 1 ? base.charCodeAt(0) : 0, + shiftKey: has("Shift"), + altKey: has("Alt"), + metaKey: has("Mod") && mac, + ctrlKey: has("Mod") && !mac, + preventDefault() {}, + stopPropagation() {}, + } as unknown as KeyboardEvent; +} + +/** + * Presses `spec` with the caret inside `value`, the way the composer's + * editor would receive it, and returns the stored Markdown. + */ +function press(value: string, spec: string) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + orderedList: false, + trailingNode: false, + }), + ComposerCodeBlockExtension, + ...ComposerBlockExtensions, + ...ComposerListExtensions, + ComposerTaskListExtension, + ComposerTaskItemExtension, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + // What the view's `someProp("handleKeyDown")` does, without mounting one: + // offer the key to each extension plugin in order until one handles it. + // An unmounted editor's state does not carry them yet, so they come from + // the extension manager. + const event = chord(spec); + const before = editor.state.doc; + const handled = editor.extensionManager.plugins.some((plugin) => + plugin.props.handleKeyDown?.call(plugin, editor.view, event), + ); + return { + handled, + changed: !editor.state.doc.eq(before), + value: serializeEditorDoc(editor.state.doc).value, + }; +} + +// Tiptap's own block shortcuts would nest a list, task list, code block or +// heading inside a quote, or restructure a list item. The serializers only +// write what the composer's grammar has, so the nested block, and the text +// in it, would vanish from the stored draft. None of these are the +// composer's shortcuts; they must do nothing. +const BLOCK_CHORDS = [ + "Mod-Shift-8", + "Mod-Shift-7", + "Mod-Shift-9", + "Mod-Shift-b", + "Mod-Alt-c", + "Mod-Alt-1", + "Mod-Alt-3", +]; + +describe("Tiptap's block shortcuts", () => { + it.each(BLOCK_CHORDS)("leave a quote's text alone on %s", (spec) => { + expect(press("> keep this text", spec).value).toBe("> keep this text"); + }); + + it.each(BLOCK_CHORDS)("leave a list item alone on %s", (spec) => { + expect(press("- item", spec).value).toBe("- item"); + }); + + it.each(BLOCK_CHORDS)("leave a paragraph alone on %s", (spec) => { + expect(press("plain text", spec).value).toBe("plain text"); + }); + + // Guards the harness itself: a chord the composer keeps must still reach + // the keymap, or every case above would pass without pressing anything. + it("still delivers the composer's own keys", () => { + expect(press("**bold**", "Mod-b").handled).toBe(true); + }); + + // Sinking or lifting an item moves it without touching its `indent`, so + // the stored draft keeps the old nesting and the next rebuild undoes the + // move. Nesting belongs to the composer's own Tab, which edits the source. + it.each([ + ["- p\n- a", "Tab"], + ["- p\n - a", "Shift-Tab"], + ["- [ ] p\n- [ ] a", "Tab"], + ["- [ ] p\n - [ ] a", "Shift-Tab"], + ])("leave the nesting of %j alone on %s", (value, spec) => { + const result = press(value, spec); + expect(result.changed).toBe(false); + expect(result.value).toBe(value); + }); +}); diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts new file mode 100644 index 000000000000..3a0ec2fe127c --- /dev/null +++ b/apps/web/src/composer-code-block.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Editor, Node } from "@tiptap/core"; +import { newlineInCode } from "@tiptap/pm/commands"; +import { TextSelection } from "@tiptap/pm/state"; + +import { + CODE_BLOCK_INDENT, + indentCodeBlock, + indentLines, + convertCodeFenceOnEnter, + exitCodeBlockOnClosingFence, + exitCodeBlockOnTrailingBlankLines, + indentedNewlineInCodeBlock, + leadingWhitespace, + outdentLine, +} from "./composer-code-block"; +import StarterKit from "@tiptap/starter-kit"; + +import { + ComposerCodeBlockExtension, + ComposerListExtensions, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +const extensions = [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, + ...ComposerListExtensions, + Node.create({ + name: "composer-mention", + group: "inline", + inline: true, + atom: true, + addAttributes: () => ({ path: { default: "" }, source: { default: "" } }), + }), +]; + +describe("leadingWhitespace", () => { + it("reads the indent a new line should inherit", () => { + expect(leadingWhitespace(" nested()")).toBe(" "); + expect(leadingWhitespace("\tnested()")).toBe("\t"); + expect(leadingWhitespace("flush()")).toBe(""); + }); + + it("stops at the first non-space so code is never treated as indent", () => { + expect(leadingWhitespace(" a b")).toBe(" "); + }); +}); + +describe("outdentLine", () => { + it("removes a full indent when there is one", () => { + expect(outdentLine(" deep")).toBe(" deep"); + }); + + /** Hand-indented lines should still outdent rather than refuse to move. */ + it("falls back to a single space or tab", () => { + expect(outdentLine(" odd")).toBe("odd"); + expect(outdentLine("\ttabbed")).toBe("tabbed"); + }); + + it("bottoms out instead of eating code", () => { + expect(outdentLine("flush()")).toBe("flush()"); + }); +}); + +describe("indentLines", () => { + it("indents every line it is given", () => { + expect(indentLines(["a", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + /** Indenting a blank line would leave whitespace the user cannot see. */ + it("leaves blank lines alone", () => { + expect(indentLines(["a", "", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + "", + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + it("outdents every line it is given", () => { + expect(indentLines([" a", " b", "c"], "out")).toEqual([" a", "b", "c"]); + }); +}); + +/** Builds an editor holding a single fenced block, with the caret placed by offset. */ +function codeEditor(code: string, at?: { from: number; to?: number }) { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }], + }, + }); + const start = 1; + const from = start + (at?.from ?? code.length); + const to = start + (at?.to ?? at?.from ?? code.length); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), + ); + return editor; +} + +const textOf = (editor: Editor) => editor.state.doc.textContent; + +describe("indentedNewlineInCodeBlock", () => { + it("carries the current indentation onto the new line", () => { + const editor = codeEditor("function f() {\n body()"); + const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(handled).toBe(true); + expect(textOf(editor)).toBe("function f() {\n body()\n "); + }); + + /** + * With no indent to carry there is nothing to add, so the default newline + * should handle it and stay a single undo step. + */ + it("declines an unindented line", () => { + const editor = codeEditor("flush()"); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(textOf(editor)).toBe("flush()"); + }); + + it("indents from the line the caret is on, not the last line", () => { + const editor = codeEditor(" deep()\nflush()", { from: 10 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" deep()\n \nflush()"); + }); + + it("replaces the selected text rather than keeping it", () => { + const editor = codeEditor(" keep()DROP", { from: 8, to: 12 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" keep()\n "); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: " hi" }] }], + }, + }); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +/** Enter inside a fence, in the order the composer's key handler tries it. */ +function pressEnter(editor: Editor) { + const dispatch = (tr: Parameters[0]) => editor.view.dispatch(tr); + return ( + exitCodeBlockOnTrailingBlankLines(editor.view) || + indentedNewlineInCodeBlock(editor.state, dispatch) || + newlineInCode(editor.state, dispatch) + ); +} + +describe("exitCodeBlockOnTrailingBlankLines", () => { + it("leaves the block once two blank lines end it", () => { + const editor = codeEditor("flush()"); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.childCount).toBe(1); + pressEnter(editor); + expect(editor.state.doc.firstChild?.textContent).toBe("flush()"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + }); + + /** Enter carries indentation, so the blank lines after nested code are not empty. */ + it("counts lines holding only carried indentation as blank", () => { + const editor = codeEditor(" nested()"); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.childCount).toBe(1); + pressEnter(editor); + expect(editor.state.doc.firstChild?.textContent).toBe(" nested()"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + }); + + /** An unclosed fence runs to the end of the draft, so leaving it must close it. */ + it("closes an unclosed fence on the way out", () => { + const editor = codeEditor("flush()"); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "close", "")); + pressEnter(editor); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.firstChild?.attrs.close).toBe("\n```"); + }); + + it("stays in the block when the caret is not at its end", () => { + const editor = codeEditor("a\n\n\nb", { from: 3 }); + expect(exitCodeBlockOnTrailingBlankLines(editor.view)).toBe(false); + }); +}); + +describe("exitCodeBlockOnClosingFence", () => { + const exit = (editor: Editor) => + exitCodeBlockOnClosingFence(editor.state, (tr) => editor.view.dispatch(tr)); + + it("ends the block at a typed closing fence and starts a line after it", () => { + const editor = codeEditor("const a = 1\n```"); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "language", "ts")); + expect(exit(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("explain this")); + expect(serializeEditorDoc(editor.state.doc).value).toBe( + "```ts\nconst a = 1\n```\nexplain this", + ); + }); + + it("moves the lines after the fence out of the block", () => { + const editor = codeEditor("a\n```\nb", { from: "a\n```".length }); + expect(exit(editor)).toBe(true); + expect(serializeEditorDoc(editor.state.doc).value).toBe("```\na\n```\n\nb"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parent.textContent).toBe(""); + }); + + it.each([ + ["a line that cannot close the fence", "a\n``", undefined], + ["a caret before the end of the line", "a\n```", { from: "a\n``".length }], + ])("ignores %s", (_, code, at) => { + expect(exit(codeEditor(code, at))).toBe(false); + }); +}); + +describe("indentCodeBlock", () => { + it("inserts an indent at a collapsed caret", () => { + const editor = codeEditor("ab", { from: 1 }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe(`a${CODE_BLOCK_INDENT}b`); + }); + + it("indents every line a selection touches", () => { + const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two\n three"); + }); + + /** A selection of whole lines ends at the start of the next one, which it does not touch. */ + it("leaves the line a selection ends at the start of", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 4 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\ntwo"); + }); + + it("outdents every line a selection touches", () => { + const editor = codeEditor(" one\n two", { from: 2, to: 9 }); + indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + /** Repeated Tab presses should keep working on the same block. */ + it("keeps the selection across the shifted lines", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two"); + }); + + it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], + }, + }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +describe("selections across two identical fences", () => { + /** Two structurally equal code blocks; the selection spans from one into the other. */ + function twoBlockEditor() { + const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] }; + const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } }); + // First block: 1..4 (text " a" occupies 1..4); second block starts at 5. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)), + ); + return editor; + } + + it("leaves Tab and Enter alone rather than editing through the boundary", () => { + const editor = twoBlockEditor(); + const before = editor.getJSON(); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); +}); + +describe("convertCodeFenceOnEnter", () => { + /** Builds an editor holding one paragraph with the caret at its end. */ + function paragraphEditor(text: string) { + const editor = new Editor({ + extensions, + content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }, + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)), + ); + return editor; + } + + it.each([ + ["```", "```", ""], + ["```ts", "```", "ts"], + ["~~~py", "~~~", "py"], + ["````", "````", ""], + ["```ts title=example", "```", "ts title=example"], + ["``` js", "```", " js"], + ])("turns %s into a code block", (text, fence, language) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + const block = editor.state.doc.firstChild!; + expect(block.type.name).toBe("codeBlock"); + expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` }); + expect(block.textContent).toBe(""); + // The caret belongs inside the new block, ready for the first line. + expect(editor.state.selection.from).toBe(1); + }); + + it.each(["``", "text ```", "```a`b"])("leaves %s alone", (text) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); + + it("refuses to open a fence inside a list item", () => { + const text = "```ts"; + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }, + ], + }, + ], + }, + }); + // Inside listItem > paragraph: position 3 is the paragraph start. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)), + ); + const before = editor.getJSON(); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); + + it("keeps a chip's source in the info string", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "```ts " }, + { type: "composer-mention", attrs: { path: "a.md", source: "@a.md" } }, + ], + }, + ], + }, + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + expect(editor.state.doc.firstChild?.attrs.language).toBe("ts @a.md"); + }); + + it("ignores a fence with the caret before its end", () => { + const editor = paragraphEditor("```ts"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts new file mode 100644 index 000000000000..f4f28765f76c --- /dev/null +++ b/apps/web/src/composer-code-block.ts @@ -0,0 +1,254 @@ +import { exitCode } from "@tiptap/pm/commands"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { isClosingFence, parseOpeningFence, serializeSelection } from "~/composer-rich-text-doc"; + +/** + * Spaces rather than a tab: the fence round-trips through Markdown on its way + * to the agent, and a literal tab there renders at whatever width the reader + * happens to use. Two matches how this repo indents its own source. + */ +export const CODE_BLOCK_INDENT = " "; + +/** The leading run of spaces and tabs on a line, which Enter carries forward. */ +export function leadingWhitespace(line: string): string { + return /^[ \t]*/.exec(line)?.[0] ?? ""; +} + +/** + * Removes one indent's worth of leading whitespace, tolerating lines that were + * indented by hand with an odd number of spaces or with tabs. A line with no + * leading whitespace is returned unchanged, so outdenting bottoms out quietly + * rather than eating the first character of code. + */ +export function outdentLine(line: string): string { + if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length); + if (line.startsWith("\t")) return line.slice(1); + if (line.startsWith(" ")) return line.slice(1); + return line; +} + +/** Applies Tab/Shift+Tab to every line a selection touches, whole lines at a time. */ +export function indentLines(lines: ReadonlyArray, direction: "in" | "out"): string[] { + return lines.map((line) => { + // Indenting a blank line would leave trailing whitespace behind on a line + // the user cannot see the caret move on, so leave it be. + if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`; + return outdentLine(line); + }); +} + +/** Whether every endpoint of the selection sits in the same code block. */ +export function selectionInOneCodeBlock(state: EditorState): boolean { + const { $from, $to } = state.selection; + return $from.parent.type.spec.code === true && $from.sameParent($to); +} + +/** + * The code block containing the selection, or null when the selection is + * outside a fence or spans out of one. Both ends must sit in the same block: a + * selection that reaches past the fence belongs to the document, not to the + * code. + */ +function codeBlockRange( + state: EditorState, +): { readonly from: number; readonly to: number; readonly text: string } | null { + const { $from, $to } = state.selection; + const parent = $from.parent; + if (parent.type.spec.code !== true) return null; + // Same occurrence, not merely equal structure: two identical fences would + // otherwise let a selection across them edit through the block boundary. + if (!$from.sameParent($to)) return null; + const from = $from.start(); + return { from, to: from + parent.content.size, text: parent.textContent }; +} + +/** + * Enter inside a fence, carrying the current line's indentation onto the new + * line. ProseMirror's default splits the text but starts the new line at column + * zero, which means re-typing the indent on every line of a nested block. + */ +export function indentedNewlineInCodeBlock( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const beforeCursor = block.text.slice(0, from - block.from); + const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1); + const indent = leadingWhitespace(currentLine); + + // Nothing to carry, so let the default newline handle it and keep this path + // out of the undo history. + if (indent.length === 0) return false; + + if (dispatch) { + const inserted = `\n${indent}`; + const transaction = state.tr.insertText(inserted, from, to); + const caret = from + inserted.length; + transaction.setSelection(TextSelection.create(transaction.doc, caret)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Two blank lines at the end of a fence leave it, the way every code editor + * does. Without this a fence at the end of the prompt is a trap: Enter only + * ever adds another line and there is no way back to prose. A line holding + * only the indentation Enter carried forward counts as blank, and is removed + * with the rest. + */ +export function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean { + const { $from, empty } = view.state.selection; + if (!empty || $from.parent.type.spec.code !== true) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const trailing = /\n[ \t]*\n[ \t]*$/.exec($from.parent.textContent); + if (!trailing) return false; + const transaction = view.state.tr.delete($from.pos - trailing[0].length, $from.pos); + // An unclosed fence runs to the end of the draft; the prose after it would + // be read back as code, so leaving it writes the closing fence. + if (!$from.parent.attrs.close) { + transaction.setNodeAttribute($from.before(), "close", `\n${$from.parent.attrs.fence}`); + } + view.dispatch(transaction); + return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView())); +} + +/** + * Enter at the end of a line that would close the fence, such as ```` ``` ```` + * typed under the code, ends the block there. That line becomes the closing + * fence, and any lines after it continue as paragraphs after a new empty one, + * which is how the stored draft reads them. + */ +export function exitCodeBlockOnClosingFence( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block || !state.selection.empty) return false; + const caret = state.selection.from - block.from; + const lineStart = block.text.lastIndexOf("\n", caret - 1) + 1; + const newlineAfter = block.text.indexOf("\n", caret); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + const line = block.text.slice(lineStart, lineEnd); + const node = state.selection.$from.parent; + if (caret !== lineEnd || !isClosingFence(line, String(node.attrs.fence))) return false; + + if (dispatch) { + const { schema } = state; + const paragraph = (text: string) => + schema.nodes.paragraph!.create(null, text ? schema.text(text) : null); + const code = block.text.slice(0, Math.max(0, lineStart - 1)); + const rest = newlineAfter === -1 ? [] : block.text.slice(newlineAfter + 1).split("\n"); + const closed = node.type.create( + { ...node.attrs, close: `\n${line}` }, + code ? schema.text(code) : null, + ); + const start = state.selection.$from.before(); + const transaction = state.tr.replaceWith(start, start + node.nodeSize, [ + closed, + paragraph(""), + ...rest.map(paragraph), + ]); + transaction.setSelection(TextSelection.create(transaction.doc, start + closed.nodeSize + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Tab and Shift+Tab inside a fence. A collapsed cursor indents at the caret so + * Tab works mid-line the way typing does; any selection spanning text indents + * the whole lines it touches, which is what makes re-indenting a block possible. + */ +export function indentCodeBlock( + state: EditorState, + direction: "in" | "out", + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const startOffset = from - block.from; + const endOffset = to - block.from; + + if (from === to && direction === "in") { + if (dispatch) { + const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to); + dispatch(transaction.scrollIntoView()); + } + return true; + } + + // Grow the range to whole lines so indenting is stable no matter where in the + // first and last lines the selection happens to start and end. + const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; + // A selection ending at the start of a line does not touch that line. + const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + + const originalLines = block.text.slice(lineStart, lineEnd).split("\n"); + const nextLines = indentLines(originalLines, direction); + if (nextLines.join("\n") === originalLines.join("\n")) return true; + + if (dispatch) { + const transaction = state.tr.insertText( + nextLines.join("\n"), + block.from + lineStart, + block.from + lineEnd, + ); + // Keep the same lines selected after the shift, so repeated Tab presses + // keep indenting the block rather than collapsing the selection. + // `split` always yields at least one entry, so these stand in only for the + // compiler's benefit. + const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0); + const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length; + const nextFrom = Math.max(block.from + lineStart, from + firstDelta); + const nextTo = Math.max(nextFrom, to + totalDelta); + transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Enter on a line that is nothing but an opening fence turns it into a code + * block. Tiptap's own input rules only fire on a space, so without this the + * gesture every markdown editor has โ€” ```` ```ts ```` then Enter โ€” leaves the + * fence as literal text. + */ +export function convertCodeFenceOnEnter( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const { $from, empty } = state.selection; + // Top-level paragraphs only: the list and quote serializers have no line + // to write a fence into, so one created inside them would vanish. + if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + // The parser's own grammar, read from the line's stored source, so a line + // Enter turns into a fence is exactly one the stored draft reads back as a + // fence, info string and all, including any chip's source in it. + const opening = parseOpeningFence(serializeSelection(state.doc, $from.start(), $from.end())); + const codeBlock = state.schema.nodes.codeBlock; + if (!opening || !codeBlock) return false; + + if (dispatch) { + const blockStart = $from.before(); + const { fence, language } = opening; + const transaction = state.tr.replaceWith( + blockStart, + blockStart + $from.parent.nodeSize, + codeBlock.create({ language, fence, close: `\n${fence}` }), + ); + transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} diff --git a/apps/web/src/composer-code-languages.test.ts b/apps/web/src/composer-code-languages.test.ts new file mode 100644 index 000000000000..cc29b29dfd39 --- /dev/null +++ b/apps/web/src/composer-code-languages.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + CODE_BLOCK_LANGUAGES, + codeLanguageLabel, + languageOfInfoString, + withInfoStringLanguage, +} from "./composer-code-languages"; + +describe("languageOfInfoString", () => { + it.each([ + ["", ""], + ["ts", "ts"], + [" ts ", "ts"], + ["js title=example", "js"], + ["typescript ", "typescript"], + ])("reads %j as %j", (info, language) => { + expect(languageOfInfoString(info)).toBe(language); + }); +}); + +describe("withInfoStringLanguage", () => { + it.each([ + ["", "ts", "ts"], + ["js", "python", "python"], + // Anything after the language belongs to the fence and stays put. + ["js title=example", "ts", "ts title=example"], + ["js {1,3}", "ts", "ts {1,3}"], + // Plain text clears the language... + ["js", "", ""], + // ...but a bare `title=x` would read as the language, so it keeps `text`. + ["js title=example", "", "text title=example"], + // Whitespace between the fence and the language is the user's formatting. + [" js title=x", "python", " python title=x"], + [" js title=x", "", " text title=x"], + [" js", "go", " go"], + [" ", "go", " go"], + ])("sets %j to %j as %j", (info, language, expected) => { + expect(withInfoStringLanguage(info, language)).toBe(expected); + }); +}); + +describe("codeLanguageLabel", () => { + it.each([ + ["", "Plain text"], + ["text", "Plain text"], + ["cpp", "C++"], + ["ts", "TypeScript"], + ["arduino", "arduino"], + ])("labels %j as %j", (language, label) => { + expect(codeLanguageLabel(language)).toBe(label); + }); + + it("lists every language once, plain text first", () => { + const ids = CODE_BLOCK_LANGUAGES.map((language) => language.id); + expect(ids[0]).toBe(""); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/apps/web/src/composer-code-languages.ts b/apps/web/src/composer-code-languages.ts new file mode 100644 index 000000000000..1c6a68aab750 --- /dev/null +++ b/apps/web/src/composer-code-languages.ts @@ -0,0 +1,97 @@ +/** + * The languages a composer fence can be switched to from its header, and how + * a choice is written back into the fence's info string. + * + * Ids are the names the highlighter and the file icons both know. The list + * is curated rather than every grammar the highlighter bundles: a picker is + * for the common case, and any other language can still be typed on the + * fence line, where it shows as its own entry. + */ +export const CODE_BLOCK_LANGUAGES = [ + { id: "", label: "Plain text" }, + { id: "bash", label: "Bash" }, + { id: "c", label: "C" }, + { id: "cpp", label: "C++" }, + { id: "csharp", label: "C#" }, + { id: "css", label: "CSS" }, + { id: "dart", label: "Dart" }, + { id: "diff", label: "Diff" }, + { id: "dockerfile", label: "Dockerfile" }, + { id: "elixir", label: "Elixir" }, + { id: "go", label: "Go" }, + { id: "graphql", label: "GraphQL" }, + { id: "haskell", label: "Haskell" }, + { id: "html", label: "HTML" }, + { id: "java", label: "Java" }, + { id: "javascript", label: "JavaScript" }, + { id: "json", label: "JSON" }, + { id: "jsx", label: "JSX" }, + { id: "kotlin", label: "Kotlin" }, + { id: "lua", label: "Lua" }, + { id: "markdown", label: "Markdown" }, + { id: "php", label: "PHP" }, + { id: "powershell", label: "PowerShell" }, + { id: "python", label: "Python" }, + { id: "r", label: "R" }, + { id: "ruby", label: "Ruby" }, + { id: "rust", label: "Rust" }, + { id: "scala", label: "Scala" }, + { id: "scss", label: "SCSS" }, + { id: "sql", label: "SQL" }, + { id: "svelte", label: "Svelte" }, + { id: "swift", label: "Swift" }, + { id: "toml", label: "TOML" }, + { id: "tsx", label: "TSX" }, + { id: "typescript", label: "TypeScript" }, + { id: "vue", label: "Vue" }, + { id: "xml", label: "XML" }, + { id: "yaml", label: "YAML" }, + { id: "zig", label: "Zig" }, +] as const; + +export type CodeBlockLanguage = { readonly id: string; readonly label: string }; + +/** Short names people type on the fence line, labelled like the entry they mean. */ +const LANGUAGE_ALIASES: Record = { + cs: "csharp", + "c++": "cpp", + js: "javascript", + md: "markdown", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + shell: "bash", + ts: "typescript", + yml: "yaml", +}; + +/** The language is the info string's first word; the rest belongs to the fence. */ +export function languageOfInfoString(info: string): string { + return info.trim().split(/\s+/, 1)[0] ?? ""; +} + +/** + * Replaces the language and keeps everything around it: the whitespace the + * fence had before it and whatever follows it. Plain text clears it, unless + * something follows: a bare `title=x` would read as the language, so that + * keeps `text` in front. + */ +export function withInfoStringLanguage(info: string, language: string): string { + const leading = /^\s*/.exec(info)![0]; + // The language is the first word, so what follows it is empty or starts + // with whitespace. + const rest = info.slice(leading.length + languageOfInfoString(info).length); + if (language) return `${leading}${language}${rest}`; + return rest.trim() ? `${leading}text${rest}` : ""; +} + +/** The canonical entry a language names, if the picker lists it. */ +export function codeLanguageEntry(language: string): CodeBlockLanguage | null { + const id = language === "text" ? "" : (LANGUAGE_ALIASES[language.toLowerCase()] ?? language); + return CODE_BLOCK_LANGUAGES.find((entry) => entry.id === id.toLowerCase()) ?? null; +} + +export function codeLanguageLabel(language: string): string { + return codeLanguageEntry(language)?.label ?? language; +} diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index b92514dd2714..a878ed95a125 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation"; +import { + listContinuationForEnter, + listIndentForTab, + nextOrderedMarkerText, +} from "./composer-list-continuation"; function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); @@ -66,3 +70,16 @@ describe("composer list continuation", () => { expect(listIndentForTab("- foo", 1, 3)).toBeNull(); }); }); + +describe("nextOrderedMarkerText", () => { + it.each([ + ["1.", "2."], + ["1)", "2)"], + ["09.", "10."], + ["001)", "002)"], + ["99.", "100."], + ["99999999999999999999.", "99999999999999999999."], + ])("counts %s up to %s", (marker, expected) => { + expect(nextOrderedMarkerText(marker)).toBe(expected); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 952dfa549475..be617da2a268 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -55,13 +55,24 @@ function parseListMarker(line: string): { marker: ListMarker; markerEnd: number return null; } +/** + * The ordered marker that follows `3.` or `09)`: one higher, same delimiter, + * zero padding kept, and a number too large to count left as typed. Shared + * with the rich editor's native split so both modes count the same way. + */ +export function nextOrderedMarkerText(marker: string): string { + const numberText = marker.slice(0, -1); + const delimiter = marker.slice(-1); + const number = Number.parseInt(numberText, 10); + const next = Number.isSafeInteger(number) + ? String(number + 1).padStart(numberText.length, "0") + : numberText; + return `${next}${delimiter}`; +} + function nextMarkerText(marker: ListMarker): string { if (marker.kind === "ordered") { - const number = Number.parseInt(marker.numberText, 10); - const next = Number.isSafeInteger(number) - ? String(number + 1).padStart(marker.numberText.length, "0") - : marker.numberText; - return `${marker.indent}${next}${marker.delimiter} `; + return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `; } if (marker.kind === "task") { return `${marker.indent}- [ ] `; diff --git a/apps/web/src/composer-list-split.test.ts b/apps/web/src/composer-list-split.test.ts new file mode 100644 index 000000000000..0e5c4d6a3958 --- /dev/null +++ b/apps/web/src/composer-list-split.test.ts @@ -0,0 +1,265 @@ +// @vitest-environment jsdom + +import { Editor } from "@tiptap/core"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + backspaceAcrossList, + buildDocJson, + buildTiptapContent, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, + ComposerTaskItemExtension, + convertBulletItemToTask, + deleteAcrossList, + serializeEditorDoc, + splitOrLiftListItem, +} from "./composer-rich-text-doc"; + +// ProseMirror's DOM observer can flush on a timer after a test ends, which +// throws once jsdom is torn down, so every editor is destroyed after its test. +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy(); +}); + +/** + * Splits the last item of `value` the way Shift+Enter does, types `b`, and + * returns the stored Markdown. Tiptap carries every attribute left at the + * default `keepOnSplit: true` onto the new item and merges the overrides on + * top, so the new item must keep the source marker, indent and spacing. + */ +function makeEditor(value: string) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + codeBlock: false, + blockquote: false, + heading: false, + horizontalRule: false, + trailingNode: false, + listKeymap: false, + }), + ...ComposerListExtensions, + TaskList, + ComposerTaskItemExtension, + ComposerCodeBlockExtension, + ...ComposerBlockExtensions, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editors.push(editor); + return editor; +} + +function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { + const editor = makeEditor(value); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.commands.splitListItem(type, overrides); + editor.view.dispatch(editor.state.tr.insertText("b")); + return serializeEditorDoc(editor.state.doc).value; +} + +describe("splitting a list item", () => { + it.each([ + ["* a", { space: " " }, "* a\n* b"], + ["+ a", { space: " " }, "+ a\n+ b"], + ["- p\n - a", { space: " " }, "- p\n - a\n - b"], + ["3) a", { marker: "4)", space: " " }, "3) a\n4) b"], + ])("keeps the source marker and indent of %s", (value, overrides, expected) => { + expect(splitLastItem(value, "listItem", overrides)).toBe(expected); + }); + + it("keeps the indent of a nested task", () => { + expect(splitLastItem("- [ ] p\n - [ ] a", "taskItem", { checked: false })).toBe( + "- [ ] p\n - [ ] a\n - [ ] b", + ); + }); +}); + +/** Puts the caret `offset` characters into the first text node holding `text`. */ +function placeCaret(editor: Editor, text: string, offset = 0) { + let caret = -1; + editor.state.doc.descendants((node, pos) => { + if (caret < 0 && node.isText && node.text!.includes(text)) { + caret = pos + node.text!.indexOf(text) + offset; + } + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret))); +} + +/** The stored draft, after checking a rebuild from it gives back the same document. */ +function storedDraft(editor: Editor) { + const stored = serializeEditorDoc(editor.state.doc).value; + expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + return stored; +} + +describe("Shift+Enter twice on an item", () => { + // The second Shift+Enter lifts the new empty item one level. The stored + // draft must write it at its new depth, so a rebuild from that draft gives + // back the document the user is looking at. + it.each([ + ["- a\n - x", "- a\n - x\n- b"], + ["* a\n * x", "* a\n * x\n* b"], + ["1. a\n 1. x", "1. a\n 1. x\n2. b"], + ["- a\n - x\n- c", "- a\n - x\n- b\n- c"], + ["- a\n - x\n - y", "- a\n - x\n- b\n - y"], + ["- [ ] a\n - [ ] x", "- [ ] a\n - [ ] x\n- [ ] b"], + ["- [x] a\n - [ ] x\n- [ ] c", "- [x] a\n - [ ] x\n- [ ] b\n- [ ] c"], + ])("after x in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + let caret = -1; + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === "x") caret = pos + 1; + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret)), + ); + expect(splitOrLiftListItem(editor)).toBe(true); + expect(splitOrLiftListItem(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("b")); + const stored = serializeEditorDoc(editor.state.doc).value; + expect(stored).toBe(expected); + // Same nesting after a rebuild; spacing defaults may differ but write the same. + expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + }); +}); + +describe("Shift+Enter in the middle of an ordered list", () => { + it.each([ + ["1. a\n2. x\n3. c\n4. d", "1. a\n2. x\n3. b\n4. c\n5. d"], + ["1) a\n2) x\n3) c", "1) a\n2) x\n3) b\n4) c"], + ["1. a\n 1. x\n 2. c\n2. d", "1. a\n 1. x\n 2. b\n 3. c\n2. d"], + ])("after x in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "x", 1); + expect(splitOrLiftListItem(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("b")); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("Shift+Enter on an empty task under a bullet", () => { + // There is no task list one level up to move it into, and the item cannot + // hold a second line, so the draft stays what the editor shows. + it.each(["- a\n - [ ] x", "- [ ] a\n - x"])("in %j keeps the draft honest", (value) => { + const editor = makeEditor(value); + placeCaret(editor, "x", 1); + splitOrLiftListItem(editor); + splitOrLiftListItem(editor); + storedDraft(editor); + }); +}); + +describe("Backspace at the start of a line", () => { + it.each([ + ["- a\n - x", "x", "- a\n- x"], + ["- a\n - x\n - y", "x", "- a\n- x\n - y"], + ["- [ ] a\n - [ ] x", "x", "- [ ] a\n- [ ] x"], + ["- a\n- b\n- c", "b", "- a\nb\n- c"], + // A task under a bullet has no task list to move into, so it joins up. + ["- a\n - [ ] x", "x", "- ax"], + ["- a\n - x\nb", "b", "- a\n - xb"], + ["1. a\nb", "b", "1. ab"], + ])("in %j at %s writes %j", (value, at, expected) => { + const editor = makeEditor(value); + placeCaret(editor, at); + expect(backspaceAcrossList(editor)).toBe(true); + expect(storedDraft(editor)).toBe(expected); + }); + + it.each([ + ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], + ["p\n```ts\nl1\n```", "p\nl1"], + ])("removes the fence of %j rather than joining its code up", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "l1"); + editor.commands.keyboardShortcut("Backspace"); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("Delete at the end of a line", () => { + it.each([ + ["- a\n- b", "- ab"], + ["- a\n - x\n- c", "- ax\n- c"], + ["- a\n\nb", "- a\nb"], + ])("in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "a", 1); + expect(deleteAcrossList(editor)).toBe(true); + expect(storedDraft(editor)).toBe(expected); + }); + + it.each(["- a\n```\nl1\n```", "a\n```\nl1\n```"])("leaves the fence in %j alone", (value) => { + const editor = makeEditor(value); + placeCaret(editor, "a", 1); + editor.commands.keyboardShortcut("Delete"); + expect(storedDraft(editor)).toBe(value); + }); +}); + +describe("blocks that a list item or quote cannot hold", () => { + it("puts lines pasted at the end of an item after the list", () => { + const editor = makeEditor("- a"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); + editor.commands.insertContent(lines); + expect(storedDraft(editor)).toBe("- a\none\ntwo"); + }); + + it("will not make a task list inside a quote", () => { + const editor = makeEditor("> q"); + placeCaret(editor, "q"); + expect(editor.commands.wrapInList("taskList")).toBe(false); + expect(storedDraft(editor)).toBe("> q"); + }); +}); + +describe("[ ] typed at the start of a bullet item", () => { + // The value holds `[ ]` already; the rule fires on the space after it. + it.each([ + ["* [ ]x", "- [ ] x"], + ["* a\n* [ ]x\n* c", "* a\n- [ ] x\n* c"], + ["- a\n * [ ]x", "- a\n - [ ] x"], + ["- a\n - [ ]x\n - y", "- a\n - [ ] x\n - y"], + ["- [ ]x\n - y", "- [ ] x\n - y"], + ["- [ ]x", "- [ ] x"], + ])("makes the item in %j a task where it stands", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "[ ]x"); + const from = editor.state.selection.from; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from + 3)), + ); + const { tr } = editor.state; + convertBulletItemToTask(tr, from, from + 3, false); + editor.view.dispatch(tr); + expect(editor.state.selection.$from.parent.textContent).toBe("x"); + expect(editor.state.selection.$from.parentOffset).toBe(0); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("a rule at the end of the draft", () => { + it("keeps an empty line after it to type on", () => { + const editor = makeEditor("a\n---"); + // Removing the line after the rule puts one straight back. + editor.view.dispatch( + editor.state.tr.delete(editor.state.doc.content.size - 2, editor.state.doc.content.size), + ); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + expect(storedDraft(editor)).toBe("a\n---"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.view.dispatch(editor.state.tr.insertText("b")); + expect(storedDraft(editor)).toBe("a\n---\nb"); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 44f8e6d1f6d1..5b26b74cacfd 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -5,9 +5,15 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; import { describe, expect, it } from "vite-plus/test"; +import { collapseExpandedComposerCursor } from "./composer-logic"; + import { buildDocJson, + buildTiptapContent, caretTakesMarksBefore, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -16,6 +22,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + serializeSelection, stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; @@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions( }), TaskList, ComposerTaskItemExtension, + ComposerCodeBlockExtension, + ...ComposerListExtensions, + ...ComposerBlockExtensions, ]), ); @@ -344,6 +354,437 @@ describe("composer rich text document model", () => { expect(roundTripPlain(value).value).toBe(value); }); + it.each([ + "- one\n- two", + "* star\n* star two", + "+ plus", + "* star\n+ plus", + "1. first\n2. second", + "1) paren\n2) paren", + "3. starts at three\n4. four", + "01. zero padded\n02. two", + "- parent\n - child\n - sibling\n- uncle", + "1. ordered\n - bullet child\n2. next", + "- outer\n 1. inner ordered\n 2. more\n- outer again", + "- wide space item", + "-", + "1.", + "- \n- second", + "- **bold** item with @README.md", + "- [ ] task\n- bullet after", + "- bullet\n- [x] task after", + "para\n- item\npara", + "\t- tab indented\n\t- again", + " - leading indent only", + "- item\n\n- after a blank", + "- item one\n```ts\ncode\n```\n- item two", + "-no space stays literal", + "1.no space stays literal", + "10. ten\n11. eleven", + ])("round-trips the list %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "- one\n- two", + "1. first\n2. second", + "- parent\n - child", + "* star\n+ plus", + "-no space stays literal", + ])("keeps the list %s literal in plain mode", (value) => { + expect(roundTripPlain(value).value).toBe(value); + }); + + it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])( + "maps every document offset of the list %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a list marker to the start of the item text", () => { + const value = "- item"; + const map = roundTrip(value); + // The marker owns no document characters, like a checkbox. + for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "- it".length)).toBe(2); + expect(flatToCollapsed(map, 0)).toBe("- ".length); + }); + + it.each([ + "> quoted", + "> line one\n> line two", + ">no space", + "> two spaces", + "> a\n>b", + ">", + "> **bold** and @README.md inside", + "> - looks like a list but stays quote text", + "> > nested stays literal inside the quote", + "before\n> quoted\nafter", + "> quote\n\n> another", + "- item\n> quote after list", + ])("round-trips the quote %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "---", + "***", + "___", + "- - -", + "* * *", + "-----", + "--- ", + "text\n---\nmore", + "- item\n---\n- item two", + "```\n---\n```", + "--", + "-- -", + "---text", + ])("round-trips the rule %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses the blocks it renders as the right node types", () => { + const json = buildDocJson("# Title\n> quote\n---\n#1234 ref\n- - -", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "paragraph", + "horizontalRule", + // The empty line a final rule keeps for the caret. + "paragraph", + ]); + }); + + it.each([ + ["---", "---"], + ["a\n---", "a\n---"], + // That line and a trailing newline are the same document; the newline goes. + ["---\n", "---"], + ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => { + const map = roundTrip(value); + expect(map.value).toBe(expected); + const json = buildDocJson(value, () => ({ label: "", description: null })); + const doc = ProseMirrorNode.fromJSON(schema, json); + expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses a quote as a blockquote of one paragraph per line", () => { + const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null })); + expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]); + expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); + }); + + it.each([ + "> a\n> b", + "> **q** @README.md", + "text\n---\nmore", + "---\ntext", + "# Heading text", + "## **b** @README.md", + ])( + "maps every document offset of the block %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a quote or heading marker to the start of the text", () => { + for (const [value, prefix] of [ + ["> quoted", "> "], + ["# Heading", "# "], + ] as const) { + const map = roundTrip(value); + for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(flatToCollapsed(map, 0)).toBe(prefix.length); + } + }); + + it.each([ + "```\ncode\n```", + "```ts\nconst a = 1;\n```", + "```ts\nconst a = 1;\n```\n", + "before\n```ts\nconst a = 1;\n```\nafter", + "```\n```", + "```ts\nline one\nline two\nline three\n```", + "```ts\n indented\n deeper\n```", + "```js title=example\ncode\n```", + "~~~py\ncode\n~~~", + "````\n```\n````", + "```ts\ncode without a closing fence", + "```", + "```ts\n**not bold** and @README.md stay literal\n```", + "```ts\ncode\n````", + "- [ ] task\n```ts\ncode\n```\n- [ ] after", + " ```ts\nindented fence stays a paragraph\n ```", + ])("round-trips the fenced block %s", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])( + "maps every document offset of %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("reads blocks written with CRLF line endings", () => { + const value = "# Title\r\n> quote\r\n---\r\n```ts\r\nconst a = 1;\r\n```\r\nafter"; + const doc = buildDocJson(value, () => ({ label: "", description: null })); + expect(doc.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "codeBlock", + "paragraph", + ]); + expect(roundTrip(value).value).toBe(value); + }); + + it("keeps the end of the code inside the fence rather than after it", () => { + const value = "```ts\nfunc();\n```"; + const map = roundTrip(value); + const endOfCode = "func();".length; + // Not the end of the string: the closing fence is a line of its own. + expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode); + }); + + it("keeps the caret inside an empty fence", () => { + const value = "before\n```\n```"; + const map = roundTrip(value); + const inside = "before\n".length; + expect(flatToCollapsed(map, inside)).toBe("before\n```".length); + expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside); + }); + + it("still places the end of an inline mark after its markers", () => { + // The fence rule must not leak into inline marks, whose trailing edge is + // deliberately the position after the closing delimiter. + const map = roundTrip("a **bold** c"); + expect(flatToMarkdown(map, 6)).toBe(10); + }); + + it("clamps offsets inside a fence to the edge of the code", () => { + const value = "```ts\nab\n```"; + const map = roundTrip(value); + expect(map.value).toBe(value); + // The opening fence owns no document characters, so every offset in it + // lands on the first character of the code. + for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "```ts\na".length)).toBe(1); + // Everything from the closing newline onwards clamps to the code's end. + for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(2); + } + }); + + it.each([ + ["```\n\n```", "```\n```"], + ["```\n", "```"], + ])("canonicalizes the empty fence %s", (value, expected) => { + expect(roundTrip(value).value).toBe(expected); + }); + + it("keeps a chip in a fence info string as source and keeps later chips aligned", () => { + const value = "```@README.md\ncode\n```\n$my-skill after"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const after = json.content[1] as { + content: { type: string; attrs?: { skillName?: string } }[]; + }; + expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]); + expect(after.content[0]?.attrs?.skillName).toBe("my-skill"); + }); + + it("keeps chips in a fence's info string and body each in their own place", () => { + const value = "``` @a.md\nsee @b.md\n```"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const code = json.content[0] as { attrs: { language: string } }; + expect(code.attrs.language).toBe(" @a.md"); + }); + + it.each([ + ["listItem", { marker: "-", space: "" }, "bulletList", "- text"], + ["listItem", { marker: "1.", space: "" }, "orderedList", "1. text"], + ])("gives a bare %s a space once it has text", (item, attrs, list, expected) => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: list, + content: [ + { + type: item, + attrs, + content: [{ type: "paragraph", content: [{ type: "text", text: "text" }] }], + }, + ], + }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe(expected); + expect(roundTrip("-").value).toBe("-"); + }); + + it("writes code holding a closing fence inside a longer fence", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "codeBlock", + attrs: { fence: "```", close: "\n```" }, + content: [{ type: "text", text: "initial\n```\nsecret" }], + }, + ], + }); + const stored = serializeEditorDoc(doc).value; + expect(stored).toBe("````\ninitial\n```\nsecret\n````"); + expect(buildDocJson(stored, () => ({ label: "", description: null })).content).toHaveLength(1); + expect(roundTrip(stored).value).toBe(stored); + }); + + it("closes an unclosed fence that has a block after it", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { type: "codeBlock", attrs: { close: "" }, content: [{ type: "text", text: "code" }] }, + { type: "paragraph", content: [{ type: "text", text: "after" }] }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe("```\ncode\n```\nafter"); + // At the end of the draft it stays unclosed, as it was written. + expect(roundTrip("```\ncode").value).toBe("```\ncode"); + }); + + it("counts a chip's source in a fence as one cursor position, as the draft store does", () => { + const value = "```\nsee @b.md now\n```\nafter"; + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(flatToCollapsed(map, flat)).toBe( + collapseExpandedComposerCursor(value, flatToMarkdown(map, flat)), + ); + } + const afterChip = "see @b.md".length; + expect(collapsedToFlat(map, flatToCollapsed(map, afterChip))).toBe(afterChip); + const end = map.docLength; + expect(collapsedToFlat(map, flatToCollapsed(map, end))).toBe(end); + }); + + it("keeps fences literal in plain mode", () => { + const value = "```ts\nconst a = 1;\n```"; + expect(roundTripPlain(value).value).toBe(value); + }); + it("maps every document offset through collapsed coordinates and back", () => { const value = "hi **bold** @README.md bye"; const map = roundTrip(value); @@ -353,6 +794,37 @@ describe("composer rich text document model", () => { } }); + // Flipping the rich text setting remounts the editor, and the caret is + // restored from the stored collapsed cursor. That only works because the + // coordinate means the same thing on both sides of the flip. + it.each([ + "plain prose with no styling at all", + "a chip @README.md counts one character in both modes", + "$my-skill leads the line", + "trailing newline\n", + ])("resolves a collapsed cursor identically in both modes for %s", (value) => { + const rich = roundTrip(value); + const plain = roundTripPlain(value); + expect(rich.value).toBe(value); + expect(plain.value).toBe(value); + for (let collapsed = 0; collapsed <= value.length; collapsed += 1) { + expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe( + flatToMarkdown(plain, collapsedToFlat(plain, collapsed)), + ); + } + }); + + it("clamps a cursor that was sitting inside a marker onto the styled text", () => { + const value = "a **bold** c"; + const plain = roundTripPlain(value); + const rich = roundTrip(value); + // Between the two asterisks: a real caret position in plain mode, and no + // position at all in rich mode, where it lands on the first styled + // character instead. The flip moves the caret by a marker's width at most. + expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3); + expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4); + }); + it("maps markdown offsets at styled edges onto document text", () => { const value = "a **bold** c"; const map = roundTrip(value); @@ -365,6 +837,63 @@ describe("composer rich text document model", () => { }); }); +describe("serializeSelection", () => { + /** The stored Markdown of the selection from the start of `first` to the end of `last`. */ + function copy(value: string, first: string, last: string) { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + let from = -1; + let to = -1; + doc.descendants((node, pos) => { + if (!node.isText) return true; + const text = node.text ?? ""; + if (from < 0 && text.includes(first)) from = pos + text.indexOf(first); + if (text.includes(last)) to = pos + text.lastIndexOf(last) + last.length; + return false; + }); + return serializeSelection(doc, from, to); + } + + it("keeps the markers of the list items it spans", () => { + expect(copy("- one\n- two\n- three", "one", "two")).toBe("- one\n- two"); + expect(copy("3) one\n4) two", "one", "two")).toBe("3) one\n4) two"); + expect(copy("- [ ] one\n- [x] two", "one", "two")).toBe("- [ ] one\n- [x] two"); + }); + + it("keeps the nesting of an item and its children", () => { + expect(copy("- p\n - a\n - b", "p", "a")).toBe("- p\n - a"); + }); + + it("keeps the prefix of the quote lines it spans", () => { + expect(copy("> one\n> two", "one", "two")).toBe("> one\n> two"); + }); + + it("copies text within one block without its marker", () => { + expect(copy("- one two", "one", "one")).toBe("one"); + }); + + it("copies across blocks as they are written", () => { + expect(copy("intro\n- one", "intro", "one")).toBe("intro\n- one"); + }); +}); + +describe("pasting into a list item or quote", () => { + it("keeps inline marks but builds no blocks", () => { + const content = buildTiptapContent( + "**bold** text\n- item\n> quote", + () => ({ label: "", description: null }), + { styling: true, blocks: false }, + ); + expect(content.map((block) => block.type)).toEqual(["paragraph", "paragraph", "paragraph"]); + const first = content[0] as { content: { text: string; marks?: { type: string }[] }[] }; + expect(first.content[0]).toMatchObject({ text: "bold", marks: [{ type: "bold" }] }); + expect(JSON.stringify(content[1])).toContain("- item"); + expect(JSON.stringify(content[2])).toContain("> quote"); + }); +}); + 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 49a489c82ad4..efdfb145dc1d 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,9 +1,24 @@ -import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; +import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; +import { Blockquote } from "@tiptap/extension-blockquote"; +import { CodeBlock } from "@tiptap/extension-code-block"; +import { Heading } from "@tiptap/extension-heading"; +import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; +import { type Command, type Editor, mergeAttributes } from "@tiptap/core"; +import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; +import { TaskList } from "@tiptap/extension-task-list"; +import { + type EditorState, + Plugin, + Selection, + TextSelection, + type Transaction, +} from "@tiptap/pm/state"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { nextOrderedMarkerText } from "~/composer-list-continuation"; import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; @@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({ exitable: false, }); +/** + * Tiptap's block extensions each bind a chord that turns the current block + * into their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on). + * The composer does not offer them, and they are not harmless: run inside a + * quote they nest a block the quote serializer cannot write, and the text in + * it drops out of the stored draft. `drop` names the chords to remove; the + * keys the composer relies on, such as Backspace and the arrows at a block's + * edge, stay. + */ +function withoutBlockChords>( + shortcuts: Shortcuts | undefined, + drop: readonly string[], +): Shortcuts { + return Object.fromEntries( + Object.entries(shortcuts ?? {}).filter(([key]) => !drop.includes(key)), + ) as Shortcuts; +} + +/** + * What a list item may hold: its own line, then nested lists. That is all the + * Markdown can write, so ProseMirror refuses any join, wrap or paste that + * would put a second line or another block into an item. + */ +const LIST_ITEM_CONTENT = "paragraph list*"; + +/** Task lists come from `- [ ]` alone; Mod-Shift-9 would nest one in a quote. */ +export const ComposerTaskListExtension = TaskList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-9"]); + }, +}); + +/** + * Tiptap's Tab and Shift-Tab sink and lift an item without touching its + * `indent`, so the stored draft keeps the old nesting and the next rebuild + * undoes the move. The composer nests through its own Tab, which edits the + * source the way plain mode does. + */ +const LIST_NESTING_KEYS = ["Tab", "Shift-Tab"]; + /** * Task list items keep their exact source indent in an attribute so nesting * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` โ€” * the same fixed-point deal as `__bold__` becoming `**bold**`. */ export const ComposerTaskItemExtension = TaskItem.extend({ + content: LIST_ITEM_CONTENT, addAttributes() { return { ...this.parent?.(), @@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({ contentSpace: { default: null }, }; }, + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS); + }, }).configure({ nested: true }); +/** + * Fenced code blocks keep their exact source delimiters so a fence round-trips + * byte-identically: `fence` is the opening run of backticks or tildes, + * `language` its info string, and `close` the closing newline and fence, or + * the empty string when the fence was never closed. + * + * Neither delimiter owns a document character, so the caret can never land + * inside a fence โ€” the same deal as a checkbox marker. + * + * An empty block written with a blank line (```` ```\n\n``` ````) canonicalizes + * to the blank-free form, the same fixed-point deal as `__bold__` becoming + * `**bold**`. + */ +export const ComposerCodeBlockExtension = CodeBlock.extend({ + addAttributes() { + return { + ...this.parent?.(), + language: { default: "" }, + fence: { default: "```" }, + close: { default: "\n```" }, + }; + }, + addKeyboardShortcuts() { + return { + ...withoutBlockChords(this.parent?.(), ["Mod-Alt-c"]), + Backspace: () => unwrapCodeBlockAtStart(this.editor), + }; + }, +}); + +/** + * Backspace at the start of a fence removes the fence and keeps its lines as + * paragraphs, the way it removes a list marker. Joining the code onto the line + * above instead would drop the fence from the draft and leave the rest of the + * code as stray lines. + */ +function unwrapCodeBlockAtStart(editor: Editor): boolean { + const { $from, empty } = editor.state.selection; + if (!empty || $from.parent.type.name !== "codeBlock" || $from.parentOffset !== 0) return false; + const { schema } = editor.state; + const paragraphs = $from.parent.textContent + .split("\n") + .map((line) => schema.nodes.paragraph!.create(null, line ? schema.text(line) : null)); + const start = $from.before(); + const tr = editor.state.tr.replaceWith(start, $from.after(), paragraphs); + editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, start + 1))); + return true; +} + +/** + * The markdown a code block node serializes to, delimiters included. `followed` + * says another block comes after it in the draft. + */ +function codeBlockSource( + node: ProseMirrorNode, + followed: boolean, +): { + open: string; + content: string; + close: string; +} { + const attrs = node.attrs as Record; + let fence = typeof attrs.fence === "string" && attrs.fence ? attrs.fence : "```"; + const language = typeof attrs.language === "string" ? attrs.language : ""; + let close = typeof attrs.close === "string" ? attrs.close : ""; + const content = node.textContent; + // Code holding a line that would close the fence, such as a pasted fence, + // is written inside a longer one, so the agent reads all of it as code. A + // parsed block never holds such a line, so its source stays byte-identical. + const longest = Math.max( + 0, + ...content + .split("\n") + .filter((line) => isClosingFence(line, fence)) + .map((line) => /^[`~]+/.exec(line)![0].length), + ); + if (longest > 0) { + fence = fence[0]!.repeat(longest + 1); + if (close) close = `\n${fence}`; + } + // An unclosed fence runs to the end of the draft, so with a block after it + // the fence is written closed, or the next rebuild would read that block as code. + if (!close && followed) close = `\n${fence}`; + return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; +} + +/** + * Bullet and ordered items keep their exact source marker so a list + * round-trips byte-identically: `marker` is the literal `-`, `*`, `+`, `3.` + * or `3)`, `space` what followed it, and `indent` the leading whitespace. + * Numbering is not renumbered: what the user typed is what the agent gets. + */ +const ComposerListItemExtension = ListItem.extend({ + content: LIST_ITEM_CONTENT, + addAttributes() { + return { + ...this.parent?.(), + indent: { default: "" }, + marker: { default: "-" }, + space: { default: " " }, + }; + }, + addKeyboardShortcuts() { + return { + ...withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS), + // Task items share these: the list extensions are always loaded together. + Backspace: () => backspaceAcrossList(this.editor), + Delete: () => deleteAcrossList(this.editor), + }; + }, + // The source marker rides on the item so the composer draws `3)` and a + // nested `7.` as written, rather than the browser own numbering. + renderHTML({ node, HTMLAttributes }) { + return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; + }, +}); + +export const ComposerListExtensions = [ + BulletList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); + }, + }), + OrderedList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-7"]); + }, + }), + ComposerListItemExtension, +]; + +/** + * A quote keeps the exact `>` prefix its lines were written with, applied to + * every line, so it round-trips byte-identically and a new line typed inside + * it gets the same prefix. One source line is one paragraph; a line whose + * prefix differs starts a sibling quote. Nested markers and list markers + * inside a quote stay literal text: the composer quotes prose, not documents. + */ +const ComposerBlockquoteExtension = Blockquote.extend({ + content: "paragraph+", + addAttributes() { + return { ...this.parent?.(), prefix: { default: "> " } }; + }, + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-b"]); + }, +}); + +/** + * A rule keeps the exact line it was written as (`---`, `* * *`, `_____`), so + * it round-trips byte-identically. It owns no document characters: offsets + * inside its source clamp to the block after it. + * + * A rule cannot hold the caret, so a document never ends in one: an empty + * line follows it, which the draft does not write (see `isTrailingLineAfterRule`). + * A draft ending in `---\n` reads back the same way and so loses that newline, + * the same fixed-point deal as `__bold__` becoming `**bold**`. + */ +const ComposerHorizontalRuleExtension = HorizontalRule.extend({ + addAttributes() { + return { ...this.parent?.(), source: { default: "---" } }; + }, + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + appendTransaction: (transactions, _, state) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + if (state.doc.lastChild?.type.name !== "horizontalRule") return null; + return state.tr.insert(state.doc.content.size, state.schema.nodes.paragraph!.create()); + }, + }), + ]; + }, +}); + +/** The empty line after a rule that ends the document, which writes nothing. */ +function isTrailingLineAfterRule(doc: ProseMirrorNode, index: number): boolean { + const block = doc.child(index); + return ( + index === doc.childCount - 1 && + index > 0 && + block.type.name === "paragraph" && + block.content.size === 0 && + doc.child(index - 1).type.name === "horizontalRule" + ); +} + +/** + * A heading keeps the exact whitespace between its `#`s and its text. The + * `#`s must be followed by whitespace to count, which is also what keeps a + * `#1234` pull request reference a reference: the marker owns no document + * characters, closing `#`s stay literal text, and nothing is ever stripped. + */ +const ComposerHeadingExtension = Heading.extend({ + addAttributes() { + return { ...this.parent?.(), space: { default: " " } }; + }, + // Its only shortcuts are the Mod-Alt-1โ€ฆ6 block chords. + addKeyboardShortcuts() { + return {}; + }, +}); + +/** Block-level nodes beyond lists and fences, in the order the parser tries them. */ +export const ComposerBlockExtensions = [ + ComposerBlockquoteExtension, + ComposerHorizontalRuleExtension, + ComposerHeadingExtension, +]; + function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; } @@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: }; } +/** + * An opening fence: three or more backticks or tildes at the start of a line, + * followed by an info string. A backtick fence cannot carry a backtick in its + * info string, which is what keeps `` `code` `` on its own line literal. + * Indented fences stay paragraphs โ€” the composer is a prompt box, not a + * CommonMark renderer, and honoring indentation would cost another attribute + * for no case anyone writes. + */ +export function parseOpeningFence(line: string): { fence: string; language: string } | null { + // `[^\n]` rather than `.`, here and in the block rules: a CRLF draft's lines + // end in `\r`, which `.` refuses, and the `\r` stays in the stored text. + const match = /^(`{3,}|~{3,})([^\n]*)$/.exec(line); + if (!match) return null; + const fence = match[1]!; + const language = match[2]!; + if (fence.startsWith("`") && language.includes("`")) return null; + return { fence, language }; +} + +/** A closing fence matches the opening run's character and is at least as long. */ +export function isClosingFence(line: string, fence: string): boolean { + const match = /^(`{3,}|~{3,})[ \t]*\r?$/.exec(line); + if (!match) return false; + const run = match[1]!; + return run[0] === fence[0] && run.length >= fence.length; +} + +type ListLinePrefix = + | ({ kind: "task" } & TaskLinePrefix) + | { kind: "bullet" | "ordered"; indent: string; marker: string; space: string }; + +/** + * The same grammar the literal continuation uses, so plain and rich mode agree + * on what a list line is: a task first (it also looks like a bullet), then an + * ordered marker, then a bullet. A marker followed by nothing is an empty item. + */ +function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: number } | null { + const task = parseTaskPrefix(line); + if (task) return { prefix: { kind: "task", ...task.prefix }, markerLength: task.markerLength }; + const ordered = /^([ \t]*)(\d+[.)])((?:[ \t]+)|$)/.exec(line); + if (ordered) { + return { + prefix: { kind: "ordered", indent: ordered[1]!, marker: ordered[2]!, space: ordered[3]! }, + markerLength: ordered[0].length, + }; + } + const bullet = /^([ \t]*)([-*+])((?:[ \t]+)|$)/.exec(line); + if (bullet) { + return { + prefix: { kind: "bullet", indent: bullet[1]!, marker: bullet[2]!, space: bullet[3]! }, + markerLength: bullet[0].length, + }; + } + return null; +} + +/** Items of one kind and marker family belong to one list; a change starts a sibling list. */ +function listKey(prefix: ListLinePrefix): string { + if (prefix.kind === "task") return "task"; + if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; + return `bullet:${prefix.marker}`; +} + type InlineJson = Record; interface DocLine { - task: TaskLinePrefix | null; + list: ListLinePrefix | null; inline: InlineJson[]; } @@ -143,28 +476,52 @@ function atomJsonForSegment( }; } -interface PendingTaskItem extends TaskLinePrefix { +interface PendingItem { + prefix: ListLinePrefix; content: InlineJson[]; - children: PendingTaskItem[]; + /** Nested lists, in order; a parent can hold lists of different kinds. */ + children: PendingList[]; } -function taskListJson(items: PendingTaskItem[]): InlineJson { - return { - type: "taskList", - content: items.map((item) => ({ - type: "taskItem", - attrs: { - checked: item.checked, - indent: item.indent, - markerSpace: item.markerSpace, - contentSpace: item.contentSpace, - }, - content: [ - { type: "paragraph", content: item.content }, - ...(item.children.length > 0 ? [taskListJson(item.children)] : []), - ], - })), - }; +interface PendingList { + key: string; + items: PendingItem[]; +} + +function listJson(list: PendingList): InlineJson { + const first = list.items[0]!.prefix; + const items = list.items.map((item) => { + const content = [ + { type: "paragraph", content: item.content }, + ...item.children.map((child) => listJson(child)), + ]; + if (item.prefix.kind === "task") { + return { + type: "taskItem", + attrs: { + checked: item.prefix.checked, + indent: item.prefix.indent, + markerSpace: item.prefix.markerSpace, + contentSpace: item.prefix.contentSpace, + }, + content, + }; + } + return { + type: "listItem", + attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space }, + content, + }; + }); + if (first.kind === "task") return { type: "taskList", content: items }; + if (first.kind === "ordered") { + return { + type: "orderedList", + attrs: { start: Number.parseInt(first.marker, 10) || 1 }, + content: items, + }; + } + return { type: "bulletList", content: items }; } function textJsonForSpan(text: string, marks: RichTextMark[]): Record { @@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean }, + options?: { styling?: boolean; blocks?: boolean }, ): Record[] { const styling = options?.styling ?? true; + // Inline marks without block structure: text pasted into a list item or + // quote, which has no line to write a nested block into. + const blockSyntax = styling && (options?.blocks ?? true); // Hide token source from the markdown parser, then restore the atoms with // the marks of their surrounding text. Choose a sentinel absent from input. let sentinel = "\uFFFC"; @@ -190,17 +550,19 @@ export function buildTiptapContent( sentinel = String.fromCodePoint(codePoint); } const atoms: InlineJson[] = []; + // 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) .map((segment) => { if (segment.type === "text") return segment.text; atoms.push(atomJsonForSegment(segment, skillLabelFor)); + atomSources.push(segment.source); return sentinel; }) .join(""); let atomIndex = 0; - const lines: DocLine[] = text.split("\n").map((line) => { - const parsed = styling ? parseTaskPrefix(line) : null; - const content = parsed ? line.slice(parsed.markerLength) : line; + const buildInline = (content: string): InlineJson[] => { const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; const inline: InlineJson[] = []; for (const span of spans) { @@ -212,61 +574,185 @@ export function buildTiptapContent( if (piece) inline.push(textJsonForSpan(piece, span.marks)); }); } - return { task: parsed?.prefix ?? null, inline }; - }); + return inline; + }; + const buildDocLine = (line: string): DocLine => { + const parsed = blockSyntax ? parseListPrefix(line) : null; + const content = parsed ? line.slice(parsed.markerLength) : line; + return { list: parsed?.prefix ?? null, inline: buildInline(content) }; + }; - // Pass 2: consecutive task lines group into (possibly nested) task lists - // by indent prefix; everything else stays a paragraph. - const blocks: Record[] = []; - let stack: { indent: string; items: PendingTaskItem[] }[] = []; - const flushTasks = () => { - if (stack.length > 0) { - blocks.push(taskListJson(stack[0]!.items)); - stack = []; + // Pass 1: fenced blocks claim their lines whole; everything else becomes an + // inline-parsed line. Fence bodies restore chip sources as literal text. + const sourceLines = text.split("\n"); + const entries: ( + | { code: Record } + | { rule: string } + | { heading: { level: number; space: string; inline: InlineJson[] } } + | { quote: { prefix: string; inline: InlineJson[] } } + | { line: DocLine } + )[] = []; + const restoreSources = (line: string) => + line.split(sentinel).reduce((joined, piece, index) => { + if (index === 0) return piece; + atomIndex += 1; + return joined + atomSources[atomIndex - 1]! + piece; + }, ""); + + for (let index = 0; index < sourceLines.length; index += 1) { + const line = sourceLines[index]!; + const opening = blockSyntax ? parseOpeningFence(line) : null; + if (!opening) { + // A thematic break outranks a list: `- - -` and `* * *` are rules, and + // `***` on its own line is a rule rather than an empty bold span. + if (blockSyntax && /^([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/.test(line)) { + entries.push({ rule: line }); + continue; + } + const heading = blockSyntax ? /^(#{1,6})([ \t]+)([^\n]*)$/.exec(line) : null; + if (heading) { + entries.push({ + heading: { + level: heading[1]!.length, + space: heading[2]!, + inline: buildInline(heading[3]!), + }, + }); + continue; + } + const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null; + if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); + else entries.push({ line: buildDocLine(line) }); + continue; + } + // The info string went through the sentinel pass like every line, so a + // token in it is put back as source here, in order, before the body. + const language = restoreSources(opening.language); + const body: string[] = []; + let cursor = index + 1; + let close = ""; + while (cursor < sourceLines.length) { + const candidate = sourceLines[cursor]!; + if (isClosingFence(candidate, opening.fence)) { + close = `\n${candidate}`; + break; + } + body.push(restoreSources(candidate)); + cursor += 1; } + // An unclosed fence runs to the end of the prompt, which is what the user + // is looking at while they are still typing the block. + const closed = cursor < sourceLines.length; + const content = body.join("\n"); + entries.push({ + code: { + type: "codeBlock", + attrs: { language, fence: opening.fence, close }, + ...(content ? { content: [{ type: "text", text: content }] } : {}), + }, + }); + index = closed ? cursor : sourceLines.length; + } + + // Pass 2: consecutive list lines group into (possibly nested) lists by + // indent prefix; everything else stays a paragraph. Items of a different + // kind or marker at the same indent start a sibling list, so `* a` under + // `- b` keeps its star and a task list can follow a bullet list. + const blocks: Record[] = []; + let rootLists: PendingList[] = []; + let stack: { indent: string; list: PendingList; container: PendingList[] }[] = []; + const flushLists = () => { + for (const list of rootLists) blocks.push(listJson(list)); + rootLists = []; + stack = []; + }; + const openList = (container: PendingList[], key: string): PendingList => { + const list = { key, items: [] }; + container.push(list); + return list; + }; + let openQuote: { prefix: string; content: InlineJson[][] } | null = null; + const flushQuote = () => { + if (!openQuote) return; + blocks.push({ + type: "blockquote", + attrs: { prefix: openQuote.prefix }, + content: openQuote.content.map((inline) => ({ type: "paragraph", content: inline })), + }); + openQuote = null; }; - for (const line of lines) { - if (!line.task) { - flushTasks(); + for (const entry of entries) { + if ("quote" in entry) { + flushLists(); + if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); + openQuote ??= { prefix: entry.quote.prefix, content: [] }; + openQuote.content.push(entry.quote.inline); + continue; + } + flushQuote(); + if ("code" in entry) { + flushLists(); + blocks.push(entry.code); + continue; + } + if ("rule" in entry) { + flushLists(); + blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } }); + continue; + } + if ("heading" in entry) { + flushLists(); + blocks.push({ + type: "heading", + attrs: { level: entry.heading.level, space: entry.heading.space }, + content: entry.heading.inline, + }); + continue; + } + const line = entry.line; + if (!line.list) { + flushLists(); blocks.push({ type: "paragraph", content: line.inline }); continue; } - const item: PendingTaskItem = { - ...line.task, - content: line.inline, - children: [], - }; + const item: PendingItem = { prefix: line.list, content: line.inline, children: [] }; + const key = listKey(line.list); + const indent = line.list.indent; for (;;) { const top = stack[stack.length - 1]; if (!top) { // A leading indented item with no parent flattens but keeps indent. - stack.push({ indent: item.indent, items: [] }); + stack.push({ indent, list: openList(rootLists, key), container: rootLists }); continue; } - if (top.indent === item.indent) { - top.items.push(item); + if (top.indent === indent) { + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - if (top.indent !== "" && !item.indent.startsWith(top.indent)) { + if (top.indent !== "" && !indent.startsWith(top.indent)) { if (stack.length > 1) { stack.pop(); continue; } - top.indent = item.indent; - top.items.push(item); + top.indent = indent; + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - const parent = top.items[top.items.length - 1]; + const parent = top.list.items[top.list.items.length - 1]; if (!parent) { - top.items.push(item); + top.list.items.push(item); break; } - parent.children.push(item); - stack.push({ indent: item.indent, items: parent.children }); + const list = openList(parent.children, key); + stack.push({ indent, list, container: parent.children }); + list.items.push(item); break; } } - flushTasks(); + flushQuote(); + flushLists(); return blocks; } @@ -275,7 +761,10 @@ export function buildDocJson( skillLabelFor: (name: string) => SkillMeta, options?: { styling?: boolean }, ) { - return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) }; + const content = buildTiptapContent(value, skillLabelFor, options); + // The empty line the rule extension keeps after a final rule. + if (content.at(-1)?.type === "horizontalRule") content.push({ type: "paragraph" }); + return { type: "doc", content }; } export interface RichRun { @@ -495,7 +984,210 @@ function appendInlineRuns( } } -function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { +const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]); +const LIST_ITEM_NODE_NAMES = new Set(["taskItem", "listItem"]); + +/** + * Gives the list item at the caret the indent and marker of the sibling it + * follows, or of the one after it when it is first. Tiptap moves items between + * lists without touching these attributes, so without this the stored draft + * keeps an outdented item at its old depth, and the next rebuild nests it again. + */ +function alignListItemWithSiblings(tr: Transaction, itemType: string): void { + const $pos = tr.selection.$from; + // A lift out of the list leaves the caret in a paragraph, possibly inside + // an item of another kind, which did not move. + if ($pos.depth < 3 || $pos.node(-1).type.name !== itemType) return; + const item = $pos.node(-1); + const list = $pos.node(-2); + const index = $pos.index(-2); + const sibling = + index > 0 ? list.child(index - 1) : index + 1 < list.childCount ? list.child(index + 1) : null; + if (!sibling) return; + const attrs: Record = { ...item.attrs, indent: sibling.attrs.indent }; + if (item.type.name === "taskItem") { + attrs.markerSpace = sibling.attrs.markerSpace; + } else { + const marker = typeof sibling.attrs.marker === "string" ? sibling.attrs.marker : "-"; + attrs.marker = index > 0 && /^\d+[.)]$/.test(marker) ? nextOrderedMarkerText(marker) : marker; + } + tr.setNodeMarkup($pos.before(-1), undefined, attrs); + if (typeof attrs.marker === "string" && /^\d+[.)]$/.test(attrs.marker)) { + renumberFollowingItems(tr, $pos.before(-2), index, attrs.marker); + } +} + +/** + * Counts the items after `index` on from `marker`, so an item inserted in the + * middle of an ordered list pushes the rest down instead of repeating a number. + * Only marker attributes change, so positions stay valid while walking. + */ +function renumberFollowingItems( + tr: Transaction, + listPos: number, + index: number, + marker: string, +): void { + const list = tr.doc.nodeAt(listPos); + if (!list) return; + let pos = listPos + 1; + let previous = marker; + list.forEach((child, _, childIndex) => { + if (childIndex > index) { + previous = nextOrderedMarkerText(previous); + if (child.attrs.marker !== previous) { + tr.setNodeMarkup(pos, undefined, { ...child.attrs, marker: previous }); + } + } + pos += child.nodeSize; + }); +} + +/** + * Shift+Enter in a list or task item: split it, or on an empty item leave one + * level of nesting. Either way the item at the caret ends up written with the + * indent and marker of its new siblings. + */ +export function splitOrLiftListItem(editor: Editor): boolean { + const $from = editor.state.selection.$from; + const itemType = listItemTypeAt($from); + if (!itemType) return false; + const empty = $from.parent.content.size === 0; + // One transaction, so the split or lift and the realignment undo together. + const move: Command = ({ commands, tr }) => { + const moved = + commands.splitListItem( + itemType, + itemType === "taskItem" ? { checked: false } : { space: " " }, + ) || + (empty && commands.liftListItem(itemType)); + if (moved) alignListItemWithSiblings(tr, itemType); + return moved; + }; + return editor.can().command(move) && editor.chain().command(move).run(); +} + +/** + * Deletes `from`โ€“`to`, the typed `[ ]` at the start of a bullet item's text, + * and makes that item a task where it stands. A task list holds only tasks, so + * the bullet list splits around it into sibling lists, which is also how the + * parser reads a change of list kind. Nested items and the item's own nested + * lists stay where they are. + */ +export function convertBulletItemToTask( + tr: Transaction, + from: number, + to: number, + checked: boolean, +): void { + tr.delete(from, to); + const $pos = tr.doc.resolve(from); + const item = $pos.node(-1); + const list = $pos.node(-2); + const index = $pos.index(-2); + const { schema } = tr.doc.type; + const before: ProseMirrorNode[] = []; + const after: ProseMirrorNode[] = []; + list.forEach((child, _, childIndex) => { + if (childIndex < index) before.push(child); + else if (childIndex > index) after.push(child); + }); + // The spacing after the bullet stays between the dash and the box, as typed. + const markerSpace = + typeof item.attrs.space === "string" && item.attrs.space ? item.attrs.space : " "; + const task = schema.nodes.taskItem!.create( + { checked, indent: item.attrs.indent, markerSpace }, + item.content, + ); + const lists = [ + ...(before.length > 0 ? [list.copy(Fragment.from(before))] : []), + schema.nodes.taskList!.create(null, task), + ...(after.length > 0 ? [list.copy(Fragment.from(after))] : []), + ]; + const listStart = $pos.before(-2); + tr.replaceWith(listStart, listStart + list.nodeSize, lists); + // Into the task list, its item and its paragraph. + const caret = listStart + (before.length > 0 ? lists[0]!.nodeSize : 0) + 3; + tr.setSelection(TextSelection.create(tr.doc, caret)); +} + +function listItemTypeAt($pos: ResolvedPos): "listItem" | "taskItem" | null { + const name = $pos.depth > 1 ? $pos.node(-1).type.name : null; + return name === "listItem" || name === "taskItem" ? name : null; +} + +/** + * Backspace at the start of an item's text leaves one level of nesting, or + * the list itself at the top, written at its new depth. At the start of a + * line right after a list it joins that line onto the list's last line. + * Tiptap's list keymap, which StarterKit loads, does both by moving nodes + * without touching their indent or marker, so the composer turns it off. + */ +export function backspaceAcrossList(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + const itemType = listItemTypeAt($from); + if (itemType && $from.index(-1) === 0) { + const lift: Command = ({ commands, tr }) => { + const moved = commands.liftListItem(itemType); + if (moved) alignListItemWithSiblings(tr, itemType); + return moved; + }; + // Not `can()` first: a dry run of the lift skips the schema check, so it + // says yes to a task under a bullet, which has no list to lift into. Its + // text joins the line above instead. + return editor.chain().command(lift).run() || joinBackward(state, editor.view.dispatch); + } + const before = $from.depth === 1 ? $from.node(0).maybeChild($from.index(0) - 1) : null; + if (before && LIST_NODE_NAMES.has(before.type.name) && $from.parent.type.name !== "codeBlock") { + return joinTextblockBackward(state, editor.view.dispatch); + } + return false; +} + +/** + * Delete at the end of a line next to list structure pulls the next line's + * text up onto it. Tiptap's default would instead nest the next item, or a + * whole list, under this one at its old indent. Before a fence, anywhere, it + * does nothing: pulling the code into the line would drop the fence from the + * draft and leave the rest of the code as stray lines. + */ +export function deleteAcrossList(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || !$from.parent.isTextblock || $from.depth === 0) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const next = Selection.findFrom(state.doc.resolve($from.after()), 1, true); + if (!next) return false; + if (next.$from.parent.type.name === "codeBlock") return $from.parent.type.name !== "codeBlock"; + if (!listItemTypeAt($from) && !listItemTypeAt(next.$from)) return false; + return joinTextblockForward(state, editor.view.dispatch); +} + +/** The literal prefix an item serializes to. Empty items keep their exact spacing. */ +function listItemPrefix(item: ProseMirrorNode, empty: boolean): string { + const attrs = item.attrs as Record; + const indent = typeof attrs.indent === "string" ? attrs.indent : ""; + if (item.type.name === "taskItem") { + const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; + const contentSpace = + typeof attrs.contentSpace === "string" + ? attrs.contentSpace || (empty ? "" : " ") + : empty + ? "" + : " "; + return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; + } + const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-"; + // A bare `-` keeps its missing space only while the item is empty: once it + // has text, `-text` would not be a list line any more. + const space = + typeof attrs.space === "string" ? attrs.space || (empty ? "" : " ") : empty ? "" : " "; + return `${indent}${marker}${space}`; +} + +function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { let itemPos = listStart + 1; let firstItem = true; list.content.forEach((item) => { @@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul const itemContentStart = itemPos + 1; const first = item.firstChild; const empty = first?.type.name === "paragraph" && first.content.childCount === 0; - const attrs = item.attrs as Record; - const indent = typeof attrs.indent === "string" ? attrs.indent : ""; - const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; - const contentSpace = - typeof attrs.contentSpace === "string" - ? attrs.contentSpace || (empty ? "" : " ") - : empty - ? "" - : " "; - const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; - // The checkbox owns no document characters; every prefix offset clamps + const prefix = listItemPrefix(item, empty); + // The marker owns no document characters; every prefix offset clamps // to the start of the item text, exactly like style markers. acc.runs.push({ kind: "prefix", @@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul item.content.forEach((child) => { if (!firstBlock) pushBreakRun(acc); firstBlock = false; - if (child.type.name === "taskList") { - walkTaskList(child, childPos, acc); + if (LIST_NODE_NAMES.has(child.type.name)) { + walkList(child, childPos, acc); } else if (child.type.name === "paragraph") { appendInlineRuns(child, childPos + 1, acc); } @@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul }); } +/** + * A fence becomes one run whose open and close lengths are the delimiters, so + * every cursor rule that already clamps out of a style marker clamps out of a + * fence too. `nodeName` keeps the marker decorations off it: a code block is + * drawn as a block, not revealed a character at a time. + * + * The code is literal text in the editor, but the draft store still counts a + * chip's source in it as one cursor position, as it does everywhere. So the + * run splits at each chip source, which spans its characters in the document + * and one position in the collapsed space. + */ +function appendCodeBlockRun( + block: ProseMirrorNode, + pmPos: number, + followed: boolean, + acc: RichAccumulator, +): void { + const { open, content, close } = codeBlockSource(block, followed); + const pieces = content + ? splitPromptIntoComposerSegments(content).map((segment) => + segment.type === "text" + ? { length: segment.text.length, collapsedLen: segment.text.length } + : { length: segment.source.length, collapsedLen: 1 }, + ) + : [{ length: 0, collapsedLen: 0 }]; + let offset = 0; + pieces.forEach((piece, index) => { + const openLen = index === 0 ? open.length : 0; + const closeLen = index === pieces.length - 1 ? close.length : 0; + const collapsedLen = openLen + piece.collapsedLen + closeLen; + const mdLen = openLen + piece.length + closeLen; + acc.runs.push({ + kind: "text", + flatStart: acc.flat, + docLen: piece.length, + collapsedLen, + mdLen, + openLen, + closeLen, + pmPos: pmPos + offset, + mdStart: acc.md, + collapsedStart: acc.collapsed, + nodeName: "codeBlock", + }); + offset += piece.length; + acc.flat += piece.length; + acc.collapsed += collapsedLen; + acc.md += mdLen; + }); + acc.value += open + content + close; +} + +/** Each paragraph of a quote is one source line behind the quote's prefix. */ +function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAccumulator): void { + const attrs = quote.attrs as Record; + const prefix = typeof attrs.prefix === "string" ? attrs.prefix : "> "; + let childPos = quoteStart + 1; + let firstLine = true; + quote.content.forEach((child) => { + if (!firstLine) pushBreakRun(acc); + firstLine = false; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: childPos + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc); + childPos += child.nodeSize; + }); +} + +/** + * The stored Markdown of a selection, as copy and cut put it on the clipboard. + * A slice holds only the content of the blocks the ends share, so items or + * quote lines arrive without the list or quote that writes their markers. The + * shared block is put back around them, and an item's list around it. + */ +export function serializeSelection(doc: ProseMirrorNode, from: number, to: number): string { + const slice = doc.slice(from, to); + const $from = doc.resolve(from); + let depth = $from.sharedDepth(to); + let content: Fragment | ProseMirrorNode = slice.content; + if (slice.content.firstChild?.isInline) { + content = doc.type.schema.nodes.paragraph!.create(null, slice.content); + } else { + while (depth > 0) { + const shared = $from.node(depth); + content = shared.copy(Fragment.from(content)); + if (!LIST_ITEM_NODE_NAMES.has(shared.type.name)) break; + depth -= 1; + } + } + return serializeEditorDoc(doc.type.create(null, content)).value; +} + export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; @@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { - if (blockIndex > 0) pushBreakRun(acc); - if (block.type.name === "taskList") { - walkTaskList(block, pmBlockStart, acc); + if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); + if (LIST_NODE_NAMES.has(block.type.name)) { + walkList(block, pmBlockStart, acc); + } else if (block.type.name === "codeBlock") { + appendCodeBlockRun(block, pmBlockStart + 1, blockIndex < blocks.length - 1, acc); + } else if (block.type.name === "blockquote") { + walkBlockquote(block, pmBlockStart, acc); + } else if (block.type.name === "heading") { + const attrs = block.attrs as Record; + const level = typeof attrs.level === "number" ? attrs.level : 1; + const space = typeof attrs.space === "string" ? attrs.space : " "; + const prefix = `${"#".repeat(level)}${space}`; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + appendInlineRuns(block, pmBlockStart + 1, acc); + } else if (block.type.name === "horizontalRule") { + const attrs = block.attrs as Record; + const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: source.length, + mdLen: source.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + block.nodeSize, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += source; + acc.collapsed += source.length; + acc.md += source.length; } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); } @@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { : last.mdStart + last.mdLen; } +/** + * The end of a run belongs to the next run, which is how the end of `**bold**` + * lands after its markers. A fence is the exception: its close is a line of + * its own, so the end of the code stays inside the block rather than jumping + * past the closing fence. Inline marks keep their trailing position. + */ +function runOwnsOffset(run: RichRun, flatOffset: number): boolean { + const end = run.flatStart + run.docLen; + return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); +} + export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { - return run.collapsedStart + run.openLen + (bounded - run.flatStart); + // A chip's source in a fence spans its characters but one position. + const within = Math.min( + bounded - run.flatStart, + run.collapsedLen - run.openLen - run.closeLen, + ); + return run.collapsedStart + run.openLen + within; } return run.collapsedStart + (bounded - run.flatStart); } @@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { return run.mdStart + run.openLen + (bounded - run.flatStart); } @@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe if (run.kind === "prefix") return run.flatStart; if (run.kind === "text" || run.kind === "token") { const within = collapsedOffset - run.collapsedStart; + const contentLen = run.collapsedLen - run.openLen - run.closeLen; // Marker characters clamp to the styled edge: they are shown, never edited. if (within <= run.openLen) return run.flatStart; - if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen; + if (within >= run.openLen + contentLen) return run.flatStart + run.docLen; return run.flatStart + (within - run.openLen); } return run.flatStart + (collapsedOffset - run.collapsedStart); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 46b57325a3ff..d43fc36769a1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1422,7 +1422,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { /* Chat code blocks join the other code surfaces on the code tokens. The default look keeps its secondary/input tints; themed palettes tint those surfaces with the accent, which fights syntax highlighting. */ -html[data-theme-id] .chat-markdown .chat-markdown-codeblock { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock { background-color: var(--code-background); border-color: var(--contrast-border); color: var(--code-foreground); @@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre { color: var(--code-foreground); } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { background-color: transparent; } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--code-foreground) 72%, transparent); } @@ -1685,7 +1685,7 @@ code { Inline code stays relative to its sentence so it never towers over prose; diffs and file previews take the size through --diffs-font-size. */ .chat-markdown .chat-markdown-shiki .shiki, -.chat-markdown pre code { +:is(.chat-markdown, .composer-tiptap) pre code { font-size: var(--font-size-code, inherit); } @@ -1755,45 +1755,47 @@ code { .chat-markdown ol, .chat-markdown blockquote, .chat-markdown pre, -.chat-markdown .chat-markdown-table-container { +.chat-markdown .chat-markdown-table-container, +.composer-tiptap ul, +.composer-tiptap ol { margin: 0.65rem 0; } -.chat-markdown h1, -.chat-markdown h2, -.chat-markdown h3, -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h1, +:is(.chat-markdown, .composer-tiptap) h2, +:is(.chat-markdown, .composer-tiptap) h3, +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { margin: 1.25rem 0 0.5rem; font-weight: 600; line-height: 1.3; color: var(--contrast-foreground); } -.chat-markdown h1 { +:is(.chat-markdown, .composer-tiptap) h1 { font-size: 1.25rem; } -.chat-markdown h2 { +:is(.chat-markdown, .composer-tiptap) h2 { font-size: 1.125rem; } -.chat-markdown h3 { +:is(.chat-markdown, .composer-tiptap) h3 { font-size: 1rem; } -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { font-size: 0.875rem; } -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h6 { color: var(--contrast-muted-foreground); } -.chat-markdown ul { +:is(.chat-markdown, .composer-tiptap) ul { /* Reset for nested uls under a widened ol. --list-gutter is an inherited custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ @@ -1807,21 +1809,27 @@ code { widest marker has multiple characters so it isn't clipped by list-style-position: outside painting the marker past the padding box. Reset it here too so a nested ol without its own widened marker doesn't inherit the outer one. */ -.chat-markdown ol { +:is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } -.chat-markdown ol > li::marker { +:is(.chat-markdown, .composer-tiptap) ol > li::marker { font-variant-numeric: tabular-nums; } -.chat-markdown ul ul { +/* The composer shows the marker the source has, `3)` or a nested `7.`, not the + browser counter. Browsers without ::marker content fall back to `start`. */ +.composer-tiptap ol > li[data-marker]::marker { + content: attr(data-marker) " "; +} + +:is(.chat-markdown, .composer-tiptap) ul ul { list-style-type: circle; } -.chat-markdown ul ul ul { +:is(.chat-markdown, .composer-tiptap) ul ul ul { list-style-type: square; } @@ -1870,12 +1878,30 @@ code { background-size: 4px 2px; } -.chat-markdown blockquote { +:is(.chat-markdown, .composer-tiptap) blockquote { border-left: 2px solid var(--contrast-border); padding-left: 0.8rem; color: var(--contrast-muted-foreground); } +/* A quote in the composer is text the user is still writing; muting it reads + as disabled in an input, so the border alone carries the quote. */ +.composer-tiptap blockquote { + margin: 0.65rem 0; + color: var(--foreground); +} + +/* Preflight already draws the 1px rule, as it does in chat; only the block + spacing needs to match. */ +.composer-tiptap hr { + margin: 0.65rem 0; +} + +/* A heading at the top of a draft should not push the first line down. */ +.composer-tiptap :is(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); @@ -1939,23 +1965,23 @@ code { background: color-mix(in srgb, var(--contrast-border) 78%, transparent); } -.chat-markdown .chat-markdown-codeblock-header { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); } -.chat-markdown .chat-markdown-codeblock pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { margin: 0; border: none; border-radius: 0; background: transparent; } -.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre { white-space: pre-wrap; overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-shiki .shiki { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki { background: transparent !important; } @@ -2181,7 +2207,7 @@ code { outline: none; } -.composer-tiptap code { +.composer-tiptap :not(pre) > code { font-family: var(--font-mono); font-size: 0.92em; background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 6ed0a8c2f7bf..650564f751b6 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -25,3 +25,18 @@ Clipboard text must come from the Markdown serializer, not DOM text: chip labels the source and marker decorations are not content. Structured context records accompany that text when available. Paste completes trailing chip delimiters and adds a leading boundary when inserting a chip directly after text. + +Block nodes (fences, lists, quotes, rules, headings) keep their exact Markdown +delimiters as node attributes, so the round trip is byte-identical and offsets +inside a delimiter clamp to the block's text like task and style markers. The +serializers write only what the grammar has: a quote holds paragraphs, a list +holds paragraphs and lists. So blocks may only form at a top-level paragraph. +Input rules check this, and Tiptap's own block chords (Mod-Shift-8, Mod-Alt-c, +and so on) are removed, because nesting a block the serializer cannot write +drops its text from the stored draft. The list grammar is the plain-mode +continuation's in [composer-list-continuation](../../apps/web/src/composer-list-continuation.ts); +keep the two in step. + +Enter sends, so the fence handling in `handleKeyDown` must run before the send +handler: checked afterwards, a fence never opens and a newline inside one sends +the draft. diff --git a/docs/user/composer.md b/docs/user/composer.md index f4c2829019a7..ba8d26fd81c2 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,23 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +## Formatting + +The composer writes Markdown and shows it styled as you type. Markers such as +`**` stay in the text and show beside the styled words when your cursor is on +them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists, +task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code +block. What you typed is what the agent receives, markers and numbering +included, and `#1234` without a space still looks up a pull request. + +Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter +on an empty line leaves it; **Tab** nests a list item. In a code block, Enter +starts a new line at the current indentation, **Tab** and **Shift+Tab** indent +the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines +at the end, leave the block. **Backspace** at the start of a code block turns it +back into plain lines. Choose the language in a code block's corner to change +it. Very large code blocks are shown without syntax highlighting. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe1e71770eb8..445556401867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,9 +687,24 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-heading': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) From 46dd0949053ae387e785c5e9801725a9ccc28de1 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:50 +0100 Subject: [PATCH 031/530] fix(web): center project monograms in settled rows (#16841) --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43d1f8dacffb..779f662e0b8d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1821,7 +1821,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { hover so the tail stays scannable when you're hunting. */} Date: Wed, 7 Oct 2026 23:58:53 +0100 Subject: [PATCH 032/530] fix(web): cancelling a new citation no longer leaves a stray space (#16828) --- .../components/ComposerPromptEditorTiptap.tsx | 30 +++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 15 ++++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 6e830991ab7e..ae2c6e460f26 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -183,12 +183,16 @@ export type ComposerCitationCommentRequest = { value: string; citationStart: number; sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: CitationInsertedSpaces; }; +// Spaces added around a freshly inserted citation, removed with it if its comment is cancelled. +type CitationInsertedSpaces = { before: boolean; after: boolean }; + type OpenCitationComment = { key: string; sourceAnchor?: AssistantCitationSourceAnchor; - removeOnCancel?: boolean; + removeOnCancel?: CitationInsertedSpaces; }; const ComposerCitationCommentContext = createContext<{ @@ -392,18 +396,20 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { [editor, nodePos], ); + // Undo a fresh insertion: drop the chip and the spaces inserted with it. + const removeOnCancel = commentTarget?.removeOnCancel; const onRemove = useCallback(() => { - if (!editor.isEditable) return; + if (!editor.isEditable || !removeOnCancel) return; const pos = nodePos(); if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); + const { doc } = editor.state; + const current = doc.nodeAt(pos); if (!current) return; - editor - .chain() - .focus() - .deleteRange({ from: pos, to: pos + current.nodeSize }) - .run(); - }, [editor, nodePos]); + const end = pos + current.nodeSize; + const from = removeOnCancel.before && doc.textBetween(pos - 1, pos) === " " ? pos - 1 : pos; + const to = removeOnCancel.after && doc.textBetween(end, end + 1) === " " ? end + 1 : end; + editor.chain().focus().deleteRange({ from, to }).run(); + }, [editor, nodePos, removeOnCancel]); // Put the caret right after the chip so Enter sends and typing continues the prompt. const onRestoreFocus = useCallback(() => { @@ -450,7 +456,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { if (open && !editor.isEditable) return; commentContext.onOpenChange(citeKey, open); }, - ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + ...(removeOnCancel ? { onCancel: onRemove } : {}), onSave: onSaveComment, onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; @@ -1531,7 +1537,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { setOpenCitation({ key: citeKey, sourceAnchor: pendingCitation.sourceAnchor, - removeOnCancel: true, + removeOnCancel: pendingCitation.insertedSpaces, }); } } @@ -1618,7 +1624,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { setOpenCitation({ key: citeKey, sourceAnchor: request.sourceAnchor, - removeOnCancel: true, + removeOnCancel: request.insertedSpaces, }); } }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4923e1fc47e7..c7eb2628e1c9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -200,7 +200,11 @@ import { shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; -import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; +import { + type ComposerCitationCommentRequest, + type ComposerPromptEditorHandle, + ComposerPromptEditor, +} from "../ComposerPromptEditor"; import { ComposerContextActionsContext, composerContextRecordsFromDraft, @@ -3832,7 +3836,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expectedText?: string; expandedCursorAfterReplace?: number; focusEditorAfterReplace?: boolean; - citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; + citationComment?: { + start: number; + sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + }; }, ): boolean => { if ( @@ -3862,6 +3870,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) value: next.text, citationStart: options.citationComment.start, sourceAnchor: options.citationComment.sourceAnchor, + insertedSpaces: options.citationComment.insertedSpaces, }); } promptRef.current = next.text; @@ -6122,6 +6131,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) citationComment: { start: cursor + (needsLeadingSpace ? 1 : 0), sourceAnchor: options.citationCommentAnchor, + // An existing space after the cursor is consumed and re-emitted, so it stays. + insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, }, focusEditorAfterReplace: false, } From ff3a04a8ebaaf6bb74b8b5b996013b82e044c700 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:32:58 +0530 Subject: [PATCH 033/530] fix(settings): provider updates show live progress instead of a bare spinner (#16958) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../providerMaintenanceRunner.test.ts | 66 +++++++++++- .../src/provider/providerMaintenanceRunner.ts | 101 +++++++++++++++--- .../settings/ProviderInstanceCard.tsx | 59 ++++++++-- 3 files changed, 201 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c82ec00143f2..b6580c78856d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,6 +7,8 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { ]); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.deepStrictEqual( - (yield* Ref.get(updateStatesRef)).map((state) => state.status), - ["queued", "running", "succeeded"], + (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), + [ + ["queued", "Waiting for another provider update to finish."], + ["running", "Checking for the latest version"], + ["running", "Running native-agent update"], + ["running", "Verifying the installed version"], + ["succeeded", "Provider updated."], + ], ); }).pipe( Effect.provide( @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("reports the installer's latest output line while the update runs", () => { + const exited = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + const runningMessages = Ref.get(updateStatesRef).pipe( + Effect.map((states) => + states.filter((state) => state.status === "running").map((state) => state.message), + ), + ); + + const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + assert.deepStrictEqual(yield* runningMessages, [ + "Checking for the latest version", + "Running native-agent update", + "80%", + ]); + + yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); + const result = yield* Fiber.join(fiber); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ + stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", + exitCode: Deferred.await(exited), + })), + ), + ), + ); + }); + + it("splits installer output into clean status lines", () => { + assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { + lines: ["Downloading"], + partialLine: " 5", + }); + assert.strictEqual( + ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"), + "==> Pouring", + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null); + assert.strictEqual( + ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length, + 4_096, + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200); + }); + it.effect("aborts without running when the installation changed since the advisory", () => { const calls: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 1e22ba899bec..cc8b3728785f 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as NodeUtil from "node:util"; import * as ModelManifest from "./ModelManifest.ts"; import { resolveProviderCompatibility } from "./providerCompatibility.ts"; @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; +// Every progress publish resends the provider list to each client, so the +// installer's output is sampled rather than forwarded line by line. +const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); +const UPDATE_PROGRESS_MAX_LENGTH = 200; +// An installer may write for minutes without a line ending; only its tail matters. +const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096; export interface ProviderMaintenanceCommandResult { readonly stdout: string; @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly command: string; readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; + readonly onProgress?: (line: string) => Effect.Effect; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + // Holds the newest output line not yet reported; the sampler takes it. + const pendingProgress = yield* Ref.make(null); + const onProgress = input.onProgress; + if (onProgress) { + yield* Ref.getAndSet(pendingProgress, null).pipe( + Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))), + Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)), + Effect.forkScoped, + ); + } + const trackProgress = (stream: Stream.Stream) => { + if (!onProgress) { + return stream; + } + const decoder = new TextDecoder(); + let partialLine = ""; + return stream.pipe( + Stream.tap((chunk) => { + const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true })); + partialLine = split.partialLine; + // A progress bar that redraws with a leading `\r` keeps its newest + // frame unterminated, so that frame is the latest status. + const line = + toProgressLine(split.partialLine) ?? + split.lines.map(toProgressLine).findLast((value) => value !== null); + return line ? Ref.set(pendingProgress, line) : Effect.void; + }), + ); + }; + const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ - stream: child.stdout, + stream: trackProgress(child.stdout), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ - stream: child.stderr, + stream: trackProgress(child.stderr), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), child.exitCode, @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }, ); +/** + * Split a chunk of installer output into complete lines. `\r` ends a line too, + * so a redrawn progress bar reports its latest frame. + */ +export function splitOutputLines( + partialLine: string, + text: string, +): { readonly lines: ReadonlyArray; readonly partialLine: string } { + const parts = (partialLine + text).split(/\r\n|\r|\n/); + return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts }; +} + +/** Turn one raw output line into a short status message, or null if it has no text. */ +export function toProgressLine(line: string): string | null { + const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim(); + if (text.length === 0) { + return null; + } + return text.length <= UPDATE_PROGRESS_MAX_LENGTH + ? text + : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}โ€ฆ`; +} + +/** `claude update` reads better in a status line than the full executable path. */ +function describeCommand(command: ProviderMaintenanceCommandAction): string { + const executable = command.executable.split(/[\\/]/).pop() || command.executable; + return [executable, ...command.args].join(" "); +} + function trimNullable(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; - const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => + const runMaintenanceCommand = ( + update: ProviderMaintenanceCommandAction, + onProgress: (line: string) => Effect.Effect, + ) => runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, args: update.args, + onProgress, ...(update.env ? { env: update.env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { function* () { const startedAt = yield* nowIso; yield* Ref.set(startedAtRef, startedAt); - yield* setUpdateState( - makeUpdateState({ - status: "running", - startedAt, - finishedAt: null, - message: "Updating provider.", - }), - ); + const setRunningMessage = (message: string) => + setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message, + }), + ).pipe(Effect.asVoid); + yield* setRunningMessage("Checking for the latest version"); // The cached capabilities chose the lock; re-derive ownership // now so the command that runs matches the executable as it is @@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } - const result = yield* runMaintenanceCommand(command); + yield* setRunningMessage(`Running ${describeCommand(command)}`); + const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } + yield* setRunningMessage("Verifying the installed version"); // Homebrew's "latest" moves once the upgrade lands; read it again. const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 381dcd864e42..eab2fba04cb2 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ enabled, ); const updateCommand = versionAdvisory?.updateCommand ?? null; + const updateState = liveProvider?.updateState; + // The server reports each update step. `isUpdating` also covers the moment + // between the click and the server's first report. + const updateProgress = isUpdating + ? ((updateState?.status === "queued" || updateState?.status === "running" + ? updateState.message + : null) ?? "Starting update") + : null; + const updateProblem = + !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged") + ? updateState.message + : null; const hasCompatibilityWarning = compatibility !== undefined && compatibility.status !== "supported" && @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ size={mode === "list" ? "icon-micro" : "icon-xs"} variant="ghost-muted" className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} - aria-label={`${versionAdvisory.title} โ€” view details`} + aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} โ€” view details`} > - + {updateProgress ? ( + + ) : ( + + )} } /> } /> - {versionAdvisory.title} + + {updateProgress ? "Updating" : versionAdvisory.title} +
    @@ -838,6 +856,17 @@ export function ProviderInstanceCard({ : "Update now"} ) : null} + {updateProgress || updateProblem ? ( +

    + {updateProgress ?? updateProblem} +

    + ) : null} {onRunVersionAction && updateCommand ? (
    @@ -908,16 +937,24 @@ export function ProviderInstanceCard({ {versionAdvisoryNode} - {statusDotNode ? ( + {/* The dot describes provider health, not the update in progress. */} + {statusDotNode && !updateProgress ? ( {statusDotNode} ) : null} - {summary.headline} - {needsAttention && inlineStatusDetail ? ` ยท ${inlineStatusDetail}` : null} + {updateProgress ? ( + `Updating ยท ${updateProgress}` + ) : ( + <> + {summary.headline} + {needsAttention && inlineStatusDetail ? ` ยท ${inlineStatusDetail}` : null} + + )} From 14fe0158ede3d964f183e281f9fec225d30a6b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:11:05 -0700 Subject: [PATCH 034/530] feat(web): find in diffs with Cmd+F (#14623) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/DiffPanel.tsx | 13 + apps/web/src/components/RightPanelTabs.tsx | 3 +- .../components/diffs/AnnotatableCodeView.tsx | 4 + .../components/diffs/codeViewSearch.test.ts | 106 +++ .../pullRequest/PullRequestCodeTab.tsx | 6 + apps/web/src/index.css | 8 + apps/web/src/lib/editableFocus.test.ts | 25 + apps/web/src/lib/editableFocus.ts | 6 +- docs/user/keybindings.md | 9 + patches/@pierre%2Fdiffs@1.5.2.patch | 869 +++++++++++++++++- pnpm-lock.yaml | 10 +- 11 files changed, 1042 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/diffs/codeViewSearch.test.ts create mode 100644 apps/web/src/lib/editableFocus.test.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index cc5354f38354..633094738e9e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -695,6 +695,18 @@ export default function DiffPanel({ return { scopeKey: collapseScopeKey, fileKeys: next }; }); }, []); + // Find can ask again before the unfolded file reaches the viewer, so this must never fold. + const unfoldDiffFile = useCallback((fileKey: string) => { + const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current; + setCollapsedDiffFiles((current) => { + const fileKeys = + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys; + if (!fileKeys.has(fileKey)) return current; + const next = new Set(fileKeys); + next.delete(fileKey); + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, []); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); @@ -1197,6 +1209,7 @@ export default function DiffPanel({ }, } : {})} + onRevealSearchMatch={unfoldDiffFile} renderHeaderPrefix={(fileDiff, fileKey) => { const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; return ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4f6f3607f470..2c7e3e3da68c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -415,7 +415,8 @@ function RightPanelEmptyState(props: { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - const target = event.target; + // The composed path starts at the real target, which may sit inside a shadow root. + const target = event.composedPath()[0] ?? event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index ab4f545ed07b..b36fd8b3cb37 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps { fileKey: string, collapsed: boolean, ) => ReactNode; + /** Unfold a collapsed file that holds the find match being navigated to. */ + onRevealSearchMatch: (fileKey: string) => void; } interface DiffSelectionContext { @@ -115,6 +117,7 @@ export function AnnotatableCodeView({ renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, + onRevealSearchMatch, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -262,6 +265,7 @@ export function AnnotatableCodeView({ items={items} selectedLines={selectedLines} onSelectedLinesChange={setSelectedLines} + onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)} options={{ ...options, enableGutterUtility: !hasOpenComment, diff --git a/apps/web/src/components/diffs/codeViewSearch.test.ts b/apps/web/src/components/diffs/codeViewSearch.test.ts new file mode 100644 index 000000000000..80af9f5ed2f9 --- /dev/null +++ b/apps/web/src/components/diffs/codeViewSearch.test.ts @@ -0,0 +1,106 @@ +import type { CodeViewItem } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { describe, expect, it } from "vite-plus/test"; + +// Find-in-diff lives in our @pierre/diffs patch and is not a package export. +const searchUrl = new URL("./components/CodeViewSearch.js", import.meta.resolve("@pierre/diffs")); +const { collectMatches } = (await import(/* @vite-ignore */ searchUrl.href)) as { + collectMatches( + items: ReadonlyArray>, + params: { text: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean }, + ): Array<{ + id: string; + side: string; + isContext: boolean; + lineNumber: number; + start: number; + end: number; + }>; +}; + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,4 +1,5 @@ + const keep = 1; +-const oldName = 2; ++const newName = 2; ++const added = oldName; + const tail = 4; +@@ -20,3 +21,3 @@ function x() { + ctx20 name +-del21 Name ++add22 names + ctx22 +diff --git a/src/b.ts b/src/b.ts +index 3333333..4444444 100644 +--- a/src/b.ts ++++ b/src/b.ts +@@ -1,1 +1,1 @@ +-nil ++: nil +`; + +const items: CodeViewItem[] = parsePatchFiles(PATCH)[0]!.files.map( + (fileDiff, index) => ({ id: `file-${index}`, type: "diff", fileDiff, collapsed: index === 1 }), +); + +function find( + text: string, + flags: { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean } = {}, +) { + return collectMatches(items, { + text, + caseSensitive: flags.caseSensitive ?? false, + wholeWord: flags.wholeWord ?? false, + regex: flags.regex ?? false, + }).map(({ id, side, lineNumber, start, end }) => `${id}:${side}:${lineNumber}:${start}-${end}`); +} + +describe("collectMatches", () => { + it("finds removed, added, and context lines in render order, folded files included", () => { + expect(find("oldName")).toEqual(["file-0:deletions:2:6-13", "file-0:additions:3:14-21"]); + expect(find("nil")).toEqual(["file-1:deletions:1:0-3", "file-1:additions:1:2-5"]); + }); + + it("honours case, whole-word, and regex toggles", () => { + expect(find("Name", { caseSensitive: true })).toEqual([ + "file-0:deletions:2:9-13", + "file-0:additions:2:9-13", + "file-0:additions:3:17-21", + "file-0:deletions:21:6-10", + ]); + expect(find("name", { wholeWord: true })).toEqual([ + "file-0:additions:21:6-10", + "file-0:deletions:21:6-10", + ]); + expect(find("n[a-z]+e", { regex: true })).toHaveLength(6); + }); + + it("marks unchanged lines, which split view draws in both columns", () => { + const flags = collectMatches(items, { + text: "ctx", + caseSensitive: false, + wholeWord: false, + regex: false, + }).map(({ lineNumber, isContext }) => [lineNumber, isContext]); + expect(flags).toEqual([ + [21, true], + [23, true], + ]); + expect( + collectMatches(items, { + text: "oldName", + caseSensitive: false, + wholeWord: false, + regex: false, + }).map(({ isContext }) => isContext), + ).toEqual([false, false]); + }); + + it("returns nothing for an empty query or an invalid pattern", () => { + expect(find("")).toEqual([]); + expect(find("(", { regex: true })).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index e1c6f1de0453..9af92ee96a81 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1516,6 +1516,12 @@ function PullRequestCodeTab({ renderHeaderPrefix={renderHeaderPrefix} renderHeaderMetadata={renderHeaderMetadata} renderAnnotation={renderAnnotation} + // Find can ask again before the unfolded file arrives, so this unfolds and never folds. + onRevealSearchMatch={(item) => + setToggledFiles((current) => + toggleFileDiffFoldForViewed(item.id, false, effectiveFoldOverride, current), + ) + } unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} />
    diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d43fc36769a1..453a9fd599fe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2087,6 +2087,14 @@ code { color: var(--code-foreground); } +/* Pierre's find panel and match highlights read these. */ +.diff-render-surface { + --diffs-search-panel-bg: var(--popover); + --diffs-search-panel-fg: var(--popover-foreground); + --diffs-search-panel-accent: var(--primary); + --diffs-search-panel-font: var(--font-sans); +} + /* Pierre's file renderer owns its own surface tokens. Keep the root of a file preview on the same themeable code surface as the diff panel. */ .diff-render-surface diffs-container, diff --git a/apps/web/src/lib/editableFocus.test.ts b/apps/web/src/lib/editableFocus.test.ts new file mode 100644 index 000000000000..1028b98c6071 --- /dev/null +++ b/apps/web/src/lib/editableFocus.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { isEditableFocused } from "./editableFocus"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("isEditableFocused", () => { + it("sees a text field focused inside an open shadow root", () => { + // A key event from a shadow input is retargeted to its host, so the host is + // what page-level shortcut handlers receive as the event target. + const host = document.createElement("div"); + const input = document.createElement("input"); + host.attachShadow({ mode: "open" }).append(input); + document.body.append(host); + + expect(isEditableFocused(host)).toBe(false); + input.focus(); + expect(isEditableFocused(host)).toBe(true); + expect(isEditableFocused()).toBe(true); + }); +}); diff --git a/apps/web/src/lib/editableFocus.ts b/apps/web/src/lib/editableFocus.ts index ad704a39f616..79af4774b49e 100644 --- a/apps/web/src/lib/editableFocus.ts +++ b/apps/web/src/lib/editableFocus.ts @@ -11,7 +11,11 @@ const EDITABLE_SELECTOR = [ /** * Whether a text-editing element owns the keyboard. Shortcuts that share * their chord with native editing (mod+z) must yield when this is true. + * Focus inside an open shadow root counts: a key event's target is retargeted + * to the shadow host, so the host's focused descendant is what is typed into. */ export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean { - return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; + let element = target instanceof Element ? target : null; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element !== null && element.closest(EDITABLE_SELECTOR) !== null; } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 488b57b997cb..2d7c786cc3f8 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. Both shortcuts can be changed in Settings. Search for โ€œCopy Link or Thread IDโ€ or โ€œCopy Numberโ€. They copy the selected PR and leave terminal input alone. +## Find in a diff + +Click into a diff in the Diff panel or a pull request's Code tab, then press +`mod+f` to search every file in it, including folded files and unchanged lines +hidden between changes. Enter and `Shift+Enter` move between matches, and a +match in a folded file opens it. Escape closes the search. This shortcut is not +configurable. A very large uncommitted diff loads its files as you scroll, and +find only searches the files loaded so far. + ## iPad With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch index f1984ea41835..fbb672dc90ab 100644 --- a/patches/@pierre%2Fdiffs@1.5.2.patch +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -1,7 +1,704 @@ +diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts +index cc32d28..3a46bf4 100644 +--- a/dist/components/CodeView.d.ts ++++ b/dist/components/CodeView.d.ts +@@ -158,6 +158,12 @@ interface CodeViewOptions extends CodeViewPassThroughOptions + * final pre-detach state. + */ + onItemEditComplete?: CodeViewItemEditCompleteHandler; ++ /** ++ * Called when find navigates to a match inside a collapsed item. Folding is ++ * host state, so the host unfolds the item (a controlled update with ++ * `collapsed: false` and a new `version`); the viewer then scrolls to it. ++ */ ++ onRevealSearchMatch?(item: CodeViewItem): void; + /** Render a non-virtualized element at the very start of the scroll content, + * before the first item. It is always rendered and scrolls with the content. + * Return the same element across calls and mutate it in place to update; +@@ -268,6 +274,13 @@ declare class CodeView { + getSelectedLines(): CodeViewLineSelection | null; + clearSelectedLines(options?: SelectionWriteOptions): void; + getItem(itemId: string): CodeViewItem | undefined; ++ /** ++ * Open the find panel, or refocus it when open. It searches every diff ++ * item's lines (including folded files and virtualized rows), and is also ++ * opened by the primary-modifier+F shortcut while focus is in the viewer. ++ */ ++ openSearch(): void; ++ closeSearch(): void; + /** + * Returns the editor while this item is being edited. Partial diffs get an + * editor after their files load; scrolling away or collapsing keeps it. +diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js +index 140f94f..96fc774 100644 +--- a/dist/components/CodeView.js ++++ b/dist/components/CodeView.js +@@ -16,6 +16,7 @@ import { prefersReducedMotion } from "../utils/prefersReducedMotion.js"; + import { roundToDevicePixel } from "../utils/roundToDevicePixel.js"; + import { VirtualizedFile } from "./VirtualizedFile.js"; + import { VirtualizedFileDiff } from "./VirtualizedFileDiff.js"; ++import { CodeViewSearch } from "./CodeViewSearch.js"; + //#region src/components/CodeView.ts + const CODE_VIEW_DIFF_OPTION_KEYS = [ + "theme", +@@ -114,6 +115,8 @@ function defineItemOption(target, key, get) { + } + const DEFAULT_SCROLL_INTERACTION_RESTORE_DELAY_MS = 120; + const SUB_PIXEL_TOLERANCE = 1; ++/** Room the find panel covers at the top of the viewport; matches under it get scrolled clear. */ ++const SEARCH_PANEL_RESERVED_HEIGHT = 48; + const SCROLLING_CODE_OVERFLOW_FIX_VARIABLE = "--diffs-overflow-override"; + function createScrollRebaseConfig(containerHeight, triggerTop, targetTop) { + return { +@@ -211,6 +214,8 @@ var CodeView = class CodeView { + isReadySubscription; + pendingHighlighterTheme; + isContainerManaged; ++ search = new CodeViewSearch(this); ++ pendingSearchReveal; + constructor(options = { theme: DEFAULT_THEMES }, workerManager, isContainerManaged = false) { + this.options = options; + this.computeMetricsCache(options.itemMetrics); +@@ -392,6 +397,7 @@ var CodeView = class CodeView { + this.root.addEventListener("touchstart", this.clearPendingScroll, { passive: true }); + this.root.addEventListener("pointerdown", this.clearPendingScroll, { passive: true }); + this.root.addEventListener("keydown", this.clearPendingScroll, { passive: true }); ++ this.root.addEventListener("keydown", this.search.handleKeyDown); + this.resizeObserver.observe(this.root); + this.render(true); + window.__INSTANCE = this; +@@ -464,6 +470,7 @@ var CodeView = class CodeView { + this.heightDirty = true; + this.resetRenderState(); + if (!this.isContainerManaged) this.flushSlotCoordinator(); ++ this.search.refresh(); + if (failed) throw failure; + } + cleanUp() { +@@ -485,6 +492,8 @@ var CodeView = class CodeView { + this.root?.removeEventListener("touchstart", this.clearPendingScroll); + this.root?.removeEventListener("pointerdown", this.clearPendingScroll); + this.root?.removeEventListener("keydown", this.clearPendingScroll); ++ this.root?.removeEventListener("keydown", this.search.handleKeyDown); ++ this.search.cleanUp(); + this.root?.style.removeProperty("overflow-anchor"); + this.container?.remove(); + this.stickyOffset.remove(); +@@ -626,6 +635,137 @@ var CodeView = class CodeView { + getItem(itemId) { + return this.idToItem.get(itemId)?.item; + } ++ /** Open the find panel, or refocus it when it is already open. */ ++ openSearch() { ++ this.search.open(); ++ } ++ closeSearch() { ++ this.search.close(); ++ } ++ getSearchItems() { ++ return this.items.map((item) => item.item); ++ } ++ /** Index of the first match at or below the top of the viewport, or 0 when every match is above it. */ ++ getFirstVisibleSearchMatchIndex(entries) { ++ if (this.items.length === 0) return 0; ++ const visibleTop = this.getScrollTop() - this.getItemTopOffset() + this.getStickyHeaderOffset(); ++ const itemIndex = this.findFirstVisibleIndex(Math.max(this.getScrollTop() - this.getItemTopOffset(), 0)); ++ const item = this.items[itemIndex]; ++ for (let index = 0; index < entries.length; index++) { ++ const entry = entries[index]; ++ if (entry.itemIndex < itemIndex) continue; ++ if (entry.itemIndex > itemIndex) return index; ++ const position = this.getLineScrollPosition(item, entry); ++ if (position == null || item.top + position.top >= visibleTop) return index; ++ } ++ return 0; ++ } ++ /** ++ * Bring a match on screen. A folded file is unfolded by the host through ++ * `onRevealSearchMatch` (folding is host state); the reveal resumes once the ++ * item arrives unfolded. Collapsed context around the line is expanded here. ++ */ ++ revealSearchMatch(match, afterUnfold = false) { ++ const item = this.idToItem.get(match.id); ++ if (item == null || item.type !== "diff") return; ++ if (item.item.collapsed === true) { ++ if (this.options.onRevealSearchMatch == null) { ++ this.scrollTo({ ++ type: "item", ++ id: match.id, ++ align: "start" ++ }); ++ return; ++ } ++ this.pendingSearchReveal = match; ++ this.options.onRevealSearchMatch(item.item); ++ return; ++ } ++ const target = { ++ type: "line", ++ id: match.id, ++ lineNumber: match.lineNumber, ++ side: match.side, ++ align: "center", ++ // Center in the space below the sticky header and the find panel. ++ offset: -(this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT) / 2 ++ }; ++ // Before its first render a file reports every line renderable, so collapsed ++ // context can't be expanded yet: scroll there and finish once it has rendered. ++ const rendered = item.instance.getRenderedDiff() != null; ++ this.pendingSearchReveal = rendered ? void 0 : match; ++ if (rendered && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) item.instance.revealLine(match.lineNumber); ++ else if (rendered && !afterUnfold && this.isScrollTargetInView(target)) return; ++ this.scrollTo(target); ++ } ++ cancelSearchReveal() { ++ this.pendingSearchReveal = void 0; ++ } ++ /** Finish a reveal that was waiting for its file to render for the first time. */ ++ resumePendingSearchReveal(updatedItems) { ++ const reveal = this.pendingSearchReveal; ++ if (reveal == null) return; ++ for (const item of updatedItems) if (item.item.id === reveal.id && item.item.collapsed !== true && item.instance.getRenderedDiff() != null) { ++ queueMicrotask(() => { ++ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true); ++ }); ++ return; ++ } ++ } ++ /** Scroll a wide code column so the current match is visible when lines don't wrap. */ ++ revealSearchMatchHorizontally(match, element) { ++ const item = this.idToItem.get(match.id); ++ const code = element.closest("[data-code]"); ++ if (item == null || code == null || code.scrollWidth <= code.clientWidth) return; ++ const codeRect = code.getBoundingClientRect(); ++ const matchRect = getTextRangeRect(element, match.start, match.end); ++ if (matchRect == null) return; ++ // The line-number gutter is sticky at the left edge and covers the code under it. ++ const gutterRight = code.querySelector(":scope > [data-gutter]")?.getBoundingClientRect().right; ++ const visibleLeft = gutterRight != null && gutterRight > codeRect.left ? gutterRight : codeRect.left; ++ const padding = 24; ++ let next = code.scrollLeft; ++ if (matchRect.left < visibleLeft + padding) next -= visibleLeft + padding - matchRect.left; ++ else if (matchRect.right > codeRect.right - padding) next += matchRect.right - (codeRect.right - padding); ++ else return; ++ item.instance.setCodeScrollLeft(Math.max(0, next)); ++ } ++ /** Whether a line already sits fully below the sticky header and find panel. */ ++ isScrollTargetInView(target) { ++ const rect = this.getScrollTargetRect(target); ++ if (rect == null) return false; ++ const top = this.getItemTopOffset() + rect.top; ++ const scrollTop = this.getScrollTop(); ++ return top >= scrollTop + this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT && top + rect.height <= scrollTop + this.getHeight(); ++ } ++ /** Pair each match with its mounted line element; rows outside the window are skipped. */ ++ getMountedSearchMatches(entriesByItem) { ++ const mounted = []; ++ const { firstIndex, lastIndex } = this.renderState; ++ if (firstIndex === -1) return mounted; ++ for (let index = firstIndex; index <= lastIndex; index++) { ++ const itemEntries = entriesByItem.get(index); ++ const shadowRoot = itemEntries != null ? this.items[index]?.element?.shadowRoot : void 0; ++ if (shadowRoot == null) continue; ++ const lines = /* @__PURE__ */ new Map(); ++ for (const element of shadowRoot.querySelectorAll("[data-line][data-line-index]")) { ++ const unifiedIndex = element.getAttribute("data-line-index").split(",")[0]; ++ const column = element.closest("[data-deletions]") != null ? "deletions" : "additions"; ++ lines.set(`${column}:${unifiedIndex}`, element); ++ } ++ for (const entry of itemEntries) { ++ // Split view draws unchanged context in both columns, so paint each copy. ++ const additions = lines.get(`additions:${entry.unifiedLineIndex}`); ++ const deletions = lines.get(`deletions:${entry.unifiedLineIndex}`); ++ const elements = entry.side === "deletions" ? [deletions ?? additions] : entry.isContext ? [additions, deletions] : [additions]; ++ for (const element of elements) if (element != null) mounted.push({ ++ entry, ++ element ++ }); ++ } ++ } ++ return mounted; ++ } + /** + * Returns the editor while this item is being edited. Partial diffs get an + * editor after their files load; scrolling away or collapsing keeps it. +@@ -645,6 +785,7 @@ var CodeView = class CodeView { + this.render(); + this.syncItemEditors(); + this.syncSelection(); ++ this.search.refresh(); + return true; + } + updateItemId(oldId, newId) { +@@ -678,6 +819,7 @@ var CodeView = class CodeView { + this.renamePendingScrollTarget(oldId, newId); + this.renamePendingLayoutAnchor(oldId, newId); + this.render(); ++ this.search.refresh(); + return true; + } + addItem(input) { +@@ -687,6 +829,7 @@ var CodeView = class CodeView { + this.appendItemsInternal(inputs); + this.syncItemEditors(); + this.syncSelection(); ++ this.search.refresh(); + } + removeItem(itemId) { + const item = this.idToItem.get(itemId); +@@ -709,6 +852,7 @@ var CodeView = class CodeView { + for (const removedItem of removedItemsById?.values() ?? []) removedItem.instance.cleanUp(); + } + this.syncSelection(); ++ this.search.refresh(); + } + /** + * Append new records to the viewer while preserving existing layout state. +@@ -1330,6 +1474,10 @@ var CodeView = class CodeView { + item.item = nextItem; + item.version = nextItem.version; + item.renderedOptionsRevision = -1; ++ const reveal = this.pendingSearchReveal; ++ if (reveal != null && reveal.id === nextItem.id && nextItem.collapsed !== true) queueMicrotask(() => { ++ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true); ++ }); + return true; + } + getMaxScrollTopForHeight(scrollHeight) { +@@ -1710,6 +1858,10 @@ var CodeView = class CodeView { + this.updateStickyPositioning(); + this.validateStickyContainerHeight(); + this.fixContainerFocus(); ++ if (updatedItems.size > 0) { ++ this.resumePendingSearchReveal(updatedItems); ++ this.search.repaint(); ++ } + if (fitPerfectly || this.scrollAnimation != null) this.render(); + }; + flushManagers(updatedItems) { +@@ -1804,7 +1956,8 @@ var CodeView = class CodeView { + this.notifyScroll(); + this.render(); + }; +- clearPendingScroll = () => { ++ clearPendingScroll = (event) => { ++ if (event != null && this.search.isOpen && this.search.ownsEvent(event)) return; + this.pendingScrollTarget = void 0; + this.pendingLayoutAnchor = void 0; + this.scrollAnimation = void 0; +@@ -2066,7 +2219,12 @@ var CodeView = class CodeView { + const item = this.items[index]; + if (item == null) throw new Error("CodeView.recomputeLayout: invalid item index"); + item.top = runningTop; +- if (item.type === "diff") item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []); ++ if (item.type === "diff") { ++ const wasPartial = item.item.fileDiff.isPartial; ++ item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []); ++ // Hydration fills the diff's lines in place, so the search has new text to cover. ++ if (wasPartial && !item.item.fileDiff.isPartial) this.search.refresh(true); ++ } + else item.height = item.instance.updateCodeViewLayout(item.item.file, runningTop, reset, item.item.annotations ?? []); + runningTop += item.height; + if (index < this.items.length - 1) runningTop += layout.gap; +@@ -2086,6 +2244,27 @@ var CodeView = class CodeView { + return this.getLayout().gap + this.itemMetricsCache.diffHeaderHeight; + } + }; ++function getTextRangeRect(lineElement, start, end) { ++ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT); ++ let consumed = 0; ++ let startNode; ++ let startOffset = 0; ++ let node; ++ while ((node = walker.nextNode()) != null) { ++ const length = node.data.length; ++ if (startNode == null && start < consumed + length) { ++ startNode = node; ++ startOffset = start - consumed; ++ } ++ if (startNode != null && end <= consumed + length) { ++ const range = document.createRange(); ++ range.setStart(startNode, startOffset); ++ range.setEnd(node, end - consumed); ++ return range.getBoundingClientRect(); ++ } ++ consumed += length; ++ } ++} + function prepareItemInstance(item) { + item.instance.cleanUp(true); + if (item.type === "diff") return item.instance.updateCodeViewLayout(item.item.fileDiff, item.top, void 0, item.item.annotations ?? []); +diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js +new file mode 100644 +index 0000000..29370ad +--- /dev/null ++++ b/dist/components/CodeViewSearch.js +@@ -0,0 +1,360 @@ ++import { isPrimaryModifier } from "../editor/platform.js"; ++import { resolveFindAgainShortcut } from "../editor/command.js"; ++import { SearchPanelWidget } from "../editor/searchPanel.js"; ++import { SVGSpriteSheet } from "../editor/sprite.js"; ++import editor_default from "../editor/editor2.js"; ++import { iterateOverDiff } from "../utils/iterateOverDiff.js"; ++//#region src/components/CodeViewSearch.ts ++const MAX_SEARCH_MATCHES = 1e5; ++const WORD_SEPARATORS = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; ++const SEARCH_HOST_ATTRIBUTE = "data-diffs-search"; ++const SEARCH_PANEL_HOST_CSS = `:host { ++ display: block; ++ position: sticky; ++ z-index: 100; ++ height: 0; ++} ++[data-icon-sprite] { ++ position: absolute; ++} ++[data-search-panel] { ++ position: relative; ++ top: 8px; ++} ++[data-editor-widget] { ++ --diffs-bg: var(--diffs-search-panel-bg, Canvas); ++ --diffs-fg: var(--diffs-search-panel-fg, CanvasText); ++ --diffs-modified-base: var(--diffs-search-panel-accent, light-dark(#009fff, #69b1ff)); ++ --diffs-header-font-fallback: var(--diffs-search-panel-font, system-ui, sans-serif); ++}`; ++let sharedHighlights; ++/** ++* Find-in-diff for a CodeView. It searches every diff item's line data rather ++* than the rendered rows, so matches in virtualized rows, expanded context, and ++* folded files are all found, and navigating to one reveals it. ++* ++* Matches are painted with the CSS Custom Highlight API. Ranges are rebuilt for ++* whichever rows a render leaves mounted, so highlighting never touches the ++* rendered DOM or the renderer's caches. ++*/ ++var CodeViewSearch = class { ++ #view; ++ #host; ++ #widget; ++ #entries = []; ++ #entriesByItem = /* @__PURE__ */ new Map(); ++ #current; ++ #revealHorizontally; ++ #ranges = []; ++ #searchedDiffs = []; ++ constructor(view) { ++ this.#view = view; ++ } ++ get isOpen() { ++ return this.#host != null; ++ } ++ /** Open the panel, or refocus it when it is already open. */ ++ open() { ++ if (this.#widget != null) { ++ this.#widget.setMode("find"); ++ return; ++ } ++ const root = this.#view.getContainerElement(); ++ if (root == null) return; ++ const host = document.createElement("div"); ++ host.setAttribute(SEARCH_HOST_ATTRIBUTE, ""); ++ host.style.top = `${this.#view.getStickyHeaderOffset()}px`; ++ const shadowRoot = host.attachShadow({ mode: "open" }); ++ const style = document.createElement("style"); ++ style.textContent = `${editor_default}\n${SEARCH_PANEL_HOST_CSS}`; ++ const sprite = document.createElement("div"); ++ sprite.innerHTML = SVGSpriteSheet; ++ const anchor = document.createElement("div"); ++ shadowRoot.append(style, ...sprite.children, anchor); ++ root.prepend(host); ++ this.#host = host; ++ const defaultQuery = getSelectionQuery(); ++ // The widget runs its first search a frame after opening, so callbacks from a ++ // panel that has since closed must not touch the current search. ++ const isLive = () => this.#widget === widget; ++ // Without a selection that first frame reports no matches; ignore it if a ++ // query typed before then has already found some. ++ let initializing = defaultQuery === ""; ++ const widget = new SearchPanelWidget({ ++ textDocument: { search: (params) => isLive() ? this.#search(params) : [] }, ++ containerElement: anchor, ++ defaultQuery, ++ mode: "find", ++ allowReplace: false, ++ initialMatch: defaultQuery !== "" ? [0, 0] : void 0, ++ scrollToMatch: ([offset]) => { ++ if (isLive()) this.#goTo(offset); ++ }, ++ applyReplace: () => {}, ++ onUpdate: (matches, options) => { ++ if (!isLive()) return; ++ if (matches.length === 0) { ++ if (!initializing || this.#entries.length === 0) this.#setEntries([]); ++ return; ++ } ++ if (options?.syncSelection === false) { ++ this.#paint(); ++ return this.#current != null ? matches[this.#current] : void 0; ++ } ++ const next = matches[this.#current ?? this.#view.getFirstVisibleSearchMatchIndex(this.#entries)]; ++ if (next != null) this.#goTo(next[0]); ++ return next; ++ }, ++ onClose: () => this.#teardown(true) ++ }); ++ this.#widget = widget; ++ // Runs after the widget's own first-frame callback, which was scheduled first. ++ if (initializing) requestAnimationFrame(() => { ++ initializing = false; ++ }); ++ widget.focus(); ++ } ++ close() { ++ this.#widget?.close(); ++ } ++ /** ++ * Rerun the query after the items change, keeping the current match when it ++ * survives. Updates that keep every diff (folding, annotations) only repaint; ++ * `force` covers diffs whose lines were filled in place. ++ */ ++ refresh(force = false) { ++ if (this.#widget == null) return; ++ if (!force && haveSameDiffs(this.#searchedDiffs, this.#view.getSearchItems())) { ++ this.#paint(); ++ return; ++ } ++ this.#widget.updateMatches({ syncSelection: false }); ++ } ++ /** Repaint highlights for the rows the last render pass left mounted. */ ++ repaint() { ++ if (this.#entries.length > 0) this.#paint(); ++ } ++ /** Whether an event came from the panel, which handles its own keys and pointer input. */ ++ ownsEvent(event) { ++ return event.composedPath().some((node) => node instanceof Element && node.hasAttribute(SEARCH_HOST_ATTRIBUTE)); ++ } ++ handleKeyDown = (event) => { ++ if (event.isComposing || event.keyCode === 229) return; ++ // The panel's inputs handle their own keys; its buttons get the shortcuts below. ++ if (this.ownsEvent(event) && isEditableTarget(event)) { ++ if (event.key === "Escape") event.stopPropagation(); ++ return; ++ } ++ if (event.defaultPrevented) return; ++ if (isPrimaryModifier(event) && !event.altKey && !event.shiftKey && (event.key.toLowerCase() === "f" || event.code === "KeyF")) { ++ event.preventDefault(); ++ this.open(); ++ return; ++ } ++ if (this.#widget == null) return; ++ const findAgain = resolveFindAgainShortcut(event); ++ if (findAgain != null) { ++ event.preventDefault(); ++ this.#widget.navigate(findAgain === "previous"); ++ } else if (event.key === "Escape" && !isEditableTarget(event)) { ++ event.preventDefault(); ++ event.stopPropagation(); ++ this.close(); ++ } ++ }; ++ cleanUp() { ++ this.#teardown(false); ++ } ++ #teardown(restoreFocus) { ++ if (this.#host == null) return; ++ this.#widget?.cleanup(); ++ this.#widget = void 0; ++ this.#host.remove(); ++ this.#host = void 0; ++ this.#searchedDiffs = []; ++ this.#setEntries([]); ++ if (restoreFocus) this.#view.getContainerElement()?.focus({ preventScroll: true }); ++ } ++ #search(params) { ++ const previous = this.#current != null ? this.#entries[this.#current] : void 0; ++ const items = this.#view.getSearchItems(); ++ this.#searchedDiffs = items.map((item) => [item.id, item.fileDiff]); ++ this.#setEntries(collectMatches(items, params), previous); ++ return this.#entries.map((entry) => entry.range); ++ } ++ #setEntries(entries, previous) { ++ this.#entries = entries; ++ this.#entriesByItem = groupByItem(entries); ++ this.#current = previous != null ? findSameMatch(entries, previous) : void 0; ++ if (entries.length === 0) { ++ this.#view.cancelSearchReveal(); ++ this.#paint(); ++ } ++ } ++ #goTo(offset) { ++ const index = this.#entries.findIndex((entry) => entry.range[0] === offset); ++ if (index === -1) return; ++ this.#current = index; ++ this.#revealHorizontally = this.#entries[index]; ++ this.#view.revealSearchMatch(this.#entries[index]); ++ this.#paint(); ++ } ++ #paint() { ++ const highlights = getSharedHighlights(); ++ if (highlights == null) return; ++ for (const range of this.#ranges) { ++ highlights.match.delete(range); ++ highlights.focus.delete(range); ++ } ++ this.#ranges = []; ++ const current = this.#current != null ? this.#entries[this.#current] : void 0; ++ let currentElement; ++ for (const { entry, element } of this.#view.getMountedSearchMatches(this.#entriesByItem)) { ++ const range = createTextRange(element, entry.start, entry.end); ++ if (range == null) continue; ++ (entry === current ? highlights.focus : highlights.match).add(range); ++ this.#ranges.push(range); ++ if (entry === current) currentElement ??= element; ++ } ++ if (current != null && currentElement != null && this.#revealHorizontally === current) { ++ this.#revealHorizontally = void 0; ++ this.#view.revealSearchMatchHorizontally(current, currentElement); ++ } ++ } ++}; ++/** ++* Collect matches across diff items in render order. Each match gets a ++* synthetic, increasing offset so the panel's offset-based navigation works ++* across files; nothing else reads those offsets. ++*/ ++function collectMatches(items, params) { ++ if (params.text === "" || params.text.includes("\n")) return []; ++ let pattern; ++ try { ++ pattern = new RegExp(params.regex ? params.text : escapeRegExp(params.text), params.caseSensitive ? "g" : "gi"); ++ } catch { ++ return []; ++ } ++ const entries = []; ++ let offset = 0; ++ for (let itemIndex = 0; itemIndex < items.length && entries.length < MAX_SEARCH_MATCHES; itemIndex++) { ++ const item = items[itemIndex]; ++ if (item.type !== "diff") continue; ++ const { fileDiff } = item; ++ iterateOverDiff({ ++ diff: fileDiff, ++ diffStyle: "unified", ++ expandedHunks: true, ++ callback: ({ type, deletionLine, additionLine }) => { ++ const isDeletion = type === "change" && deletionLine != null; ++ const line = isDeletion ? deletionLine : additionLine; ++ const text = trimLineBreak((isDeletion ? fileDiff.deletionLines : fileDiff.additionLines)[line.lineIndex] ?? ""); ++ pattern.lastIndex = 0; ++ let match; ++ while ((match = pattern.exec(text)) !== null) { ++ const length = match[0].length; ++ if (length === 0) { ++ pattern.lastIndex++; ++ continue; ++ } ++ if (params.wholeWord && !isWholeWord(text, match.index, length)) continue; ++ entries.push({ ++ id: item.id, ++ itemIndex, ++ side: isDeletion ? "deletions" : "additions", ++ isContext: type !== "change", ++ lineNumber: line.lineNumber, ++ unifiedLineIndex: line.unifiedLineIndex, ++ start: match.index, ++ end: match.index + length, ++ range: [offset + match.index, offset + match.index + length] ++ }); ++ if (entries.length >= MAX_SEARCH_MATCHES) return true; ++ } ++ offset += text.length + 1; ++ } ++ }); ++ } ++ return entries; ++} ++function haveSameDiffs(searched, items) { ++ return searched.length === items.length && items.every((item, index) => item.id === searched[index][0] && item.fileDiff === searched[index][1]); ++} ++function groupByItem(entries) { ++ const byItem = /* @__PURE__ */ new Map(); ++ for (const entry of entries) { ++ let list = byItem.get(entry.itemIndex); ++ if (list == null) byItem.set(entry.itemIndex, list = []); ++ list.push(entry); ++ } ++ return byItem; ++} ++function findSameMatch(entries, previous) { ++ const index = entries.findIndex((entry) => entry.id === previous.id && entry.side === previous.side && entry.lineNumber === previous.lineNumber && entry.start === previous.start); ++ return index === -1 ? void 0 : index; ++} ++function createTextRange(lineElement, start, end) { ++ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT); ++ let consumed = 0; ++ let startNode; ++ let startOffset = 0; ++ let node; ++ while ((node = walker.nextNode()) != null) { ++ const length = node.data.length; ++ if (startNode == null && start < consumed + length) { ++ startNode = node; ++ startOffset = start - consumed; ++ } ++ if (startNode != null && end <= consumed + length) { ++ const range = document.createRange(); ++ range.setStart(startNode, startOffset); ++ range.setEnd(node, end - consumed); ++ return range; ++ } ++ consumed += length; ++ } ++} ++function trimLineBreak(text) { ++ if (text.endsWith("\r\n")) return text.slice(0, -2); ++ if (text.endsWith("\n") || text.endsWith("\r")) return text.slice(0, -1); ++ return text; ++} ++function isWholeWord(text, start, length) { ++ return isSeparator(text, start - 1) && isSeparator(text, start + length); ++} ++function isSeparator(text, index) { ++ if (index < 0 || index >= text.length) return true; ++ const code = text.charCodeAt(index); ++ return code <= 32 || code === 127 || WORD_SEPARATORS.includes(text[index]); ++} ++function escapeRegExp(text) { ++ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); ++} ++function isEditableTarget(event) { ++ const target = event.composedPath()[0]; ++ return target instanceof HTMLElement && (target.isContentEditable || target.matches("input, textarea, select")); ++} ++function getSelectionQuery() { ++ const text = document.getSelection()?.toString() ?? ""; ++ return text.includes("\n") ? "" : text; ++} ++/** ++* Two registry entries shared by every CodeView on the page; each search adds ++* and removes only its own ranges. The `::highlight()` colours live in the core ++* stylesheet, because a highlight rule only styles text in its own tree. ++*/ ++function getSharedHighlights() { ++ if (sharedHighlights != null) return sharedHighlights; ++ if (typeof CSS === "undefined" || !("highlights" in CSS) || typeof Highlight === "undefined") return; ++ sharedHighlights = { ++ match: new Highlight(), ++ focus: new Highlight() ++ }; ++ CSS.highlights.set("diffs-search-match", sharedHighlights.match); ++ CSS.highlights.set("diffs-search-focus", sharedHighlights.focus); ++ return sharedHighlights; ++} ++//#endregion ++export { CodeViewSearch, collectMatches }; ++ ++//# sourceMappingURL=CodeViewSearch.js.map diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +index 5e68e35..79dcd88 100644 --- a/dist/components/VirtualizedFile.d.ts +++ b/dist/components/VirtualizedFile.d.ts -@@ -49,7 +49,7 @@ +@@ -49,7 +49,7 @@ declare class VirtualizedFile extend setVisibility(visible: boolean): void; rerender(): void; syncGhostTextRows(): void; @@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF protected finalizeRender(): void; private updatePendingRender; diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +index d1f57cd..5908ad7 100644 --- a/dist/components/VirtualizedFile.js +++ b/dist/components/VirtualizedFile.js -@@ -23,6 +23,7 @@ +@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File { heights: /* @__PURE__ */ new Map(), checkpoints: [], fileAnnotationHeight: 0, @@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil ghostTextRows: NO_GHOST_TEXT_ROWS }; pendingRender; -@@ -35,6 +36,8 @@ +@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File { super(options, workerManager, isContainerManaged); this.virtualizer = virtualizer; this.metrics = metrics; @@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil } setMetrics(metrics, force = false) { const nextMetrics = computeVirtualFileMetrics(metrics); -@@ -86,10 +89,12 @@ +@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); super.setThemeType(themeType); } @@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; -@@ -135,6 +140,13 @@ +@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File { if (this.code == null) return hasHeightChange; const content = this.code.children[1]; if (!(content instanceof HTMLElement)) return hasHeightChange; @@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { -@@ -358,12 +370,12 @@ +@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File { codeView.instanceChanged(this, true); } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); } @@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil const file = this.getRenderedFile(); if (!this.isSimpleMode()) this.computeApproximateSize(true); else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { +diff --git a/dist/components/web-components.js b/dist/components/web-components.js +index 6e7b486..5b7290f 100644 +--- a/dist/components/web-components.js ++++ b/dist/components/web-components.js +@@ -2,8 +2,17 @@ import { DIFFS_TAG_NAME } from "../constants.js"; + import style_default from "../style.js"; + import { getMeasuredScrollbarGutter } from "../utils/scrollbarGutter.js"; + //#region src/components/web-components.ts ++// `::highlight()` rules only style text in their own tree, so find's match colours ride ++// along with every container's shadow root. ++const SEARCH_HIGHLIGHT_CSS = `::highlight(diffs-search-match) { ++ background-color: var(--diffs-search-match-bg, light-dark(#ff963288, #ff963266)); ++} ++::highlight(diffs-search-focus) { ++ background-color: var(--diffs-search-focus-bg, color-mix(in lab, var(--diffs-modified-base) 45%, transparent)); ++}`; + if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") == null) { + let sheet; ++ let searchSheet; + class FileDiffContainer extends HTMLElement { + constructor() { + super(); +@@ -12,8 +21,10 @@ if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") + if (sheet == null) { + sheet = new CSSStyleSheet(); + sheet.replaceSync(style_default); ++ searchSheet = new CSSStyleSheet(); ++ searchSheet.replaceSync(SEARCH_HIGHLIGHT_CSS); + } +- shadowRoot.adoptedStyleSheets = [sheet]; ++ shadowRoot.adoptedStyleSheets = [sheet, searchSheet]; + } + connectedCallback() { + const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" }); diff --git a/dist/editor/editor.js b/dist/editor/editor.js +index 8f04a6f..c203023 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -1899,7 +1899,11 @@ +@@ -1899,7 +1899,11 @@ var Editor = class { changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, documentLineCount: textDocument.lineCount }); @@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); if (newLineAnnotations != null) { this.#lineAnnotations = newLineAnnotations; +diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts +index 9542849..1c1a51f 100644 +--- a/dist/editor/searchPanel.d.ts ++++ b/dist/editor/searchPanel.d.ts +@@ -15,6 +15,8 @@ interface SearchPanelOptions { + containerElement: HTMLElement; + defaultQuery: string; + mode?: SearchPanelMode; ++ /** False keeps the panel in find mode, for hosts with nothing to replace into. */ ++ allowReplace?: boolean; + initialMatch?: MatchRange; + scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void; + applyReplace: (edits: ResolvedTextEdit[]) => void; +diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js +index df1ba63..676751b 100644 +--- a/dist/editor/searchPanel.js ++++ b/dist/editor/searchPanel.js +@@ -12,7 +12,7 @@ var SearchPanelWidget = class { + #navigate; + #close; + constructor(options) { +- const { textDocument, containerElement, defaultQuery, mode = "find", initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options; ++ const { textDocument, containerElement, defaultQuery, mode = "find", allowReplace = true, initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options; + const searchParams = { + text: defaultQuery, + replaceText: "", +@@ -278,7 +278,7 @@ var SearchPanelWidget = class { + ] + }); + const applyMode = (next) => { +- gridElement.dataset.mode = next; ++ gridElement.dataset.mode = allowReplace ? next : "find"; + this.#inputElement.focus(); + this.#inputElement.select(); + }; diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +index 3efafc4..9e5b751 100644 --- a/dist/managers/ResizeManager.d.ts +++ b/dist/managers/ResizeManager.d.ts -@@ -5,6 +5,8 @@ +@@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions { columnVariables?: ResizeManagerColumnVariableMode; } declare class ResizeManager { @@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts private static managersByElement; private static getResizeObserver; diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +index 9724cb1..14145d8 100644 --- a/dist/managers/ResizeManager.js +++ b/dist/managers/ResizeManager.js -@@ -19,6 +19,7 @@ +@@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager { for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); } observedNodes = /* @__PURE__ */ new Map(); @@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js setup(pre, { disableAnnotations, columnVariables = "apply" }) { const annotationUpdates = /* @__PURE__ */ new Set(); const applyColumnVariables = columnVariables === "apply"; -@@ -212,6 +213,7 @@ +@@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager { this.applyAnnotationUpdates(annotationUpdates); annotationUpdates.clear(); this.applyColumnUpdates(codeUpdates); @@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js codeUpdates.clear(); } applyAnnotationUpdates(annotationUpdates) { +diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts +index e78e713..642b235 100644 +--- a/dist/react/CodeView.d.ts ++++ b/dist/react/CodeView.d.ts +@@ -56,6 +56,8 @@ interface CodeViewBaseProps { + * is frozen; re-key the accepted value in place before accepting. + */ + onItemEditComplete?: CodeViewItemEditCompleteHandler; ++ /** Unfold a collapsed item that holds the find match being navigated to. */ ++ onRevealSearchMatch?(item: CodeViewItem): void; + renderCustomHeader?(item: CodeViewItem): ReactNode; + renderHeaderPrefix?(item: CodeViewItem): ReactNode; + renderHeaderFilenameSuffix?(item: CodeViewItem): ReactNode; +diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js +index 84c7915..d1930bf 100644 +--- a/dist/react/CodeView.js ++++ b/dist/react/CodeView.js +@@ -24,7 +24,7 @@ function createDefaultCache(controlled) { + }; + } + function CodeViewInner(props, ref) { +- const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props; ++ const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onRevealSearchMatch, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props; + const controlled = controlledItems !== void 0; + const contextCreateEditor = useCreateEditor(); + const poolManager = useContext(WorkerPoolContext); +@@ -52,6 +52,9 @@ function CodeViewInner(props, ref) { + onItemEditChange?.(event, item); + }); + const emitItemEditComplete = useStableCallback((event, item, nextItem) => onItemEditComplete?.(event, item, nextItem) ?? "reject"); ++ const emitRevealSearchMatch = useStableCallback((item) => { ++ onRevealSearchMatch?.(item); ++ }); + const managedOptions = useMemo(() => createManagedCodeViewOptions({ + options, + hasCustomHeader, +@@ -63,13 +66,15 @@ function CodeViewInner(props, ref) { + getEditStateKey, + createEditor: contextCreateEditor != null ? createEditor : void 0, + onItemEditChange: onItemEditChange != null ? emitItemEditChange : void 0, +- onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0 ++ onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0, ++ onRevealSearchMatch: onRevealSearchMatch != null ? emitRevealSearchMatch : void 0 + }), [ + contextCreateEditor, + controlledSelection, + createEditor, + emitItemEditChange, + emitItemEditComplete, ++ emitRevealSearchMatch, + emitSelectedLinesChange, + hasCodeViewFooter, + hasCodeViewHeader, +@@ -78,6 +83,7 @@ function CodeViewInner(props, ref) { + getEditStateKey, + onItemEditChange, + onItemEditComplete, ++ onRevealSearchMatch, + onSelectedLinesChange, + options + ]); +@@ -302,7 +308,7 @@ function createSlotContentStore() { + } + }; + } +-function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete }) { ++function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete, onRevealSearchMatch }) { + const managedOptions = { + ...options, + controlledSelection, +@@ -312,6 +318,7 @@ function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRende + if (getEditStateKey != null) managedOptions.getEditStateKey = getEditStateKey; + if (onItemEditChange != null) managedOptions.onItemEditChange = onItemEditChange; + if (onItemEditComplete != null) managedOptions.onItemEditComplete = onItemEditComplete; ++ if (onRevealSearchMatch != null) managedOptions.onRevealSearchMatch = onRevealSearchMatch; + if (hasCustomHeader) managedOptions.renderCustomHeader = noopRender; + if (hasGutterRenderer) managedOptions.renderGutterUtility = noopRender; + if (hasCodeViewHeader) managedOptions.renderCodeViewHeader = noopRender; diff --git a/package.json b/package.json +index d9dc7b5..3c37bae 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,18 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445556401867..8a54dc0d1fa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,7 @@ patchedDependencies: '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 - '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289 + '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -276,7 +276,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -665,7 +665,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) @@ -14913,7 +14913,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -14928,7 +14928,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) From 08b1222d0bae670433c275ec70165c7b5048726f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:24 -0700 Subject: [PATCH 035/530] refactor(server): GitHub services are named for the API they call, not gh (#16967) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/scripts/measure-pr-preview.ts | 4 +- apps/server/src/git/GitManager.test.ts | 79 +++-- apps/server/src/git/GitManager.ts | 2 +- .../ThreadPullRequestService.ts | 2 +- ...i.test.ts => GitHubPullRequestApi.test.ts} | 272 +++++++++--------- ...lRequestCli.ts => GitHubPullRequestApi.ts} | 159 +++++----- .../GitHubPullRequestProvider.test.ts | 54 ++-- .../pullRequest/GitHubPullRequestProvider.ts | 12 +- .../PullRequestProviderRegistry.ts | 6 +- .../src/pullRequest/PullRequestService.ts | 2 +- .../pullRequest/gitHubConditionalChecks.ts | 4 +- apps/server/src/server.ts | 6 +- ...li.test.ts => GitHubRepositoryApi.test.ts} | 64 +++-- .../{GitHubCli.ts => GitHubRepositoryApi.ts} | 141 +++++---- .../GitHubSourceControlProvider.test.ts | 26 +- .../GitHubSourceControlProvider.ts | 14 +- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 6 +- apps/server/src/ws.ts | 4 +- 20 files changed, 410 insertions(+), 455 deletions(-) rename apps/server/src/pullRequest/{GitHubPullRequestCli.test.ts => GitHubPullRequestApi.test.ts} (94%) rename apps/server/src/pullRequest/{GitHubPullRequestCli.ts => GitHubPullRequestApi.ts} (96%) rename apps/server/src/sourceControl/{GitHubCli.test.ts => GitHubRepositoryApi.test.ts} (91%) rename apps/server/src/sourceControl/{GitHubCli.ts => GitHubRepositoryApi.ts} (90%) diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 414bd99d0745..a8d2f9d986d1 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index c4a688b1aa7a..f1fc491b1a2b 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/http"; -import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; @@ -73,7 +73,7 @@ const measuredApi = Layer.effect( Layer.provide(NodeServices.layer), ); -const services = GitHubPullRequestCli.layer.pipe( +const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(measuredApi), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 61dd000633ef..f30af2c23c5b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -42,7 +42,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -92,7 +92,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCli.GitHubCliError; + failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary( + raw: unknown, +): GitHubRepositoryApi.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -381,8 +383,8 @@ function createTextGeneration( }; } -function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCli["Service"]; +function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { + service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - GitHubCli.isGitHubCliError(error) + GitHubRepositoryApi.isGitHubRepositoryApiError(error) ? error - : new GitHubCli.GitHubCliCommandError({ - command: "gh", + : new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: error, }), @@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository lookup: ${repository}`), }), @@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), @@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), + .filter( + (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + ), ), ), createPullRequest: (input) => @@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), + Effect.map( + (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + ), ), getRepositoryCloneUrls: (input) => execute({ @@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository create: ${input.repository}`), }), @@ -691,7 +693,9 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + input?.ghScenario, + ); const textGeneration = createTextGeneration(input?.textGeneration); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", @@ -740,7 +744,7 @@ function makeManager(input?: { ), Effect.provide( Layer.merge( - Layer.succeed(GitHubCli.GitHubCli, gitHubCli), + Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), Layer.mock(GitHubApi.GitHubApi)({}), ), ), @@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliRateLimitError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ cwd: repoDir, cause: new Error(upstreamCause), }), @@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { errorTag: "SourceControlProviderError", provider: "github", providerOperation: "listChangeRequests", - providerCommand: "gh", errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); const loggedText = [ @@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ cwd: repoDir, cause: new Error("gh is not authenticated"), }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cbc0490a95c1..e9b244dfbe86 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1286,7 +1286,7 @@ export const make = Effect.gen(function* () { ? { provider: error.provider, providerOperation: error.operation, - providerCommand: error.command ?? "unknown", + ...(error.command === undefined ? {} : { providerCommand: error.command }), errorDetail: error.detail, } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 072bf930c038..c1e0cff2658f 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one + // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts similarity index 94% rename from apps/server/src/pullRequest/GitHubPullRequestCli.test.ts rename to apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 28aca8b6a9b3..c42bf6d147bf 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,13 +12,13 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -96,7 +96,7 @@ const mockApi = Layer.effect( ); const layer = it.layer( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provide(NodeCrypto.layer), @@ -489,7 +489,7 @@ it.effect( }), ), ); - const cli = yield* GitHubPullRequestCli.make.pipe( + const cli = yield* GitHubPullRequestApi.make.pipe( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), @@ -537,7 +537,7 @@ it.effect( }), ); -layer("GitHubPullRequestCli.layer", (it) => { +layer("GitHubPullRequestApi.layer", (it) => { it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; @@ -569,7 +569,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -623,7 +623,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const preview = yield* cli.getPullRequestPreview({ cwd: "/w", repository: "acme/web", @@ -653,7 +653,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockImplementation(() => Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const results = yield* Effect.all( Array.from({ length: 4 }, () => cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), @@ -683,7 +683,7 @@ layer("GitHubPullRequestCli.layer", (it) => { return output('{"id":123,"login":"viewer"}'); }), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", host: "github.identity-cancel.test" }; const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); yield* Deferred.await(firstStarted); @@ -727,7 +727,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const reads = yield* Effect.forEach( [7, 8], @@ -795,7 +795,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const reads = yield* Effect.forEach( [7, 8], @@ -855,7 +855,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const read = yield* cli .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) @@ -895,7 +895,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -943,7 +943,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", repository: "acme/web", @@ -963,7 +963,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reads an empty stacks listing as not stacked", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -986,7 +986,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -1009,7 +1009,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ @@ -1032,7 +1032,7 @@ layer("GitHubPullRequestCli.layer", (it) => { status: 503, }); mockedExecute.mockReturnValueOnce(Effect.fail(failure)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ cwd: "/w", @@ -1048,7 +1048,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports a stacks answer it cannot read against the stack read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ @@ -1068,7 +1068,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1091,7 +1091,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports truncation from the extra row, counted before decoding", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1117,7 +1117,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1143,7 +1143,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1168,7 +1168,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1189,7 +1189,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries every repository and every qualifier into one search", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1218,7 +1218,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("narrows a search to the author, and to merged on the merged tab", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1240,7 +1240,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("keeps a searched-for qualifier inside the phrase", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1263,7 +1263,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses to search for a repository GitHub cannot address", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const failure = yield* Effect.flip( cli.searchPullRequests({ @@ -1295,7 +1295,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ]), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.searchPullRequests({ cwd: "/w", @@ -1341,7 +1341,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const read = () => cli.searchPullRequests({ cwd: "/w", @@ -1390,7 +1390,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", repository: "acme/web", @@ -1443,7 +1443,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", repository: "acme/web", @@ -1469,7 +1469,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -1499,7 +1499,7 @@ layer("GitHubPullRequestCli.layer", (it) => { output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stats = yield* cli.listPullRequestStats({ cwd: "/w", @@ -1521,7 +1521,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses to look up counts for a repository GitHub cannot address", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const failure = yield* Effect.flip( cli.listPullRequestStats({ @@ -1545,7 +1545,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1575,7 +1575,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1603,7 +1603,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1641,7 +1641,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1667,7 +1667,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1701,7 +1701,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1732,7 +1732,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1759,7 +1759,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1784,7 +1784,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries the further narrowings into a batched search", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1813,7 +1813,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1843,7 +1843,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1873,7 +1873,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1895,7 +1895,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries on from the instant the last slice ended on", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1923,7 +1923,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // answer means the text matched nothing, and listing everything instead would fill the // page with rows the reader did not search for. mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1948,7 +1948,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1984,7 +1984,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2020,7 +2020,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2064,7 +2064,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2094,7 +2094,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -2139,7 +2139,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("updates a stale branch with a merge commit unless asked to rebase", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2168,7 +2168,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("merges with the strategy it was asked for", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2199,7 +2199,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2243,7 +2243,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ["query PullRequestMergeMessage", mergeMessage(body, queued)], ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2269,7 +2269,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2294,7 +2294,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("does not fetch a message for rebase merges", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2317,7 +2317,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const result = yield* Effect.result( cli.runPullRequestAction({ cwd: "/w", @@ -2336,7 +2336,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("arms auto-merge with the same strategy a merge would have used", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2366,7 +2366,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2387,7 +2387,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes auto-merge back off without naming a strategy", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2405,7 +2405,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("opens a pull request that reverts a merged pull request", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2441,7 +2441,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2458,7 +2458,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("finds and approves every workflow waiting on a maintainer", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2490,7 +2490,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const runs = yield* cli .listWorkflowRunsRequiringApproval({ @@ -2532,7 +2532,7 @@ layer("GitHubPullRequestCli.layer", (it) => { heads([7]), workflowRuns([10]), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.runPullRequestAction({ @@ -2568,7 +2568,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.as(output(encodeJson(workflowRuns([10])))), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const runs = yield* cli.listWorkflowRunsRequiringApproval({ cwd: "/w", @@ -2588,7 +2588,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses workflow approval when one head belongs to several pull requests", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.listWorkflowRunsRequiringApproval({ @@ -2636,7 +2636,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.runPullRequestAction({ @@ -2670,7 +2670,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.listWorkflowRunsRequiringApproval({ @@ -2701,7 +2701,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("returns a pull request to draft by converting it", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2719,7 +2719,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a comment body as a variable, never inside the document", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.commentOnPullRequest({ cwd: "/w", @@ -2743,7 +2743,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -2777,7 +2777,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listReviewThreadComments({ cwd: "/w", @@ -2794,7 +2794,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2823,7 +2823,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // GitHub answers 406 rather than a diff past 300 changed files. mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2846,7 +2846,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2866,7 +2866,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; const first = yield* cli.getPullRequestDiff(target); @@ -2884,7 +2884,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses a cursor it never handed out rather than reading it into a request", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -2906,7 +2906,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2933,7 +2933,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const target = { cwd: "/w", repository: "acme/web", @@ -2957,7 +2957,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses a commit that is not a sha rather than reading it into a request", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -2980,7 +2980,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const contents = yield* cli.getPullRequestDiffFileContents({ cwd: "/w", @@ -3003,7 +3003,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports unusable diff revisions as a structured error", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3030,7 +3030,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3058,7 +3058,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary๏ฟฝcontents", false, true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3084,7 +3084,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("before๏ฟฝafter"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const contents = yield* cli.getPullRequestDiffFileContents({ cwd: "/w", @@ -3103,7 +3103,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("ends the diff on a page with no files rather than asking for it again", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -3122,7 +3122,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -3139,7 +3139,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("skips the avatar lookup when a listing named nobody", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const avatars = yield* cli.listActorAvatars({ cwd: "/w", @@ -3172,7 +3172,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const avatars = yield* cli.listActorAvatars({ cwd: "/w", @@ -3190,7 +3190,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails when the authenticated account has no login", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); @@ -3203,7 +3203,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); @@ -3215,7 +3215,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reuses verified credentials offline and refuses an unverified replacement", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", host: "github.identity-cache.test" }; mockedCredential.mockImplementation((host) => Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), @@ -3263,7 +3263,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a whole review as one request", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.submitReview({ cwd: "/w", @@ -3294,7 +3294,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a reply body as a variable, never inside the document", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.replyToReviewThread({ cwd: "/w", @@ -3314,7 +3314,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("resolves and unresolves through the mutation each one needs", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewThreadResolution({ cwd: "/w", @@ -3343,7 +3343,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReaction({ cwd: "/w", @@ -3379,7 +3379,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.setReaction({ @@ -3402,7 +3402,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("looks up the pull request's own node id when no subject was given", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; // Its own pull request: a node id looked up once is remembered for the life of the service. yield* cli.setReaction({ @@ -3423,7 +3423,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes a reaction back through the remove mutation", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReaction({ cwd: "/w", @@ -3443,7 +3443,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("rewrites only the words a request named", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const rewrite = (fields: { readonly title?: string; readonly body?: string }) => cli.updatePullRequest({ cwd: "/w", @@ -3470,7 +3470,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("rewrites a remark through the mutation its kind needs", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const rewrite = (kind: "issue-comment" | "review-comment") => cli.updateComment({ cwd: "/w", @@ -3512,7 +3512,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.updateComment({ @@ -3537,7 +3537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails the read when gh returns something unreadable", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDetail({ @@ -3586,7 +3586,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -3664,7 +3664,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const detail = yield* cli.getPullRequestDetail({ cwd: "/w", repository: "acme/web", @@ -3720,7 +3720,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const detail = yield* cli.getPullRequestDetail({ cwd: "/w", repository: "acme/web", @@ -3768,7 +3768,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDetail({ cwd: "/w", @@ -3802,7 +3802,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -3822,7 +3822,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -3847,7 +3847,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const slice = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -3872,7 +3872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3902,7 +3902,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3929,7 +3929,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3954,7 +3954,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const page = yield* cli.getReviewThreadComments({ cwd: "/w", @@ -3983,7 +3983,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getReviewThreadComments({ @@ -4021,7 +4021,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const access = yield* cli.getViewerAccess({ cwd: "/w", @@ -4069,7 +4069,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -4134,7 +4134,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.getViewerAccess({ cwd: "/w", @@ -4165,7 +4165,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks GitHub to review, naming the collection a request is added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewerRequest({ cwd: "/w", @@ -4191,7 +4191,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes a request back by deleting from the same collection it was added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewerRequest({ cwd: "/w", @@ -4233,7 +4233,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const list = yield* cli.listReviewerCandidates({ cwd: "/w", @@ -4256,7 +4256,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("puts labels on by posting to the issue's own collection, all at once", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setLabels({ cwd: "/w", @@ -4280,7 +4280,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes labels off one at a time, naming each in the path encoded", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setLabels({ cwd: "/w", @@ -4329,7 +4329,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce(page(0, true)) .mockReturnValueOnce(page(1, true)) .mockReturnValueOnce(page(2, false)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const viewed = yield* cli.getPullRequestFilesViewed({ cwd: "/w", @@ -4377,7 +4377,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const viewed = yield* cli.getPullRequestFilesViewed({ cwd: "/w", @@ -4401,7 +4401,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4428,7 +4428,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks the host nothing when nothing was pressed", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4451,7 +4451,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; yield* cli.setPullRequestFilesViewed({ @@ -4485,7 +4485,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const write = () => cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4522,7 +4522,7 @@ layer("GitHubPullRequestCli.layer", (it) => { output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const tick = (number: number) => cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4534,7 +4534,7 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* tick(HOT); // A cache's worth of cold pull requests, with the open one pressed in between each of them. - for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { + for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { yield* tick(HOT + 1 + filled); yield* tick(HOT); } diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts similarity index 96% rename from apps/server/src/pullRequest/GitHubPullRequestCli.ts rename to apps/server/src/pullRequest/GitHubPullRequestApi.ts index ce944b931aa8..116a8a742d1a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,7 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; @@ -138,7 +138,6 @@ import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullReq export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubPullRequestReadError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, cause: Schema.Defect(), @@ -149,11 +148,10 @@ export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubViewerLoginUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -162,11 +160,10 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError()( "GitHubPullRequestUpdatedAtUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, repository: Schema.String, number: Schema.Int, @@ -181,7 +178,6 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCursorError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -194,7 +190,6 @@ export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCommitError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -207,7 +202,6 @@ export class GitHubDiffCommitError extends Schema.TaggedError()( "GitHubDiffRevisionsUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, commit: Schema.optional(Schema.String), @@ -224,7 +218,6 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError()( "GitHubDiffFileContentsUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, path: Schema.String, reason: Schema.Literals(["oversized", "binary"]), @@ -246,7 +239,6 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError()( "GitHubRepositorySelectorError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, }, @@ -260,7 +252,6 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError()( "GitHubSubjectScopeError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, }, @@ -274,7 +265,6 @@ export class GitHubSubjectScopeError extends Schema.TaggedError()( "GitHubWorkflowApprovalRefusedError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), @@ -297,7 +287,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, }, @@ -311,7 +300,6 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadChangedError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, }, @@ -321,7 +309,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError {} class PullRequestWatchFingerprintRead extends Request.Class< @@ -435,7 +423,7 @@ class PullRequestWatchFingerprintRead extends Request.Class< readonly number: number; }, GitHubPullRequestWatchFingerprint | null, - GitHubPullRequestCliError + GitHubPullRequestApiError > {} export interface GitHubPullRequestSearchBatch { @@ -460,8 +448,8 @@ export interface GitHubPullRequestFilesViewed { readonly truncated: boolean; } -export class GitHubPullRequestCli extends Context.Service< - GitHubPullRequestCli, +export class GitHubPullRequestApi extends Context.Service< + GitHubPullRequestApi, { readonly withVerifiedCredential: ( input: { readonly cwd: string; readonly host: string }, @@ -470,18 +458,18 @@ export class GitHubPullRequestCli extends Context.Service< readonly viewer: string; readonly credentialFingerprint: string; }) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; readonly getRoutingIdentity: (input: { readonly cwd: string; readonly host: string; }) => Effect.Effect< { readonly accountId: string; readonly viewer: string }, - GitHubPullRequestCliError + GitHubPullRequestApiError >; readonly getViewerLogin: (input: { readonly cwd: string; readonly host: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; @@ -497,7 +485,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor?: ProviderListCursor | undefined; /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** * The same listing for a whole host in one search. `limit` is the size of the slice across @@ -516,7 +504,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** The line counts the search leaves out, for rows already on the page. */ readonly listPullRequestStats: (input: { @@ -526,14 +514,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly number: number; }>; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; readonly getPullRequestSummary: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** * What a watch compares between passes, batched like summaries. Null when GitHub gave no * answer for this pull request, so the watch reads it in full instead. @@ -543,7 +531,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly revalidateChecks: Effect.Success; @@ -552,7 +540,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestPreview: (input: { readonly cwd: string; @@ -561,7 +549,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect< Omit, - GitHubPullRequestCliError + GitHubPullRequestApiError >; readonly listWorkflowRunsRequiringApproval: (input: { @@ -573,7 +561,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly headBranch: string; readonly headRepositoryOwner: string; readonly isCrossRepository: true; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; /** * The host-native stack this pull request is in, or null when it is in none โ€” which is also @@ -585,14 +573,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestActivity: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestDiff: (input: { readonly cwd: string; @@ -603,7 +591,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor?: string | undefined; /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestDiffFileContents: (input: { readonly cwd: string; @@ -616,7 +604,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly newPath: string; }) => Effect.Effect< { readonly oldContents: string; readonly newContents: string }, - GitHubPullRequestCliError + GitHubPullRequestApiError >; /** @@ -628,7 +616,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, @@ -640,14 +628,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; - }) => Effect.Effect; + }) => Effect.Effect; readonly listReviewThreadComments: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ readonly listActorAvatars: (input: { @@ -655,7 +643,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly ids: ReadonlyArray; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; readonly getReviewThreadComments: (input: { readonly cwd: string; @@ -664,7 +652,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly threadId: string; readonly cursor: string; - }) => Effect.Effect; + }) => Effect.Effect; /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ readonly getViewerAccess: (input: { @@ -674,7 +662,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; /** Manual action checks may use the quota held back from automatic reads. */ readonly allowReserve?: boolean | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** Who this pull request may be sent to, and who it has already been sent to. */ readonly listReviewerCandidates: (input: { @@ -682,7 +670,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly setReviewerRequest: (input: { readonly cwd: string; @@ -695,7 +683,7 @@ export class GitHubPullRequestCli extends Context.Service< }>; /** False deletes the same collection a request posts to, which takes the request back. */ readonly requested: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** The repository's labels, and which of them this pull request already wears. */ readonly listLabelCandidates: (input: { @@ -703,7 +691,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly setLabels: (input: { readonly cwd: string; @@ -713,7 +701,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly labels: ReadonlyArray; /** False takes each label off; true adds each to whatever is already there. */ readonly applied: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly runPullRequestAction: (input: { readonly cwd: string; @@ -726,7 +714,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly removeAgentCreditsOnMerge?: boolean; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; - }) => Effect.Effect; + }) => Effect.Effect; readonly commentOnPullRequest: (input: { readonly cwd: string; @@ -734,7 +722,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly submitReview: (input: { readonly cwd: string; @@ -744,7 +732,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly verdict: PullRequestReviewVerdict; readonly body: string; readonly comments: ReadonlyArray; - }) => Effect.Effect; + }) => Effect.Effect; readonly replyToReviewThread: (input: { readonly cwd: string; @@ -752,7 +740,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly threadId: string; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly setReviewThreadResolution: (input: { readonly cwd: string; @@ -760,7 +748,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly threadId: string; readonly resolved: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls @@ -776,7 +764,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly subjectId?: string | undefined; readonly content: PullRequestReactionContent; readonly reacted: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ readonly updatePullRequest: (input: { @@ -786,7 +774,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed @@ -801,9 +789,9 @@ export class GitHubPullRequestCli extends Context.Service< readonly commentId: string; readonly kind: "issue-comment" | "review-comment"; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; } ->()("t3/pullRequest/GitHubPullRequestCli") {} +>()("t3/pullRequest/GitHubPullRequestApi") {} /** * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. @@ -1134,10 +1122,9 @@ export const make = Effect.gen(function* () { }), ), ); - const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")( + const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( function* (input: { readonly cwd: string; readonly host: string }) { - const unavailable = () => - new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); + const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); // A missing or signed-out credential keeps its own error, so the page can say which. const { token, fingerprint: key } = yield* api.credential(host); @@ -1183,7 +1170,7 @@ export const make = Effect.gen(function* () { ); }, ); - const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = ( + const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( input, use, ) => @@ -1199,7 +1186,7 @@ export const make = Effect.gen(function* () { ), ), ); - const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) => + const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => captureVerifiedCredential(input).pipe( Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), ); @@ -1219,7 +1206,7 @@ export const make = Effect.gen(function* () { readonly host: string; readonly number: number; readonly operation: string; - }): Effect.Effect => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const key = `${input.host} ${owner}/${name} ${input.number}`; const held = nodeIds.get(key); @@ -1287,7 +1274,7 @@ export const make = Effect.gen(function* () { }) => api.graphql(input).pipe(Effect.asVoid); const readError = (cwd: string, operation: string, cause: unknown) => - new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); + new GitHubPullRequestReadError({ cwd, operation, cause }); /** Decodes an answer, reporting a failure against the read that made it. */ const decodeWith =
    ( @@ -1311,7 +1298,7 @@ export const make = Effect.gen(function* () { readonly variables?: Readonly>; readonly query: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + }): Effect.Effect => api .graphql({ host: input.host, @@ -1329,7 +1316,7 @@ export const make = Effect.gen(function* () { readonly operation: string; readonly path: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + }): Effect.Effect => api .rest({ host: input.host, operation: input.operation, path: input.path }) .pipe( @@ -1355,7 +1342,7 @@ export const make = Effect.gen(function* () { readonly number: number; readonly page: number; readonly commit?: string | undefined; - }): Effect.Effect => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; return api @@ -1407,11 +1394,11 @@ export const make = Effect.gen(function* () { ); }; - const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = + const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = (input) => Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); + return yield* new GitHubDiffCommitError({ cwd: input.cwd }); } const { owner, name } = parseRepositorySelector(input.repository); const refsResponse = yield* api.rest({ @@ -1444,7 +1431,6 @@ export const make = Effect.gen(function* () { !isCommitSha(headRef) ) { return yield* new GitHubDiffRevisionsUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, ...(input.commit === undefined ? {} : { commit: input.commit }), @@ -1469,7 +1455,6 @@ export const make = Effect.gen(function* () { response.truncated || response.body.includes("\0") || response.invalidUtf8 ? Effect.fail( new GitHubDiffFileContentsUnavailableError({ - command: "gh", cwd: input.cwd, path: filePath, reason: response.truncated ? "oversized" : "binary", @@ -1497,7 +1482,7 @@ export const make = Effect.gen(function* () { * mixing two revisions' checks. */ const readAllCheckContexts = ( - input: Parameters[0], + input: Parameters[0], allowReserve: boolean, ) => Effect.gen(function* () { @@ -1534,7 +1519,7 @@ export const make = Effect.gen(function* () { return { headSha, contexts, truncated: after !== null }; }); - const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); return AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => @@ -1659,7 +1644,7 @@ export const make = Effect.gen(function* () { return runs; }); - const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = + const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = (input) => Effect.all( [ @@ -1681,7 +1666,6 @@ export const make = Effect.gen(function* () { if (heads.length > workflowApprovalLimit) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "head-list-truncated", @@ -1693,7 +1677,6 @@ export const make = Effect.gen(function* () { if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "head-not-unique", @@ -1711,7 +1694,6 @@ export const make = Effect.gen(function* () { runs.length > workflowApprovalLimit ? Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "run-list-truncated", @@ -1830,7 +1812,7 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => Effect.request(new PullRequestSummaryRead(input), summaryResolver); // Every watched pull request on a host in one read per pass. A pull request the batch has no @@ -1884,10 +1866,10 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestWatchFingerprint: GitHubPullRequestCli["Service"]["getPullRequestWatchFingerprint"] = + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); - return GitHubPullRequestCli.of({ + return GitHubPullRequestApi.of({ withVerifiedCredential, revalidateChecks, getRoutingIdentity, @@ -1901,7 +1883,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "listPullRequests", }), @@ -1913,7 +1894,7 @@ export const make = Effect.gen(function* () { page: ( after: string | null, rows: number, - ) => Effect.Effect, + ) => Effect.Effect, ) => Effect.gen(function* () { const items: GitHubPullRequestListItem[] = []; @@ -1930,7 +1911,7 @@ export const make = Effect.gen(function* () { const read = ( continues: boolean, requestedRows = input.limit + 1, - ): Effect.Effect => + ): Effect.Effect => collect(requestedRows, (after, rows) => continues ? graphqlRead({ @@ -2047,7 +2028,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "searchPullRequests", }), @@ -2085,7 +2065,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "listPullRequestStats", }), @@ -2214,14 +2193,14 @@ export const make = Effect.gen(function* () { ...(input.commit === undefined ? {} : { commit: input.commit }), }); if (input.commit !== undefined && !isCommitSha(input.commit)) { - return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); + return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); } // A cursor only ever comes from the files walk, so a reader carrying one is already past // the point where `gh pr diff` had anything to say. if (input.cursor !== undefined) { const page = diffCursorPage(input.cursor); return page === null - ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) + ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) : filesPage(page); } // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. @@ -2283,7 +2262,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed({ comments, nextCursor }) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "getReviewThreadComments", }), @@ -2297,7 +2275,7 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); const threadPage = ( cursor: string | null, - ): Effect.Effect => + ): Effect.Effect => graphqlRead({ cwd: input.cwd, host: input.host, @@ -2522,7 +2500,6 @@ export const make = Effect.gen(function* () { if (detail.headSha == null || detail.headRepositoryOwner == null) { return Effect.fail( new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2547,7 +2524,6 @@ export const make = Effect.gen(function* () { if (current.headSha == null || current.headRepositoryOwner == null) { return Effect.fail( new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2562,7 +2538,6 @@ export const make = Effect.gen(function* () { ) { return Effect.fail( new GitHubWorkflowApprovalHeadChangedError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2737,7 +2712,7 @@ export const make = Effect.gen(function* () { after: string | null, collected: ReadonlyArray, pagesLeft: number, - ): Effect.Effect => + ): Effect.Effect => graphqlRead({ cwd: input.cwd, host: input.host, @@ -2799,7 +2774,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed(givenSubjectId) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "setReaction", }), @@ -2850,7 +2824,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed(input.commentId) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "updateComment", }), @@ -2871,4 +2844,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubPullRequestCli, make); +export const layer = Layer.effect(GitHubPullRequestApi, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 88079ebb94c7..f00cbfaf5d32 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,8 +7,8 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa let operations = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, withVerifiedCredential: (_input, use) => verificationFails ? Effect.fail( - new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({ - command: "gh", + new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ cwd: "/w", }), ) @@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () => ); const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.sync(() => { @@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => let summaryReads = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestSummary: () => Effect.sync(() => { @@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through" }; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), }), @@ -189,12 +188,11 @@ it.effect("reports a failed stack read against its own operation", () => Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestStack", cause: new Error("unreadable"), @@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => { } }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -541,7 +539,7 @@ it.effect( Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ getPullRequestDetail: () => Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), listWorkflowRunsRequiringApproval: () => @@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", expect(detail.checks).toEqual([]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), listWorkflowRunsRequiringApproval: () => @@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => Effect.fail( - new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ - command: "gh", + new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ cwd: "/w", number: 7, reason: "head-not-unique", @@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () => expect(error.reason).toBe("rate-limited"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => @@ -713,7 +710,7 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.die("Unexpected detail read"), getViewerAccess: () => Effect.sync(() => (accessReads++, access)), @@ -723,13 +720,13 @@ describe("getViewerPermissions", () => { }); const layerWithDetail = ( - detail: Effect.Effect, + detail: Effect.Effect, onDetail: (allowReserve: boolean) => void = () => {}, ) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubCli.AllowGitHubReserve.pipe( + GitHubRepositoryApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), @@ -789,12 +786,11 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestDetail", cause: new Error("head changed"), @@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => { }; const layerWith = (commits: GitHubReviewThreadComments["commits"]) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ @@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { viewer: { canUpdate: true, didAuthor: false }, }; const layerFor = (body: string) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), @@ -1040,7 +1036,7 @@ describe("editing", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), updateComment: (input) => Effect.sync(() => void rewrites.push(input)), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 74c5852a48a4..5e6f24207299 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,8 +7,8 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, @@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ export function gitHubProviderFailure( - error: GitHubPullRequestCli.GitHubPullRequestCliError, + error: GitHubPullRequestApi.GitHubPullRequestApiError, ): PullRequestProviderFailure { switch (error._tag) { case "GitHubCliMissingError": @@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean => body.replace(//g, "").trim().length === 0; export const make = Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => new PullRequestProviderError({ provider: "github", operation, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubCli.AllowGitHubReserve, true), + Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 60de91d0e99a..efbe37f991ee 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; @@ -61,11 +61,11 @@ export const make = Effect.map( export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provide( GitHubApi.layer.pipe( Layer.provide(GitHubCredentials.layer), - // The same layers GitHubCli merges, so both share one budget and one pause per host. + // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 3da0b9ba2e03..9e2b81d97d5f 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 8172bf544074..6975da251936 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -9,7 +9,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; -import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; const HeadSchema = Schema.Struct({ @@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { Pick & { workflowApprovalsRequired?: number; }, - GitHubPullRequestCliError + GitHubPullRequestApiError >, ) => Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a43..fdd7d6c7165f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubCli.layer), + Layer.provideMerge(GitHubRepositoryApi.layer), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts similarity index 91% rename from apps/server/src/sourceControl/GitHubCli.test.ts rename to apps/server/src/sourceControl/GitHubRepositoryApi.test.ts index 3954617008a5..688216211f1f 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts @@ -11,7 +11,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -51,7 +51,7 @@ const node = (number: number, headRefName: string, owner = "acme") => ({ }); /** - * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what + * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what * `git remote -v` prints; `git` records every driver call. */ function harness(input: { @@ -92,7 +92,10 @@ function harness(input: { args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), ), }); - const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + const layer = Layer.effect( + GitHubRepositoryApi.GitHubRepositoryApi, + GitHubRepositoryApi.make, + ).pipe( Layer.provide( Layer.mergeAll( driver, @@ -111,7 +114,7 @@ describe("selectGitHubBaseRepository", () => { .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) .join("\n"); const select = (input: { remotes: string; resolved?: string }) => - GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); it("picks the repository gh reads without a prompt", () => { assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { @@ -175,7 +178,7 @@ describe("selectGitHubBaseRepository", () => { }); }); -describe("GitHubCli repository resolution", () => { +describe("GitHubRepositoryApi repository resolution", () => { it.effect("reads the repository gh would pick from the remotes", () => { const paths: string[] = []; const { layer } = harness({ @@ -197,7 +200,7 @@ describe("GitHubCli repository resolution", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); }).pipe(Effect.provide(layer)); @@ -221,16 +224,19 @@ describe("GitHubCli repository resolution", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.getDefaultBranch({ cwd: "/repo" }); // A provider's host hint for the same alias (`github` here) resolves the same way. yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); assert.strictEqual( - GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), + GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), "github.example.com", ); - assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + assert.strictEqual( + GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), + null, + ); }).pipe(Effect.provide(layer)); }); @@ -240,15 +246,15 @@ describe("GitHubCli repository resolution", () => { api: {}, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubCliCommandError"); + assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); assert.include(String((error.cause as Error).message), "No GitHub repository"); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli.listPullRequestsByHead", () => { +describe("GitHubRepositoryApi.listPullRequestsByHead", () => { const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { @@ -271,7 +277,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const lookup = (headSelector: string) => gh .listPullRequestsByHead({ @@ -324,7 +330,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const lookups = yield* Effect.all( Array.from({ length: 26 }, (_, index) => gh.listPullRequestsByHead({ @@ -368,7 +374,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const open = yield* gh .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) .pipe(Effect.forkChild); @@ -397,24 +403,24 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const read = (headSelector: string) => gh .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) .pipe(Effect.flip, Effect.forkChild); const missing = yield* read("missing"); yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); + assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); const limited = yield* read("limited"); yield* TestClock.adjust("500 millis"); const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); assert.propertyVal(error, "retryAt", 123); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli.getPullRequest", () => { +describe("GitHubRepositoryApi.getPullRequest", () => { it.effect("reads a pull request by number, and by URL on its own repository", () => { const variables: Array = []; const { layer } = harness({ @@ -428,7 +434,7 @@ describe("GitHubCli.getPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); yield* gh.getPullRequest({ cwd: "/repo", @@ -449,14 +455,14 @@ describe("GitHubCli.getPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli writes", () => { +describe("GitHubRepositoryApi writes", () => { it.effect("creates a cross-repository pull request with an owner:branch head", () => { const requests: Array = []; const { layer } = harness({ @@ -476,7 +482,7 @@ describe("GitHubCli writes", () => { const fs = yield* FileSystem.FileSystem; const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.createPullRequest({ cwd: "/repo", baseBranch: "main", @@ -515,7 +521,7 @@ describe("GitHubCli writes", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const urls = yield* gh.createRepository({ cwd: "/repo", repository: "acme/new", @@ -531,7 +537,7 @@ describe("GitHubCli writes", () => { }); }); -describe("GitHubCli.checkoutPullRequest", () => { +describe("GitHubRepositoryApi.checkoutPullRequest", () => { const repository = (fullName: string, defaultBranch = "main") => restResponse({ full_name: fullName, @@ -551,7 +557,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); assert.deepStrictEqual(git, [ [ @@ -588,7 +594,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); // Nothing touched the local branches: `main` must not be reset to the fork's commit. assert.deepStrictEqual(git, []); @@ -609,7 +615,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); assert.deepStrictEqual(git, [ [ @@ -632,7 +638,7 @@ describe("GitHubCli.checkoutPullRequest", () => { it("names the local branch the way gh pr checkout does", () => { const name = (headRefName: string, isCrossRepository: boolean) => - GitHubCli.pullRequestCheckoutBranchName({ + GitHubRepositoryApi.pullRequestCheckoutBranchName({ headRefName, headOwner: "someone", isCrossRepository, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts similarity index 90% rename from apps/server/src/sourceControl/GitHubCli.ts rename to apps/server/src/sourceControl/GitHubRepositoryApi.ts index d12e5f334feb..595d869d7227 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.ts @@ -33,24 +33,23 @@ import { export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; -const gitHubCliFailureFields = { - command: Schema.Literal("gh"), +const gitHubFailureFields = { cwd: Schema.String, cause: Schema.Defect(), } as const; -export class GitHubCliUnavailableError extends Schema.TaggedError()( - "GitHubCliUnavailableError", - gitHubCliFailureFields, +export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( + "GitHubRepositoryUnavailableError", + gitHubFailureFields, ) { override get message(): string { return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; } } -export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliAuthenticationError", - gitHubCliFailureFields, +export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( + "GitHubRepositoryAuthenticationError", + gitHubFailureFields, ) { override get message(): string { // A missing or turned-off credential already says what to do about it. @@ -60,9 +59,9 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliRateLimitError", - { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, +export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( + "GitHubRepositoryRateLimitError", + { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, ) { override get message(): string { return "GitHub API rate limit exceeded. Requests resume when the limit resets."; @@ -71,16 +70,16 @@ export class GitHubCliRateLimitError extends Schema.TaggedError()( "GitHubPullRequestNotFoundError", - gitHubCliFailureFields, + gitHubFailureFields, ) { override get message(): string { return "Pull request not found. Check the PR number or URL and try again."; } } -export class GitHubCliCommandError extends Schema.TaggedError()( - "GitHubCliCommandError", - { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, +export class GitHubRepositoryCommandError extends Schema.TaggedError()( + "GitHubRepositoryCommandError", + { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, ) { override get message(): string { // GitHub's own reason ("A pull request already existsโ€ฆ") or the failed step's, when known. @@ -92,15 +91,14 @@ export class GitHubCliCommandError extends Schema.TaggedError()( "GitHubPullRequestListDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid pull request list."; @@ -109,7 +107,7 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( "GitHubChangeRequestListDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid change request list."; @@ -118,7 +116,7 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( "GitHubPullRequestDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid pull request."; @@ -127,53 +125,56 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError()( "GitHubRepositoryDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid repository."; } } -export const GitHubCliError = Schema.Union([ - GitHubCliUnavailableError, - GitHubCliAuthenticationError, - GitHubCliRateLimitError, +export const GitHubRepositoryApiError = Schema.Union([ + GitHubRepositoryUnavailableError, + GitHubRepositoryAuthenticationError, + GitHubRepositoryRateLimitError, GitHubPullRequestNotFoundError, - GitHubCliCommandError, + GitHubRepositoryCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, ]); -export type GitHubCliError = typeof GitHubCliError.Type; +export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; -export const isGitHubCliError = Schema.is(GitHubCliError); +export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); /** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError { - const context = { command: "gh" as const, cwd, cause: error }; +function fromGitHubApiError( + cwd: string, + error: GitHubApi.GitHubApiError, +): GitHubRepositoryApiError { + const context = { cwd, cause: error }; switch (error._tag) { case "GitHubCliMissingError": - return new GitHubCliUnavailableError(context); + return new GitHubRepositoryUnavailableError(context); case "GitHubNotSignedInError": case "GitHubHostDisabledError": case "GitHubApiAuthenticationError": - return new GitHubCliAuthenticationError(context); + return new GitHubRepositoryAuthenticationError(context); case "GitHubCliFailedError": - return new GitHubCliCommandError(context); + return new GitHubRepositoryCommandError(context); case "GitHubApiRateLimitError": - return new GitHubCliRateLimitError({ + return new GitHubRepositoryRateLimitError({ ...context, ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), }); case "SourceControlRateLimitPausedError": - return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt }); + return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); case "GitHubApiNotFoundError": return new GitHubPullRequestNotFoundError(context); case "GitHubApiResponseError": - return new GitHubCliCommandError({ ...context, httpStatus: error.status }); + return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); case "GitHubApiRequestError": - return new GitHubCliCommandError(context); + return new GitHubRepositoryCommandError(context); } } @@ -208,15 +209,15 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } -export class GitHubCli extends Context.Service< - GitHubCli, +export class GitHubRepositoryApi extends Context.Service< + GitHubRepositoryApi, { readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; readonly limit?: number; readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; + }) => Effect.Effect, GitHubRepositoryApiError>; /** * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the @@ -230,24 +231,24 @@ export class GitHubCli extends Context.Service< readonly limit: number; /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; + }) => Effect.Effect, GitHubRepositoryApiError>; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly rateLimitHost?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; + }) => Effect.Effect; readonly createPullRequest: (input: { readonly cwd: string; @@ -255,20 +256,20 @@ export class GitHubCli extends Context.Service< readonly headSelector: string; readonly title: string; readonly bodyFile: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; readonly rateLimitHost?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly force?: boolean; - }) => Effect.Effect; + }) => Effect.Effect; } ->()("t3/sourceControl/GitHubCli") {} +>()("t3/sourceControl/GitHubRepositoryApi") {} /** * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a @@ -385,7 +386,7 @@ class PullRequestsByHeadRead extends Request.Class< readonly allowReserve: boolean; }, ReadonlyArray, - GitHubCliError + GitHubRepositoryApiError > {} /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ @@ -549,7 +550,7 @@ export const make = Effect.gen(function* () { const gitRead = (cwd: string, args: ReadonlyArray) => process.run({ - operation: "GitHubCli.resolveRepository", + operation: "GitHubRepositoryApi.resolveRepository", command: "git", args, cwd, @@ -558,13 +559,13 @@ export const make = Effect.gen(function* () { }); const commandFailure = (cwd: string, detail: string) => - new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); + new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); /** * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else * the remote the caller resolved the provider from, else the best-ranked GitHub remote. */ - const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { + const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { readonly cwd: string; readonly host?: string | undefined; }) { @@ -623,7 +624,7 @@ export const make = Effect.gen(function* () { cwd: string, input: GitHubApi.GitHubGraphQlInput, decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubCliError, + onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, ) => api.graphql(input).pipe( Effect.mapError((error) => fromGitHubApiError(cwd, error)), @@ -646,7 +647,7 @@ export const make = Effect.gen(function* () { Pick >; readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubCliError; + readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; }) => { const query = buildPullRequestsByHeadQuery(input.lookups); return graphqlJson( @@ -668,7 +669,6 @@ export const make = Effect.gen(function* () { decoded.data.repository === null ? Effect.fail( new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("The repository could not be read."), }), @@ -697,8 +697,7 @@ export const make = Effect.gen(function* () { locator: { host, owner, name }, lookups: entries.map((entry) => entry.request), allowReserve, - onDecodeFailure: (cause) => - new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), + onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), }).pipe( Effect.map((aliases) => { for (const [index, entry] of entries.entries()) { @@ -724,7 +723,7 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), ); - const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { + const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { readonly cwd: string; readonly headSelector: string; readonly state: PullRequestListState; @@ -759,7 +758,7 @@ export const make = Effect.gen(function* () { const toSummaries = (rows: ReadonlyArray) => rows.map(pullRequestSummary); - const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { + const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { readonly cwd: string; readonly reference: string; readonly rateLimitHost?: string | undefined; @@ -787,7 +786,6 @@ export const make = Effect.gen(function* () { }))[0]; if (found === undefined) { return yield* new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("No pull request has this head branch."), }); @@ -799,7 +797,7 @@ export const make = Effect.gen(function* () { ? parsed.locator : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); + new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); const decoded = yield* graphqlJson( input.cwd, { @@ -815,7 +813,6 @@ export const make = Effect.gen(function* () { const node = decoded.data.repository?.pullRequest; if (node == null) { return yield* new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("The pull request does not exist."), }); @@ -825,7 +822,7 @@ export const make = Effect.gen(function* () { return record; }); - const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( + const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( cwd: string, locator: GitHubRepositoryLocator, ) { @@ -838,7 +835,6 @@ export const make = Effect.gen(function* () { const decoded = decodeRawRepository(response.body); if (Result.isFailure(decoded)) { return yield* new GitHubRepositoryDecodeError({ - command: "gh", cwd, cause: decoded.failure, }); @@ -846,23 +842,23 @@ export const make = Effect.gen(function* () { return decoded.success; }); - const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( + const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( cwd: string, host: string, ) { const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { - return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); + return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); } return decoded.success.login; }); const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubCliCommandError({ command: "gh", cwd, cause }); + new GitHubRepositoryCommandError({ cwd, cause }); const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); + git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); /** * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a @@ -870,8 +866,8 @@ export const make = Effect.gen(function* () { * track the head. A head branch that is gone is read from the base's `refs/pull//head`. * An existing branch fast-forwards, or with `force` is reset to the pull request. */ - const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubCli.checkoutPullRequest", + const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( + "GitHubRepositoryApi.checkoutPullRequest", )(function* (input) { const reference = parsePullRequestReference(input.reference); const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); @@ -994,7 +990,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(gitFailure(input.cwd))); }); - return GitHubCli.of({ + return GitHubRepositoryApi.of({ listPullRequestsByHead: (input) => AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), @@ -1043,7 +1039,6 @@ export const make = Effect.gen(function* () { const decoded = decodeRawRepository(response.body); if (Result.isFailure(decoded)) { return yield* new GitHubRepositoryDecodeError({ - command: "gh", cwd: input.cwd, cause: decoded.failure, }); @@ -1083,7 +1078,7 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make).pipe( +export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( Layer.provideMerge(GitHubApi.layer), Layer.provideMerge(GitHubCredentials.layer), Layer.provideMerge(GitHubGraphQlBudget.layer), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index f3f7f31ed4e3..89398b634786 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -27,12 +27,15 @@ const processResult = ( }); function makeProvider( - github: Partial, + github: Partial, api: Partial = {}, ) { return GitHubSourceControlProvider.make.pipe( Effect.provide( - Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), + Layer.merge( + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), + Layer.mock(GitHubApi.GitHubApi)(api), + ), ), ); } @@ -87,10 +90,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => +it.effect("adds safe request context while retaining GitHub API causes", () => Effect.gen(function* () { - const cause = new GitHubCli.GitHubPullRequestNotFoundError({ - command: "gh", + const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ cwd: "/repo", cause: new Error("raw upstream detail that should remain in the cause"), }); @@ -109,7 +111,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: error.provider, operation: error.operation, - command: error.command, cwd: error.cwd, reference: error.reference, detail: error.detail, @@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: "github", operation: "getChangeRequest", - command: "gh", cwd: "/repo", reference: "https://github.com/pingdotgg/t3code/pull/42", detail: "Pull request not found. Check the PR number or URL and try again.", @@ -130,8 +130,9 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => it.effect("lists change request history through the batched head lookup", () => Effect.gen(function* () { - let lookup: Parameters[0] | null = - null; + let lookup: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ listPullRequestsByHead: (input) => { lookup = input; @@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () => it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cf06c6efdfcd..a3b7a22f8d55 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as ServerSettings from "../serverSettings.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, @@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () { }); export const make = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; + const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = @@ -286,7 +286,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -325,7 +324,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -403,7 +401,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, @@ -428,7 +425,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -445,7 +441,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getRepositoryCloneUrls", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -462,7 +457,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createRepository", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -486,7 +480,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getDefaultBranch", - command: error.command, cwd: input.cwd, detail: error.message, cause: error, @@ -500,7 +493,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "checkoutChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 54de8cc14fde..d9bbb51d7a61 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 08751017b351..c9a7164c8b31 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -15,7 +15,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,7 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; + readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,7 +98,7 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index af3fc819a4c7..5f64ecb12f87 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), From a21ac5fb153e3bf418ccd2a4abef2787c241813c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:25 -0700 Subject: [PATCH 036/530] refactor(server): GitHub GraphQL batches use variables and share one pager (#16960) Co-authored-by: Claude Opus 5.5 (1M context) --- .../pullRequest/GitHubPullRequestApi.test.ts | 107 +++++-- .../src/pullRequest/GitHubPullRequestApi.ts | 268 +++++++++--------- .../pullRequest/gitHubPullRequestJson.test.ts | 55 ++-- .../src/pullRequest/gitHubPullRequestJson.ts | 165 ++++++----- .../src/pullRequest/githubStackActions.ts | 20 +- .../src/sourceControl/githubGraphQl.test.ts | 105 +++++++ .../server/src/sourceControl/githubGraphQl.ts | 100 +++++++ 7 files changed, 565 insertions(+), 255 deletions(-) create mode 100644 apps/server/src/sourceControl/githubGraphQl.test.ts create mode 100644 apps/server/src/sourceControl/githubGraphQl.ts diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index c42bf6d147bf..64259e337e92 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -767,11 +767,15 @@ layer("GitHubPullRequestApi.layer", (it) => { ); assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - const document = queryAt(0); - expect(document).toContain( - 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + expect(queryAt(0)).toContain( + "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", ); - expect(document).toContain("pullRequest(number: 8)"); + expect(varsAt(0)).toMatchObject({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_number: 8, + }); }), ); @@ -815,10 +819,10 @@ layer("GitHubPullRequestApi.layer", (it) => { // GitHub had no answer for #8, so its watch reads it in full. expect(eight).toBeNull(); expect(mockedExecute).toHaveBeenCalledOnce(); - const call = callAt(0); - expect(call.kind === "graphql" ? call.query : "").toContain( - 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', + expect(queryAt(0)).toContain( + "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", ); + expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 }); }), ); @@ -1108,6 +1112,37 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => + Effect.gen(function* () { + // Two pages of 100 that hand back the same cursor, for a page of 250. + const repeating = output( + encodeJson({ + data: { + search: { + pageInfo: { hasNextPage: true, endCursor: "same" }, + nodes: rows(100, 1), + }, + }, + }), + ); + mockedExecute.mockReturnValue(Effect.succeed(repeating)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 250, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.isTrue(batch.truncated); + }), + ); + it.effect("excludes merged pull requests from the Closed tab", () => Effect.gen(function* () { mockedExecute.mockImplementation((call) => @@ -1408,11 +1443,12 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(batch.continues).toBe(false); expect(mockedStackMemberships).toHaveBeenCalledTimes(1); const membership = mockedStackMemberships.mock.calls[0]?.[0]; - const query = membership?.kind === "graphql" ? membership.query : ""; - expect(query).toContain("pullRequest(number: 4)"); - expect(query).toContain("pullRequest(number: 5)"); - expect(query).not.toContain("pullRequest(number: 1)"); - expect(query).not.toContain("pullRequest(number: 6)"); + const numbers = Object.entries( + membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, + ) + .filter(([name]) => name.endsWith("_number")) + .map(([, number]) => number); + expect(numbers).toEqual([4, 5]); }), ); @@ -1420,7 +1456,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); mockedStackMemberships.mockImplementation((input) => - input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") + input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26) ? Effect.fail( new GitHubApi.GitHubApiResponseError({ host: "github.com", @@ -1513,9 +1549,8 @@ layer("GitHubPullRequestApi.layer", (it) => { { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, ]); - const document = queryAt(0); - expect(document).toContain('s0: repository(owner: "acme", name: "web")'); - expect(document).toContain("pullRequest(number: 25)"); + expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); + expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 }); }), ); @@ -2614,6 +2649,38 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("refuses workflow approval when the head list stops before its end", () => + Effect.gen(function* () { + // GitHub hands the same cursor back, so the second page is never read past. #7 looks + // unique on what was read, and an unread page could still hold another head like it. + const repeating = heads([7]); + repeating.data.repository.pullRequests.pageInfo = { + hasNextPage: true, + endCursor: "same", + } as never; + workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([])); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "head-list-truncated", + }); + }), + ); + it.effect("refuses workflow approval when GitHub omits the head repository", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -4386,9 +4453,9 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - assert.strictEqual(mockedExecute.mock.calls.length, 5); + // The same cursor twice is a page GitHub already gave, so reading stops there. + assert.strictEqual(mockedExecute.mock.calls.length, 2); assert.isTrue(viewed.truncated); - assert.strictEqual(viewed.files.length, 5); }), ); @@ -4420,8 +4487,8 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); expect(varsAt(1)).toEqual({ pullRequestId: "PR_1", - path0: "src/a.ts", - path1: "src/b.ts", + f0_path: "src/a.ts", + f1_path: "src/b.ts", }); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 116a8a742d1a..59281964df4a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -44,6 +44,7 @@ import { import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -64,7 +65,6 @@ import { PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, PULL_REQUEST_HEADS_GRAPHQL_QUERY, decodeWorkflowRunsJson, - type GitHubCheckContext, pullRequestCoreGraphQlQuery, type GitHubPullRequestCore, type GitHubPullRequestSummary, @@ -1481,42 +1481,45 @@ export const make = Effect.gen(function* () { * its first hundred. Each page names the head it read, so a push mid-walk fails rather than * mixing two revisions' checks. */ + type CheckContextsPage = Result.Result.Success< + ReturnType + >; const readAllCheckContexts = ( input: Parameters[0], allowReserve: boolean, ) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - const contexts: GitHubCheckContext[] = []; - let headSha: string | null = null; - let after: string | null = null; - for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { - const read: { - readonly headSha: string; - readonly contexts: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - allowReserve, - variables: { owner, name, number: input.number, after }, - query: pullRequestCheckContextsGraphQlQuery(input.host), - decode: decodePullRequestCheckContextsJson, - }); - if (headSha !== null && read.headSha !== headSha) { - return yield* readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ); - } - headSha = read.headSha; - contexts.push(...read.contexts); - after = read.nextCursor; - if (after === null) break; - } - return { headSha, contexts, truncated: after !== null }; + const { pages, truncated } = yield* readGraphQlPages( + (after, previous: ReadonlyArray) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }).pipe( + // Each page names the head it read, so a push mid-walk fails rather than mixing + // two revisions' checks. + Effect.filterOrFail( + (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + ), + { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, + ); + return { + headSha: pages[0]?.headSha ?? null, + contexts: pages.flatMap((page) => page.contexts), + truncated, + }; }); const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { @@ -1568,7 +1571,10 @@ export const make = Effect.gen(function* () { const workflowApprovalReadError = (cwd: string, cause: unknown) => readError(cwd, "listWorkflowRunsRequiringApproval", cause); - /** Every open pull request whose head branch carries this name, up to one past the limit. */ + /** + * Every open pull request whose head branch carries this name, up to one past the limit. + * `truncated` says some were never read, which is never enough to call a head unique. + */ const listHeadsByBranch = (input: { readonly cwd: string; readonly repository: string; @@ -1577,25 +1583,25 @@ export const make = Effect.gen(function* () { }) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - const heads: GitHubPullRequestHead[] = []; - let after: string | null = null; - do { - const page: { - readonly heads: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - allowReserve: true, - variables: { owner, name, head: input.headBranch, after }, - query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decode: decodePullRequestHeadsJson, - }); - heads.push(...page.heads); - after = page.nextCursor; - } while (after !== null && heads.length <= workflowApprovalLimit); - return heads; + const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => + pages.reduce((total, page) => total + page.heads.length, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + variables: { owner, name, head: input.headBranch, after }, + query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decode: decodePullRequestHeadsJson, + }), + { + nextCursor: (page) => page.nextCursor, + until: (pages) => countHeads(pages) > workflowApprovalLimit, + }, + ); + return { heads: pages.flatMap((page) => page.heads), truncated }; }); /** @@ -1650,9 +1656,10 @@ export const make = Effect.gen(function* () { [ listHeadsByBranch(input).pipe( Effect.flatMap( - ( + ({ heads, - ): Effect.Effect< + truncated, + }): Effect.Effect< GitHubPullRequestHead, GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError > => { @@ -1663,7 +1670,7 @@ export const make = Effect.gen(function* () { pullRequest.headRepositoryOwner?.toLowerCase() === input.headRepositoryOwner.toLowerCase(), ); - if (heads.length > workflowApprovalLimit) { + if (truncated || heads.length > workflowApprovalLimit) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ cwd: input.cwd, @@ -1755,18 +1762,18 @@ export const make = Effect.gen(function* () { ); // Stack membership rides along where GitHub serves stacks, so the background sync can skip // the REST stack read for pull requests that are in none. - const query = buildPullRequestSummariesGraphQlQuery( + const document = buildPullRequestSummariesGraphQlQuery( batchable.map((entry) => entry.request), first.request.host === "github.com", ); const batched = - query === null + document === null ? Effect.succeed(new Map()) : graphqlRead({ cwd: first.request.cwd, host: first.request.host, operation: "getPullRequestSummary", - query, + ...document, decode: decodePullRequestSummariesJson, }); return batched.pipe( @@ -1833,17 +1840,17 @@ export const make = Effect.gen(function* () { const batchable = entries.filter( (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, ); - const query = buildPullRequestWatchFingerprintsGraphQlQuery( + const document = buildPullRequestWatchFingerprintsGraphQlQuery( batchable.map((entry) => entry.request), ); const read = - query === null + document === null ? Effect.succeed(new Map()) : graphqlRead({ cwd: first.request.cwd, host: first.request.host, operation: "getPullRequestWatchFingerprint", - query, + ...document, decode: decodePullRequestWatchFingerprintsJson, }); return read.pipe( @@ -1897,16 +1904,22 @@ export const make = Effect.gen(function* () { ) => Effect.Effect, ) => Effect.gen(function* () { - const items: GitHubPullRequestListItem[] = []; - let rawCount = 0; - let after: string | null = null; - do { - const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); - items.push(...read.items); - rawCount += read.rawCount; - after = read.endCursor; - } while (after !== null && rawCount < rows); - return { items, rawCount }; + const countRows = (pages: ReadonlyArray) => + pages.reduce((total, read) => total + read.rawCount, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after, pages: ReadonlyArray) => + page(after, rows - countRows(pages)), + { + nextCursor: (read) => read.endCursor, + until: (pages) => countRows(pages) >= rows, + }, + ); + // `truncated` also covers a cursor GitHub repeated before `rows` arrived. + return { + items: pages.flatMap((read) => read.items), + rawCount: countRows(pages), + truncated, + }; }); const read = ( continues: boolean, @@ -1932,7 +1945,7 @@ export const make = Effect.gen(function* () { decode: decodePullRequestListJson, }), ).pipe( - Effect.flatMap(({ items: rawItems, rawCount }) => { + Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { const items = continues ? rawItems : rawItems.filter((item) => matchesUnsortedListing(item, input)); @@ -1949,9 +1962,11 @@ export const make = Effect.gen(function* () { items: items.slice(0, input.limit), // One row over the page size is the probe for a next page, and it is // counted before decoding: a skipped malformed row must not end paging. - truncated: continues - ? rawCount > input.limit - : items.length > input.limit || rawCount >= requestedRows, + truncated: + cutShort || + (continues + ? rawCount > input.limit + : items.length > input.limit || rawCount >= requestedRows), continues, }); }), @@ -1989,16 +2004,16 @@ export const make = Effect.gen(function* () { return Effect.forEach( chunks, (chunk) => { - const query = buildPullRequestStackMembershipsGraphQlQuery( + const document = buildPullRequestStackMembershipsGraphQlQuery( input.repository, chunk.map((item) => item.number), ); - if (query === null) return Effect.succeed(chunk); + if (document === null) return Effect.succeed(chunk); return graphqlRead({ cwd: input.cwd, host: input.host, operation: "listPullRequestStackMemberships", - query, + ...document, decode: decodePullRequestStackMembershipsJson, }).pipe( Effect.map((memberships) => @@ -2061,8 +2076,8 @@ export const make = Effect.gen(function* () { return Effect.forEach( chunks, (chunk) => { - const query = buildPullRequestStatsGraphQlQuery(chunk); - if (query === null) { + const document = buildPullRequestStatsGraphQlQuery(chunk); + if (document === null) { return Effect.fail( new GitHubRepositorySelectorError({ cwd: input.cwd, @@ -2074,7 +2089,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "listPullRequestStats", - query, + ...document, decode: decodePullRequestStatsJson, }).pipe( Effect.map((stats) => @@ -2328,23 +2343,26 @@ export const make = Effect.gen(function* () { // Almost never entered: the embedded page already holds every dismissal a pull request // ordinarily accrues. Followed so a review whose event fell past that page still finds // its reason. - let dismissalPage = 0; - while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { - const read: { - readonly dismissalsByReviewId: ReadonlyMap; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listReviewThreadComments", - variables: { owner, name, number: input.number, cursor: dismissalCursor }, - query: REVIEW_DISMISSALS_GRAPHQL_QUERY, - decode: decodeReviewDismissalsJson, - }); - for (const [id, message] of read.dismissalsByReviewId) - dismissalsByReviewId.set(id, message); - dismissalCursor = read.nextCursor; - dismissalPage += 1; + if (dismissalCursor !== null) { + const { pages } = yield* readGraphQlPages( + (cursor) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }), + { + from: dismissalCursor, + maxPages: REVIEW_THREAD_PAGES, + nextCursor: (read) => read.nextCursor, + }, + ); + for (const read of pages) + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); } const reviewThreads = entries.map((entry) => ({ @@ -2708,44 +2726,34 @@ export const make = Effect.gen(function* () { getPullRequestFilesViewed: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - const read = ( - after: string | null, - collected: ReadonlyArray, - pagesLeft: number, - ): Effect.Effect => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestFilesViewed", - variables: { owner, name, number: input.number, after }, - query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - decode: decodePullRequestFilesViewedJson, - }).pipe( - Effect.flatMap((page) => { - const files = [...collected, ...page.files]; - if (page.nextCursor === null) { - return Effect.succeed({ files, truncated: false }); - } - return pagesLeft <= 1 - ? Effect.succeed({ files, truncated: true }) - : read(page.nextCursor, files, pagesLeft - 1); + return readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: { owner, name, number: input.number, after }, + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, }), - ); - return read(null, [], FILES_VIEWED_MAX_PAGES); + { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, + ).pipe( + Effect.map(({ pages, truncated }) => ({ + files: pages.flatMap((page) => page.files), + truncated, + })), + ); }, setPullRequestFilesViewed: (input) => { - const mutation = buildSetFilesViewedGraphQlMutation(input.files); - if (mutation === null) return Effect.void; + if (input.files.length === 0) return Effect.void; return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "setPullRequestFilesViewed", - query: mutation.query, - variables: { pullRequestId, ...mutation.variables }, - }), - ), + Effect.flatMap((pullRequestId) => { + const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); + return mutation === null + ? Effect.void + : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); + }), ); }, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 3105634dc087..06e25c6e4709 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1754,36 +1754,40 @@ describe("decodePullRequestFilesViewedJson", () => { describe("buildSetFilesViewedGraphQlMutation", () => { it("asks for nothing when nothing was pressed", () => { - expect(buildSetFilesViewedGraphQlMutation([])).toBeNull(); + expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); }); it("clears and restores in one document, each file under its own alias", () => { - const mutation = buildSetFilesViewedGraphQlMutation([ + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ { path: "src/a.ts", viewed: true }, { path: "src/b.ts", viewed: false }, ]); expect(mutation).not.toBeNull(); if (mutation === null) return; expect(mutation.query).toContain( - "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)", + "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", ); expect(mutation.query).toContain( - "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })", + "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", ); expect(mutation.query).toContain( - "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })", + "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", ); - expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" }); + expect(mutation.variables).toEqual({ + pullRequestId: "PR_1", + f0_path: "src/a.ts", + f1_path: "src/b.ts", + }); }); it("keeps a path out of the document, so one cannot be read as part of it", () => { - const mutation = buildSetFilesViewedGraphQlMutation([ + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, ]); expect(mutation).not.toBeNull(); if (mutation === null) return; expect(mutation.query).not.toContain("evil"); - expect(mutation.variables.path0).toBe( + expect(mutation.variables.f0_path).toBe( '") { __typename } evil: markFileAsViewed(input: { path: "x', ); }); @@ -1922,9 +1926,14 @@ describe("pull request stack membership batches", () => { expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( - "pullRequest(number: 8)", - ); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_owner: "acme", + s1_name: "web", + s1_number: 8, + }); }); }); @@ -1982,10 +1991,10 @@ describe("batched pull request summaries", () => { it("reads stack membership only where the document asked for it", () => { expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true), + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, ).toContain("stack { number size baseRefName } stackEntry { position }"); expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }]), + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, ).not.toContain("stack {"); const pullRequest = (number: number, stack: Record) => ({ pullRequest: { @@ -2019,16 +2028,22 @@ describe("batched pull request summaries", () => { describe("pull request watch fingerprints", () => { it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { - const query = buildPullRequestWatchFingerprintsGraphQlQuery([ + const document = buildPullRequestWatchFingerprintsGraphQlQuery([ { repository: "pingdotgg/t3code", number: 7 }, { repository: "pingdotgg/lakebed", number: 8 }, ]); - expect(query).toContain( - 'w0: repository(owner: "pingdotgg", name: "t3code") { pullRequest(number: 7)', - ); - expect(query).toContain( - 'w1: repository(owner: "pingdotgg", name: "lakebed") { pullRequest(number: 8)', - ); + expect(document?.query).toContain( + "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", + ); + expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); + expect(document?.variables).toEqual({ + w0_owner: "pingdotgg", + w0_name: "t3code", + w0_number: 7, + w1_owner: "pingdotgg", + w1_name: "lakebed", + w1_number: 8, + }); expect( buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), ).toBeNull(); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index ce89cf6fe44a..028231c513f2 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -35,6 +35,7 @@ import type { import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** @@ -1917,53 +1918,81 @@ function toStackMembership( : undefined; } -/** What a repository selector may hold before it is written into a GraphQL document unquoted. */ +/** What GitHub allows in an owner or repository name. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; +/** + * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is + * one document, and a single unanswerable alias fails all of it, so these are refused up front. + */ +function repositoryParts( + repository: string, +): { readonly owner: string; readonly name: string } | null { + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; +} + +const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; + +/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ +function aliasedPullRequestsDocument( + name: string, + alias: string, + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, + selection: string, +): GraphQlDocument | null { + const addressed = changeRequests.map((changeRequest) => ({ + parts: repositoryParts(changeRequest.repository), + number: changeRequest.number, + })); + if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { + return null; + } + return aliasedGraphQlDocument({ + operation: "query", + name, + alias, + items: addressed, + variables: ({ parts, number }) => ({ + owner: ["String!", parts!.owner], + name: ["String!", parts!.name], + number: ["Int!", number], + }), + field: ({ owner, name, number }) => + `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, + }); +} + /** * The line counts for rows a listing already handed over, as one aliased lookup each. * * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds โ€” a - * repository and a number โ€” and never sees a node id. Owner, name and number are written into - * the document, so each is checked against what GitHub can actually name first: null for anything - * else, which the caller reports rather than sends. - * - * Null too for an empty request, since a GraphQL document with no selection is not a document. + * repository and a number โ€” and never sees a node id. Null for a selector GitHub cannot name, + * which the caller reports rather than sends, and for an empty request. */ export function buildPullRequestStatsGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { additions deletions } }`, - ); - } - return `query {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStats", + "s", + changeRequests, + "additions deletions", + ); } /** Stack membership for the visible rows of a per-repository listing. */ export function buildPullRequestStackMembershipsGraphQlQuery( repository: string, numbers: ReadonlyArray, -): string | null { - if (numbers.length === 0) return null; - const [owner, name, ...rest] = repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - const selections: string[] = []; - for (const [index, number] of numbers.entries()) { - if (!Number.isSafeInteger(number) || number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, - ); - } - return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStackMemberships", + "s", + numbers.map((number) => ({ repository, number })), + STACK_MEMBERSHIP_SELECTION, + ); } const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); @@ -2024,26 +2053,17 @@ const PULL_REQUEST_SUMMARY_SELECTION = "latestReviews(first: 20) { nodes { state author { login } } } " + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; -/** - * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written - * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". - */ +/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ export function buildPullRequestSummariesGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, includeStacks = false, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }`, - ); - } - return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestSummaries", + "s", + changeRequests, + `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, + ); } /** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ @@ -2170,19 +2190,13 @@ const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = /** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ export function buildPullRequestWatchFingerprintsGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` w${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_WATCH_FINGERPRINT_SELECTION} } }`, - ); - } - return `query PullRequestWatchFingerprints {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestWatchFingerprints", + "w", + changeRequests, + PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, + ); } /** @@ -3357,25 +3371,20 @@ export function decodePullRequestFilesViewedJson( * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each * take a single path, so the batching is done with aliases; top-level mutation fields run in * write order, so the last word about a path is the one that sticks. - * - * Paths travel as variables rather than interpolated into the document, since a path is data - * and a document is not. */ export function buildSetFilesViewedGraphQlMutation( + pullRequestId: string, files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, -): { readonly query: string; readonly variables: Readonly> } | null { - if (files.length === 0) return null; - const parameters = files.map((_, index) => `$path${index}: String!`).join(", "); - const fields = files - .map( - (file, index) => - ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`, - ) - .join("\n"); - return { - query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`, - variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])), - }; +): GraphQlDocument | null { + return aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: files, + shared: { pullRequestId: ["ID!", pullRequestId] }, + variables: (file) => ({ path: ["String!", file.path] }), + field: ({ path }, file) => + `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, + }); } /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts index 59d29627b81b..c2e8fadb751d 100644 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -9,6 +9,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts"; import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; import { cascadeRebaseStack } from "./githubStackRebase.ts"; @@ -192,13 +193,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* host: input.host, operation: "runGitHubStackAction", allowReserve: true, - variables: { owner, name }, - query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open - .map( - (layer) => - `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, - ) - .join(" ")}}}`, + // `open` is non-empty here, so there is always a document. + ...aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (layer) => layer.number, + items: open, + shared: { owner: ["String!", owner], name: ["String!", name] }, + variables: (layer) => ({ number: ["Int!", layer.number] }), + field: ({ number }) => + `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, + within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, + })!, }); const access = yield* decodeBranchAccess(permissions).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), diff --git a/apps/server/src/sourceControl/githubGraphQl.test.ts b/apps/server/src/sourceControl/githubGraphQl.test.ts new file mode 100644 index 000000000000..9ab8f9354493 --- /dev/null +++ b/apps/server/src/sourceControl/githubGraphQl.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; + +describe("aliasedGraphQlDocument", () => { + it("keeps every value out of the document, each under its alias's own variable", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + name: "Lookups", + alias: "s", + items: ['acme") { x } #', "web"], + shared: { owner: ["String!", "acme"] }, + variables: (repo) => ({ name: ["String!", repo] }), + field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, + }); + assert.isNotNull(document); + expect(document!.query).toBe( + "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + + " s0: repository(owner: $owner, name: $s0_name) { id }\n" + + " s1: repository(owner: $owner, name: $s1_name) { id }\n}", + ); + expect(document!.variables).toEqual({ + owner: "acme", + s0_name: 'acme") { x } #', + s1_name: "web", + }); + }); + + it("aliases by key and nests inside a shared parent when asked", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (number) => number, + items: [2, 3], + variables: (number) => ({ number: ["Int!", number] }), + field: ({ number }) => `pullRequest(number: ${number}) { id }`, + within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, + }); + expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); + expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); + expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); + }); + + it("asks for nothing when there is nothing to ask", () => { + expect( + aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: [], + variables: () => ({}), + field: () => "x", + }), + ).toBeNull(); + }); +}); + +describe("readGraphQlPages", () => { + const pages = (cursors: ReadonlyArray) => { + const asked: Array = []; + const read = (after: string | null) => + Effect.sync(() => { + asked.push(after); + return { next: cursors[asked.length - 1] ?? null }; + }); + return { asked, read }; + }; + + it.effect("follows the cursor until GitHub has no more", () => + Effect.gen(function* () { + const { asked, read } = pages(["a", "b", null]); + const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); + expect(asked).toEqual([null, "a", "b"]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => + Effect.gen(function* () { + const limited = pages(["a", "b", "c"]); + const atLimit = yield* readGraphQlPages(limited.read, { + nextCursor: (page) => page.next, + maxPages: 2, + }); + expect(limited.asked).toEqual([null, "a"]); + expect(atLimit.truncated).toBe(true); + + const enough = pages(["a", "b", "c"]); + const satisfied = yield* readGraphQlPages(enough.read, { + nextCursor: (page) => page.next, + until: (read) => read.length === 1, + }); + expect(enough.asked).toEqual([null]); + expect(satisfied.truncated).toBe(true); + + const stuck = pages(["a", "a", "a"]); + const repeated = yield* readGraphQlPages(stuck.read, { + nextCursor: (page) => page.next, + from: "start", + }); + expect(stuck.asked).toEqual(["start", "a"]); + expect(repeated.truncated).toBe(true); + }), + ); +}); diff --git a/apps/server/src/sourceControl/githubGraphQl.ts b/apps/server/src/sourceControl/githubGraphQl.ts new file mode 100644 index 000000000000..0b1e3ca65844 --- /dev/null +++ b/apps/server/src/sourceControl/githubGraphQl.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; + +/** GraphQL variables by name, each with the type it is declared as and the value it carries. */ +export type GraphQlVariables = Readonly>; + +export interface GraphQlDocument { + readonly query: string; + readonly variables: Readonly>; +} + +/** + * One document asking the same field for many items, each under its own alias: the prefix and + * the item's position (`s0`, `s1`, โ€ฆ), or its `key` when the answer is easier read by one. GitHub + * has no bulk form of most lookups, so aliases are how a batch travels in one request. + * + * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those + * placeholders rather than the values: nothing an item holds is written into the document. + * `shared` variables are declared once and referenced by their own name; `within` nests the aliased + * fields inside one parent selection, such as a single repository. + * + * Null for no items, since a document with no selection is not a document. + */ +export function aliasedGraphQlDocument(input: { + readonly operation: "query" | "mutation"; + /** The operation's name, which tells one batch from another in logs and recorded traffic. */ + readonly name?: string; + readonly alias: string; + readonly key?: (item: Item, index: number) => number; + readonly items: ReadonlyArray; + readonly variables: (item: Item) => Variables; + readonly field: ( + placeholders: { readonly [Name in keyof Variables]: string }, + item: Item, + ) => string; + readonly shared?: GraphQlVariables; + readonly within?: (fields: string) => string; +}): GraphQlDocument | null { + if (input.items.length === 0) return null; + const declarations: string[] = []; + const variables: Record = {}; + for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { + declarations.push(`$${name}: ${type}`); + variables[name] = value; + } + const fields = input.items.map((item, index) => { + const alias = `${input.alias}${input.key?.(item, index) ?? index}`; + const placeholders: Record = {}; + for (const [name, [type, value]] of Object.entries(input.variables(item))) { + const variable = `${alias}_${name}`; + declarations.push(`$${variable}: ${type}`); + variables[variable] = value; + placeholders[name] = `$${variable}`; + } + return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; + }); + const selection = fields.join("\n"); + const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; + return { + query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, + variables, + }; +} + +/** + * The pages of a GraphQL connection, read one after another from `from` (the first page by + * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is + * handed the pages so far, for reads that size or check the next page by what has already arrived. + * + * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time + * would page forever, so it stops the read as truncated too. + */ +export const readGraphQlPages = ( + read: (after: string | null, pages: ReadonlyArray) => Effect.Effect, + options: { + readonly nextCursor: (page: NoInfer) => string | null; + readonly from?: string | null; + readonly maxPages?: number; + readonly until?: (pages: ReadonlyArray>) => boolean; + }, +): Effect.Effect<{ readonly pages: ReadonlyArray; readonly truncated: boolean }, E, R> => + Effect.gen(function* () { + const pages: Page[] = []; + const seen = new Set(); + let after = options.from ?? null; + while (true) { + const page: Page = yield* read(after, pages); + pages.push(page); + const next = options.nextCursor(page); + if (next === null) return { pages, truncated: false }; + if ( + seen.has(next) || + pages.length >= (options.maxPages ?? Infinity) || + options.until?.(pages) === true + ) { + return { pages, truncated: true }; + } + seen.add(next); + after = next; + } + }); From 42d92ec59775c187af69071c81b2b91180810bdd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:25 -0700 Subject: [PATCH 037/530] refactor(server): GitHub source control reads GitHubApi directly (#16982) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/src/git/GitManager.test.ts | 280 ++--- .../ThreadPullRequestService.ts | 2 +- .../pullRequest/GitHubPullRequestApi.test.ts | 2 +- .../src/pullRequest/GitHubPullRequestApi.ts | 3 +- .../GitHubPullRequestProvider.test.ts | 3 +- .../pullRequest/GitHubPullRequestProvider.ts | 4 +- .../PullRequestProviderRegistry.ts | 11 +- .../src/pullRequest/PullRequestService.ts | 2 +- apps/server/src/server.ts | 6 +- apps/server/src/sourceControl/GitHubApi.ts | 10 + .../src/sourceControl/GitHubCredentials.ts | 4 - .../sourceControl/GitHubRepositoryApi.test.ts | 651 ---------- .../src/sourceControl/GitHubRepositoryApi.ts | 1086 ----------------- .../GitHubSourceControlProvider.test.ts | 768 +++++++++--- .../GitHubSourceControlProvider.ts | 911 +++++++++++--- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 5 +- .../gitHubRepositoryResolution.test.ts | 87 ++ .../gitHubRepositoryResolution.ts | 190 +++ apps/server/src/ws.ts | 4 +- 21 files changed, 1746 insertions(+), 2291 deletions(-) delete mode 100644 apps/server/src/sourceControl/GitHubRepositoryApi.test.ts delete mode 100644 apps/server/src/sourceControl/GitHubRepositoryApi.ts create mode 100644 apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts create mode 100644 apps/server/src/sourceControl/gitHubRepositoryResolution.ts diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index a8d2f9d986d1..b91196f28f84 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f30af2c23c5b..1ce25b4106be 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -24,6 +24,7 @@ import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { + ChangeRequest, GitActionProgressEvent, GitManagerServiceError, GitPreparePullRequestThreadInput, @@ -37,12 +38,11 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + SourceControlProviderError as SourceControlProviderFailure, TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; import { ForgejoPullRequestSchema, @@ -92,7 +91,10 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; + /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */ + failWith?: string; + /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */ + failCause?: unknown; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary( - raw: unknown, -): GitHubRepositoryApi.GitHubPullRequestSummary | null { +type FakePullRequestSummary = Omit & { + readonly state?: ChangeRequest["state"]; + readonly updatedAt?: string; +}; + +function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -383,8 +388,12 @@ function createTextGeneration( }; } -function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; +/** + * A GitHub source control provider over a fake `gh`. The fake still speaks gh's command shapes, + * which is what `ghCalls` records and the tests assert on; the provider methods translate to them. + */ +function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { + service: SourceControlProvider["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - // The fake still speaks in gh's command shapes; the service methods below translate to them. + const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => + new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); + + /** `operation` is the provider method the call answers, which a failure reports. */ const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + readonly operation: string; + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { - return Effect.fail(scenario.failWith); + return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation)); } if (args[0] === "pr" && args[1] === "list") { @@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { } return fakeGhOutput(""); }, - catch: (error) => - GitHubRepositoryApi.isGitHubRepositoryApiError(error) - ? error - : new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: error, - }), + catch: (error) => fail(input.cwd, "GitHub request failed.", error), }); } @@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository lookup: ${repository}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`)); } return Effect.succeed( fakeGhOutput( @@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected gh command: ${args.join(" ")}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`)); }; + /** What GitHubSourceControlProvider makes of a pull request it read. */ + const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ + ...summary, + provider: "github", + state: summary.state ?? "open", + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), + }); + return { service: { - // The fake answers the CLI shape, so batched lookups read it the way the fallback does. - listPullRequestsByHead: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - input.state, - "--limit", - String(input.limit), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) return []; - const decoded = decodeGitHubPullRequestListJson(raw); - return Result.isSuccess(decoded) ? decoded.success : []; - }), - ), - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), - Effect.map((raw) => - raw - .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter( - (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + kind: "github", + listChangeRequests: (input) => + input.state === "open" + ? execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is FakePullRequestSummary => entry !== null) + .map(toChangeRequest), ), - ), - ), - createPullRequest: (input) => + ) + : // The fake answers the CLI shape, so batched lookups read it the way the fallback does. + execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return []; + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) + : []; + }), + ), + createChangeRequest: (input) => execute({ + operation: "createChangeRequest", cwd: input.cwd, args: [ "pr", "create", "--base", - input.baseBranch, + input.baseRefName, "--head", input.headSelector, "--title", @@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ + operation: "getDefaultBranch", cwd: input.cwd, args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( @@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return value.length > 0 ? value : null; }), ), - getPullRequest: (input) => + getChangeRequest: (input) => execute({ + operation: "getChangeRequest", cwd: input.cwd, args: [ "pr", @@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + Effect.map((result) => + toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary), ), ), getRepositoryCloneUrls: (input) => execute({ + operation: "getRepositoryCloneUrls", cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => - Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository create: ${input.repository}`), - }), - ), - checkoutPullRequest: (input) => + Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)), + checkoutChangeRequest: (input) => execute({ + operation: "checkoutChangeRequest", cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), @@ -693,7 +707,7 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh( input?.ghScenario, ); const textGeneration = createTextGeneration(input?.textGeneration); @@ -729,10 +743,7 @@ function makeManager(input?: { ); const layerSourceControlRegistry = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - (input?.sourceControlProvider === undefined - ? GitHubSourceControlProvider.make - : Effect.succeed(input.sourceControlProvider) - ).pipe( + Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), @@ -742,12 +753,6 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide( - Layer.merge( - Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), - Layer.mock(GitHubApi.GitHubApi)({}), - ), - ), ), ); @@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); yield* manager.remoteStatus({ cwd: repoDir }); @@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ - cwd: repoDir, - cause: new Error(upstreamCause), - }), + failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.", + failCause: new Error(upstreamCause), }, }); const logs: Array<{ message: string; annotations: Record }> = []; @@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ - cwd: repoDir, - cause: new Error("gh is not authenticated"), - }), + failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", }, }); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index c1e0cff2658f..cd88d9397776 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one + // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 64259e337e92..20fc1dd88526 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 59281964df4a..5e3572915460 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,6 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () { const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return AllowGitHubReserve.pipe( + return GitHubApi.AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => graphqlRead({ allowReserve, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index f00cbfaf5d32..df4f7da4cd2c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; @@ -726,7 +725,7 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubRepositoryApi.AllowGitHubReserve.pipe( + GitHubApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5e6f24207299..f2672dbe991f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,7 +7,7 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), + Effect.provideService(GitHubApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index efbe37f991ee..316cd1e7ae92 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; @@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( GitHubPullRequestApi.layer.pipe( Layer.provide( - GitHubApi.layer.pipe( - Layer.provide(GitHubCredentials.layer), - // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. - Layer.provide(GitHubGraphQlBudget.layer), - Layer.provide(SourceControlRateLimit.layer), - ), + // Built here too, so a registry built alone still has one budget and pause per host. + GitHubApi.layerWithDependencies, ), ), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9e2b81d97d5f..a44bd322adcb 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fdd7d6c7165f..192b17204aea 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubRepositoryApi.layer), + Layer.provideMerge(GitHubApi.layerWithDependencies), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 01aa1c9ccfcf..0da53e0230b1 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -598,3 +598,13 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(GitHubApi, make); + +/** + * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one budget and one pause per host. + */ +export const layerWithDependencies = layer.pipe( + Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index fb043d809c68..d47d57ea7ffe 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -81,10 +81,6 @@ export type GitHubCredentialUnavailableError = | GitHubHostDisabledError | GitHubCliFailedError; -export const isGitHubCredentialUnavailableError = Schema.is( - Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]), -); - /** * Where GitHub tokens come from. Callers ask per host and never see how the token was found, * so another source (an in-app OAuth login) slots in here without touching any of them. diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts deleted file mode 100644 index 688216211f1f..000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { assert, it, describe } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as TestClock from "effect/testing/TestClock"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(exitCode), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const remotesOutput = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - -const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ - status, - headers: {}, - body: body === undefined ? "" : encodeJson(body), - truncated: false, - invalidUtf8: false, -}); - -const node = (number: number, headRefName: string, owner = "acme") => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - baseRefName: "main", - headRefName, - state: "OPEN", - isCrossRepository: owner !== "acme", - updatedAt: "2026-01-02T00:00:00Z", - headRepository: { name: "web", nameWithOwner: `${owner}/web` }, - headRepositoryOwner: { login: owner }, -}); - -/** - * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what - * `git remote -v` prints; `git` records every driver call. - */ -function harness(input: { - readonly remotes: string; - readonly api: Partial; - readonly localBranches?: ReadonlyArray; -}) { - const git: Array = []; - const record = - (name: string, value: A) => - (args: unknown) => - Effect.sync(() => { - git.push([name, args]); - return value; - }); - const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: (args) => - Effect.sync(() => { - git.push(["execute", args.args]); - return processOutput(""); - }), - resolvePrimaryRemoteName: () => Effect.succeed("origin"), - readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), - ensureRemote: (args) => - Effect.sync(() => { - git.push(["ensureRemote", args]); - return args.preferredName; - }), - fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), - setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), - switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, - listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), - resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), - }); - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (args) => - Effect.succeed( - args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), - ), - }); - const layer = Layer.effect( - GitHubRepositoryApi.GitHubRepositoryApi, - GitHubRepositoryApi.make, - ).pipe( - Layer.provide( - Layer.mergeAll( - driver, - process, - Layer.mock(GitHubApi.GitHubApi)(input.api), - NodeServices.layer, - ), - ), - ); - return { layer, git }; -} - -describe("selectGitHubBaseRepository", () => { - const remotes = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - const select = (input: { remotes: string; resolved?: string }) => - GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); - - it("picks the repository gh reads without a prompt", () => { - assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { - owner: "me", - name: "web", - }); - const fork = remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/Acme/Web.git"], - ); - assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); - assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { - owner: "me", - name: "web", - }); - assert.deepStrictEqual( - select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), - { owner: "acme", name: "other" }, - ); - // gh ranks remote names in any case. - assert.deepStrictEqual( - select({ - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Upstream", "git@github.com:acme/web.git"], - ), - }), - { owner: "acme", name: "web" }, - ); - }); - - it("leaves gh to choose when it might weigh the remotes differently", () => { - for (const input of [ - { remotes: "" }, - { - remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["mirror", "git@gitlab.com:me/web.git"], - ), - }, - { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Origin", "git@github.com:acme/web.git"], - ), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", - }, - ]) { - assert.strictEqual(select(input), null); - } - }); -}); - -describe("GitHubRepositoryApi repository resolution", () => { - it.effect("reads the repository gh would pick from the remotes", () => { - const paths: string[] = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - paths.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "trunk", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); - assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads an SSH alias remote through github.com", () => { - const hosts: string[] = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github:acme/web.git"]), - api: { - rest: (input) => - Effect.sync(() => { - hosts.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "main", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.getDefaultBranch({ cwd: "/repo" }); - // A provider's host hint for the same alias (`github` here) resolves the same way. - yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); - assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), - "github.example.com", - ); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), - null, - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails clearly when no remote is on GitHub", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), - api: {}, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); - assert.include(String((error.cause as Error).message), "No GitHub repository"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.listPullRequestsByHead", () => { - const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); - - it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { - const documents: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - documents.push(input); - return encodeJson({ - data: { - repository: { - h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, - h1: { nodes: [] }, - }, - }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookup = (headSelector: string) => - gh - .listPullRequestsByHead({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }) - .pipe(Effect.forkChild); - // Each branch's own git reads come first, so a sweep's lookups arrive spread out. - const firstLookup = yield* lookup("feature/a"); - yield* TestClock.adjust("200 millis"); - const secondLookup = yield* lookup("feature/b"); - yield* TestClock.adjust("300 millis"); - const first = yield* Fiber.join(firstLookup); - const second = yield* Fiber.join(secondLookup); - assert.deepStrictEqual( - first?.map((pr) => pr.number), - [7], - ); - assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); - assert.deepStrictEqual(second, []); - assert.strictEqual(documents.length, 1); - assert.include(documents[0]!.query, "headRefOid"); - assert.deepStrictEqual(documents[0]!.variables, { - owner: "acme", - name: "web", - h0: "feature/a", - s0: ["OPEN", "CLOSED", "MERGED"], - h1: "feature/b", - s1: ["OPEN", "CLOSED", "MERGED"], - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("caps a background document at twenty-five heads", () => { - const headCounts: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); - headCounts.push(heads.length); - return encodeJson({ - data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookups = yield* Effect.all( - Array.from({ length: 26 }, (_, index) => - gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: `feature/${index}`, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(lookups); - assert.deepStrictEqual( - headCounts.toSorted((a, b) => a - b), - [1, 25], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("matches an owner:branch selector on the head owner", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.succeed( - encodeJson({ - data: { - repository: { - h0: { - nodes: - input.variables?.h0 === "main" - ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] - : [], - }, - }, - }, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const open = yield* gh - .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("50 millis"); - assert.deepStrictEqual( - (yield* Fiber.join(open)).map((pr) => pr.number), - [8], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps API failures onto the errors callers handle", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.fail( - input.variables?.h0 === "missing" - ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) - : new GitHubApi.GitHubApiRateLimitError({ - host: "github.com", - operation: "x", - retryAt: 123, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const read = (headSelector: string) => - gh - .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) - .pipe(Effect.flip, Effect.forkChild); - const missing = yield* read("missing"); - yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); - const limited = yield* read("limited"); - yield* TestClock.adjust("500 millis"); - const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); - assert.propertyVal(error, "retryAt", 123); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.getPullRequest", () => { - it.effect("reads a pull request by number, and by URL on its own repository", () => { - const variables: Array = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: (input) => - Effect.sync(() => { - variables.push(input.variables); - return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); - yield* gh.getPullRequest({ - cwd: "/repo", - reference: "https://github.com/other/thing/pull/42", - }); - assert.deepStrictEqual(variables, [ - { owner: "acme", name: "web", number: 42 }, - { owner: "other", name: "thing", number: 42 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails a missing pull request as not found", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi writes", () => { - it.effect("creates a cross-repository pull request with an owner:branch head", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - requests.push(input); - return restResponse({ number: 1 }, 201); - }), - }, - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); - yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "me:feature", - title: "Title", - bodyFile, - }); - assert.strictEqual(requests[0]!.method, "POST"); - assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - assert.deepStrictEqual(requests[0]!.body, { - base: "main", - head: "me:feature", - title: "Title", - body: "Body", - maintainer_can_modify: true, - }); - }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); - }); - - it.effect("creates a repository under an organization the viewer is not", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: "", - api: { - rest: (input) => - Effect.sync(() => { - requests.push(`${input.method ?? "GET"} ${input.path}`); - return input.path === "user" - ? restResponse({ login: "me" }) - : restResponse({ - full_name: "acme/new", - html_url: "https://github.com/acme/new", - ssh_url: "git@github.com:acme/new.git", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const urls = yield* gh.createRepository({ - cwd: "/repo", - repository: "acme/new", - visibility: "private", - }); - assert.deepStrictEqual(urls, { - nameWithOwner: "acme/new", - url: "https://github.com/acme/new", - sshUrl: "git@github.com:acme/new.git", - }); - assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.checkoutPullRequest", () => { - const repository = (fullName: string, defaultBranch = "main") => - restResponse({ - full_name: fullName, - html_url: `https://github.com/${fullName}`, - ssh_url: `git@github.com:${fullName}.git`, - default_branch: defaultBranch, - }); - - it.effect("checks a same-repository pull request out from its head branch", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); - assert.deepStrictEqual(git, [ - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], - ["switchRef", { cwd: "/repo", refName: "feature/x" }], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: () => - Effect.fail( - new GitHubApi.GitHubApiRequestError({ - host: "github.com", - operation: "x", - cause: "offline", - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); - // Nothing touched the local branches: `main` must not be reset to the fork's commit. - assert.deepStrictEqual(git, []); - }).pipe(Effect.provide(layer)); - }); - - it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["someone/main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: (input) => - Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); - assert.deepStrictEqual(git, [ - [ - "ensureRemote", - { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, - ], - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, - ], - ["switchRef", { cwd: "/repo", refName: "someone/main" }], - ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it("names the local branch the way gh pr checkout does", () => { - const name = (headRefName: string, isCrossRepository: boolean) => - GitHubRepositoryApi.pullRequestCheckoutBranchName({ - headRefName, - headOwner: "someone", - isCrossRepository, - defaultBranch: "main", - }); - assert.strictEqual(name("main", true), "someone/main"); - assert.strictEqual(name("feature", true), "feature"); - assert.strictEqual(name("main", false), "main"); - }); -}); diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts deleted file mode 100644 index 595d869d7227..000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.ts +++ /dev/null @@ -1,1086 +0,0 @@ -import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -import { - decodeGitHubPullRequestEntries, - type NormalizedGitHubPullRequestRecord, -} from "./gitHubPullRequests.ts"; - -export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; - -const gitHubFailureFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( - "GitHubRepositoryUnavailableError", - gitHubFailureFields, -) { - override get message(): string { - return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; - } -} - -export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( - "GitHubRepositoryAuthenticationError", - gitHubFailureFields, -) { - override get message(): string { - // A missing or turned-off credential already says what to do about it. - return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) - ? this.cause.message - : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; - } -} - -export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( - "GitHubRepositoryRateLimitError", - { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, -) { - override get message(): string { - return "GitHub API rate limit exceeded. Requests resume when the limit resets."; - } -} - -export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( - "GitHubPullRequestNotFoundError", - gitHubFailureFields, -) { - override get message(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } -} - -export class GitHubRepositoryCommandError extends Schema.TaggedError()( - "GitHubRepositoryCommandError", - { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, -) { - override get message(): string { - // GitHub's own reason ("A pull request already existsโ€ฆ") or the failed step's, when known. - const reason = - this.cause instanceof Error && this.cause.message.trim() !== "" - ? this.cause.message.trim() - : null; - return reason === null ? "GitHub request failed." : reason; - } -} - -const gitHubDecodeFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( - "GitHubPullRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request list."; - } -} - -export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( - "GitHubChangeRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid change request list."; - } -} - -export class GitHubPullRequestDecodeError extends Schema.TaggedError()( - "GitHubPullRequestDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request."; - } -} - -export class GitHubRepositoryDecodeError extends Schema.TaggedError()( - "GitHubRepositoryDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid repository."; - } -} - -export const GitHubRepositoryApiError = Schema.Union([ - GitHubRepositoryUnavailableError, - GitHubRepositoryAuthenticationError, - GitHubRepositoryRateLimitError, - GitHubPullRequestNotFoundError, - GitHubRepositoryCommandError, - GitHubPullRequestListDecodeError, - GitHubChangeRequestListDecodeError, - GitHubPullRequestDecodeError, - GitHubRepositoryDecodeError, -]); -export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; - -export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); - -/** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError( - cwd: string, - error: GitHubApi.GitHubApiError, -): GitHubRepositoryApiError { - const context = { cwd, cause: error }; - switch (error._tag) { - case "GitHubCliMissingError": - return new GitHubRepositoryUnavailableError(context); - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - case "GitHubApiAuthenticationError": - return new GitHubRepositoryAuthenticationError(context); - case "GitHubCliFailedError": - return new GitHubRepositoryCommandError(context); - case "GitHubApiRateLimitError": - return new GitHubRepositoryRateLimitError({ - ...context, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }); - case "SourceControlRateLimitPausedError": - return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); - case "GitHubApiNotFoundError": - return new GitHubPullRequestNotFoundError(context); - case "GitHubApiResponseError": - return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); - case "GitHubApiRequestError": - return new GitHubRepositoryCommandError(context); - } -} - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly headSha?: string; - readonly state?: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt?: string; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { - const { updatedAt, ...summary } = input; - return { - ...summary, - ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), - }; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class GitHubRepositoryApi extends Context.Service< - GitHubRepositoryApi, - { - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - /** - * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the - * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive - * together share one GraphQL document. - */ - readonly listPullRequestsByHead: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit: number; - /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitHubRepositoryApi") {} - -/** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * fall back to the provider's remote, then to the best-ranked remote on the host. - */ -export function selectGitHubBaseRepository(input: { - readonly remotes: string; - readonly resolved: string; - readonly host: string; -}): { readonly owner: string; readonly name: string } | null { - const host = input.host.toLowerCase(); - const repositories = new Map(); - for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); - if (!match) continue; - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(match[1]!, { owner, name }); - } - const marks = input.resolved - .split("\n") - .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); - if (marks.length > 1) return null; - const [mark] = marks; - if (mark) { - if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; - const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); - return owner && name && rest.length === 0 ? { owner, name } : null; - } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. - const score = (remoteName: string) => - ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; - const ranked = [...repositories.entries()].toSorted( - ([left], [right]) => score(right) - score(left), - ); - const [top, next] = ranked; - return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) - ? top[1] - : null; -} - -const RawRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - html_url: TrimmedNonEmptyString, - ssh_url: TrimmedNonEmptyString, - default_branch: Schema.optional(Schema.NullOr(Schema.String)), -}); -const decodeRawRepository = decodeJsonResult(RawRepositorySchema); - -function repositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; -} - -const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); - -type PullRequestListState = "open" | "closed" | "merged" | "all"; - -/** The pull request fields every read selects, in GraphQL. */ -const PULL_REQUEST_NODE_SELECTION = - "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; -const GRAPHQL_STATES: Record> = { - open: ["OPEN"], - closed: ["CLOSED"], - merged: ["MERGED"], - all: ["OPEN", "CLOSED", "MERGED"], -}; -/** - * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * single lookup, but half that keeps each answer near half a second. - */ -const HEAD_LOOKUPS_PER_DOCUMENT = 50; -/** - * How long a head lookup waits for company. Branch discovery reaches GitHub only after each - * branch's own git reads, so lookups started together arrive tens of milliseconds apart. - * A background sweep's lookups spread over up to ~300ms, and every document costs a point no - * matter how few heads it holds, so reads without reserve wait longer. - */ -const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; -const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; -/** - * Background documents fill up under the longer window, and a failed document fails every head - * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own - * processing limit; twenty-five took ~7s. - */ -const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; -/** A full document is 5,000 rows of well under 2 KB each. */ -const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; -/** - * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch - * named like a busy default (`main`) is the case this bounds; the owner's own row is near the - * top because the newest come first. - */ -const OWNER_HEAD_SCAN_LIMIT = 100; - -class PullRequestsByHeadRead extends Request.Class< - { - readonly cwd: string; - readonly host: string; - readonly owner: string; - readonly name: string; - readonly headRefName: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly allowReserve: boolean; - }, - ReadonlyArray, - GitHubRepositoryApiError -> {} - -/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ -function buildPullRequestsByHeadQuery( - lookups: ReadonlyArray>, -): { readonly document: string; readonly variables: Record } { - const variables: Record = {}; - const declarations: string[] = ["$owner: String!", "$name: String!"]; - const selections: string[] = []; - for (const [index, lookup] of lookups.entries()) { - variables[`h${index}`] = lookup.headRefName; - variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; - declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); - // `gh pr list` orders the same way, so a head with more matches than the limit keeps the - // same rows. - selections.push( - ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, - ); - } - return { - document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, - variables, - }; -} - -const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } - } -}`; - -const decodePullRequestsByHead = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), - ), - ), - }), - }), -); - -const decodePullRequestByNumber = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), - }), - }), -); - -/** A repository on a GitHub host: the API it is read through, and its owner and name. */ -export interface GitHubRepositoryLocator { - readonly host: string; - readonly owner: string; - readonly name: string; -} - -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ -function parseGitHubRepositorySelector( - selector: string, - defaultHost: string, -): GitHubRepositoryLocator | null { - const trimmed = selector.trim().replace(/\.git$/i, ""); - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); - return owner && name && rest.length === 0 - ? { host: url.host.toLowerCase(), owner, name } - : null; - } catch { - return null; - } - } - const parts = trimmed.split("/").filter(Boolean); - if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; - if (parts.length === 3) - return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; - return null; -} - -/** - * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request - * URL, or a branch name. - */ -function parsePullRequestReference( - reference: string, -): - | { readonly kind: "number"; readonly number: number } - | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } - | { readonly kind: "branch"; readonly headSelector: string } { - const trimmed = reference.trim(); - const numbered = /^#?([1-9]\d*)$/.exec(trimmed); - if (numbered) return { kind: "number", number: Number(numbered[1]) }; - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); - if (match) { - return { - kind: "url", - locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, - number: Number(match[3]), - }; - } - } catch { - // Not a URL after all; read it as a branch. - } - } - return { kind: "branch", headSelector: trimmed }; -} - -/** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). - */ -export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); - // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. - if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { - return host.includes("github") ? "github.com" : null; - } - return provider.kind === "github" ? host : null; -} - -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ -function apiHostForHint(host: string): string { - const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; -} - -/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ -export function pullRequestCheckoutBranchName(input: { - readonly headRefName: string; - readonly headOwner: string | null; - readonly isCrossRepository: boolean; - readonly defaultBranch: string | null; -}): string { - // gh prefixes the owner only where the fork's branch would take over the default branch's - // name, which is the one collision every fork pull request from `main` would hit. - return input.isCrossRepository && - input.headOwner !== null && - input.defaultBranch !== null && - input.headRefName === input.defaultBranch - ? `${input.headOwner}/${input.headRefName}` - : input.headRefName; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const api = yield* GitHubApi.GitHubApi; - const git = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - - const gitRead = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubRepositoryApi.resolveRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); - - const commandFailure = (cwd: string, detail: string) => - new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); - - /** - * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else - * the remote the caller resolved the provider from, else the best-ranked GitHub remote. - */ - const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { - readonly cwd: string; - readonly host?: string | undefined; - }) { - const envRepository = environment.GH_REPO?.trim(); - const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); - if (envRepository) { - const locator = parseGitHubRepositorySelector(envRepository, defaultHost); - if (locator !== null) return locator; - } - const [remotes, resolved] = yield* Effect.all([ - gitRead(input.cwd, ["remote", "-v"]), - gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); - const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; - const fetchRemotes = remoteOutput - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ - name: match[1]!, - url: match[2]!, - host: gitHubApiHostForRemote(match[2]!), - })); - const host = - (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? - fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? - fetchRemotes.find((remote) => remote.host !== null)?.host ?? - defaultHost; - const selected = selectGitHubBaseRepository({ - remotes: remoteOutput, - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - host, - }); - if (selected !== null) return { host, ...selected }; - // gh's own order without its prompt: upstream, github, origin, then the first remote, among - // the ones on this host (an SSH alias counts as its API host). - const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); - const candidates = fetchRemotes - .filter((remote) => remote.host === host) - .toSorted((left, right) => { - const l = rank(left.name); - const r = rank(right.name); - return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); - }); - for (const remote of candidates) { - const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); - if (owner && name && rest.length === 0) return { host, owner, name }; - } - return yield* commandFailure( - input.cwd, - `No GitHub repository on ${host} was found among this checkout's git remotes.`, - ); - }); - - const graphqlJson = ( - cwd: string, - input: GitHubApi.GitHubGraphQlInput, - decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, - ) => - api.graphql(input).pipe( - Effect.mapError((error) => fromGitHubApiError(cwd, error)), - Effect.flatMap((raw) => { - const decoded = decode(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(onDecodeFailure(decoded.failure)); - }), - ); - - const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => - api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); - - /** Pull requests whose head branch is `headRefName` on one repository. */ - const readPullRequestsByHead = (input: { - readonly cwd: string; - readonly locator: GitHubRepositoryLocator; - readonly lookups: ReadonlyArray< - Pick - >; - readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; - }) => { - const query = buildPullRequestsByHeadQuery(input.lookups); - return graphqlJson( - input.cwd, - { - host: input.locator.host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, - allowReserve: input.allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. - maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, - }, - decodePullRequestsByHead, - input.onDecodeFailure, - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail( - new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The repository could not be read."), - }), - ) - : Effect.succeed(decoded.data.repository), - ), - ); - }; - - const headResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - [ - request.host, - request.owner, - request.name, - String(request.allowReserve), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? "", - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ].join("\0"), - resolver: (entries) => { - const [first] = entries; - const { cwd, host, owner, name, allowReserve } = first.request; - return readPullRequestsByHead({ - cwd, - locator: { host, owner, name }, - lookups: entries.map((entry) => entry.request), - allowReserve, - onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), - }).pipe( - Effect.map((aliases) => { - for (const [index, entry] of entries.entries()) { - const alias = aliases[`h${index}`]; - entry.completeUnsafe( - Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); - const interactiveHeadResolver = headResolver.pipe( - RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), - ); - const backgroundHeadResolver = headResolver.pipe( - RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), - RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), - ); - - const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly rateLimitHost?: string | undefined; - readonly allowReserve: boolean; - }) { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); - // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the - // owner is matched on the rows it returns. - const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); - const rows = yield* Effect.request( - new PullRequestsByHeadRead({ - cwd: input.cwd, - ...locator, - headRefName, - state: input.state, - limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, - allowReserve: input.allowReserve, - }), - input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, - ); - if (!ownerMatch) return rows; - const headOwner = ownerMatch[1]!.toLowerCase(); - return rows - .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) - .slice(0, limit); - }); - - const toSummaries = (rows: ReadonlyArray) => - rows.map(pullRequestSummary); - - const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string | undefined; - }) { - const parsed = parsePullRequestReference(input.reference); - if (parsed.kind === "branch") { - // `gh pr view ` prefers an open pull request, then the newest of any state. - const [open] = yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "open", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }); - const found = - open ?? - (yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "all", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }))[0]; - if (found === undefined) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("No pull request has this head branch."), - }); - } - return found; - } - const locator = - parsed.kind === "url" - ? parsed.locator - : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); - const decoded = yield* graphqlJson( - input.cwd, - { - host: locator.host, - operation: "getPullRequest", - query: PULL_REQUEST_BY_NUMBER_QUERY, - variables: { owner: locator.owner, name: locator.name, number: parsed.number }, - allowReserve: true, - }, - decodePullRequestByNumber, - decodeFailure, - ); - const node = decoded.data.repository?.pullRequest; - if (node == null) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The pull request does not exist."), - }); - } - const [record] = decodeGitHubPullRequestEntries([node]); - if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); - return record; - }); - - const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( - cwd: string, - locator: GitHubRepositoryLocator, - ) { - const response = yield* rest(cwd, { - host: locator.host, - operation: "getRepository", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, - allowReserve: true, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd, - cause: decoded.failure, - }); - } - return decoded.success; - }); - - const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( - cwd: string, - host: string, - ) { - const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); - const decoded = decodeViewerLogin(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); - } - return decoded.success.login; - }); - - const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubRepositoryCommandError({ cwd, cause }); - - const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); - - /** - * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a - * fork gets a remote of its own), checked out under the name gh would give it, and set to - * track the head. A head branch that is gone is read from the base's `refs/pull//head`. - * An existing branch fast-forwards, or with `force` is reset to the pull request. - */ - const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubRepositoryApi.checkoutPullRequest", - )(function* (input) { - const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); - const base = - reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); - const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); - const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; - const isCrossRepository = - pullRequest.isCrossRepository ?? - (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); - const headOwner = - pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; - - const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( - Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), - Effect.mapError(gitFailure(input.cwd)), - ); - const remoteFor = (nameWithOwner: string) => - remotes - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .find( - (match) => - match !== null && - normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), - )?.[1] ?? null; - const baseRemote = Effect.suspend(() => { - const known = remoteFor(baseNameWithOwner); - return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); - }); - // A fork's branch named like the base's default branch is checked out under the owner's - // prefix. Without the default branch that collision cannot be ruled out, and the checkout - // would reset the local default branch to the fork's commit, so it fails instead. - const defaultBranch = isCrossRepository - ? yield* readRepository(input.cwd, base).pipe( - Effect.map((repository) => repository.default_branch ?? null), - ) - : null; - const localBranch = pullRequestCheckoutBranchName({ - headRefName: pullRequest.headRefName, - headOwner, - isCrossRepository, - defaultBranch, - }); - - /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ - const headRemote = Effect.gen(function* () { - if (!isCrossRepository) return yield* baseRemote; - if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); - const known = remoteFor(headNameWithOwner); - if (known !== null) return known; - const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository(input.cwd, { - host: base.host, - owner: owner!, - name: name!, - }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); - return yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: headOwner ?? "fork", - url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, - }); - }); - - const exists = (yield* git - .listLocalBranchNames(input.cwd) - .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); - - // The commit to check out, fetched from the head branch where it still exists. - const target = yield* Effect.gen(function* () { - const remoteName = yield* headRemote; - yield* git.fetchRemoteTrackingBranch({ - cwd: input.cwd, - remoteName, - remoteBranch: pullRequest.headRefName, - }); - return { - ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, - upstream: { remoteName, remoteBranch: pullRequest.headRefName }, - }; - }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* runGit(input.cwd, "fetchPullRef", [ - "fetch", - "--quiet", - "--no-tags", - yield* baseRemote, - `refs/pull/${pullRequest.number}/head`, - ]); - const { commitSha } = yield* git.resolveCommit({ - cwd: input.cwd, - revision: "FETCH_HEAD", - }); - return { ref: commitSha, upstream: null }; - }), - ), - Effect.mapError(gitFailure(input.cwd)), - ); - - yield* Effect.gen(function* () { - if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); - yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - if (exists) { - yield* runGit( - input.cwd, - "sync", - input.force === true - ? ["reset", "--hard", "--quiet", target.ref] - : ["merge", "--ff-only", "--quiet", target.ref], - ); - } - // Tracking is set once the branch is the pull request's, so a sync that fails leaves an - // existing branch's upstream as it was. - if (target.upstream !== null) { - yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); - } - }).pipe(Effect.mapError(gitFailure(input.cwd))); - }); - - return GitHubRepositoryApi.of({ - listPullRequestsByHead: (input) => - AllowGitHubReserve.pipe( - Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), - ), - listOpenPullRequests: (input) => - listByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: "open", - limit: input.limit ?? 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }).pipe(Effect.map(toSummaries)), - getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( - Effect.map((locator) => locator.host), - Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), - )).toLowerCase(); - const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); - if (locator === null) { - return yield* commandFailure(input.cwd, "Repositories are named owner/name."); - } - return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); - }), - createRepository: (input) => - Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); - const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); - const owner = locator?.owner ?? viewer; - const name = locator?.name ?? input.repository.trim(); - const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); - const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); - const response = yield* rest(input.cwd, { - host, - operation: "createRepository", - method: "POST", - path: - isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, - body: { name, private: input.visibility === "private" }, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd: input.cwd, - cause: decoded.failure, - }); - } - return repositoryCloneUrls(decoded.success); - }), - createPullRequest: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd }); - const body = yield* fileSystem - .readFileString(input.bodyFile) - .pipe(Effect.mapError(gitFailure(input.cwd))); - yield* rest(input.cwd, { - host: locator.host, - operation: "createPullRequest", - method: "POST", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, - // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. - // gh allows maintainer edits unless told otherwise; the API's default is not documented. - body: { - base: input.baseBranch, - head: input.headSelector, - title: input.title, - body, - maintainer_can_modify: true, - }, - }); - }), - getDefaultBranch: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const repository = yield* readRepository(input.cwd, locator); - const branch = repository.default_branch?.trim() ?? ""; - return branch.length > 0 ? branch : null; - }), - checkoutPullRequest, - }); -}); - -export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( - Layer.provideMerge(GitHubApi.layer), - Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), - Layer.provideMerge(SourceControlRateLimit.layer), -); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 89398b634786..b70bfff78f6b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,17 +1,23 @@ -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const processResult = ( stdout: string, options?: { @@ -26,191 +32,611 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider( - github: Partial, - api: Partial = {}, -) { - return GitHubSourceControlProvider.make.pipe( - Effect.provide( - Layer.merge( - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), - Layer.mock(GitHubApi.GitHubApi)(api), - ), - ), - ); -} +const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const remotesOutput = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); -const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ - status: 200, +const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ + status, headers: {}, - body, + body: body === undefined ? "" : encodeJson(body), truncated: false, invalidUtf8: false, }); -it.effect("maps GitHub PR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getPullRequest: () => - Effect.succeed({ - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }), +const node = (number: number, headRefName: string, owner = "acme") => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + isCrossRepository: owner !== "acme", + updatedAt: "2026-01-02T00:00:00Z", + headRepository: { name: "web", nameWithOwner: `${owner}/web` }, + headRepositoryOwner: { login: owner }, +}); + +/** + * The provider over a mocked GitHubApi, git driver and process. `remotes` is what + * `git remote -v` prints; `git` records every driver call. + */ +function harness(input: { + readonly remotes: string; + readonly api: Partial; + readonly localBranches?: ReadonlyArray; + /** Fails the local branch listing, the first git read a checkout makes. */ + readonly gitFailure?: unknown; +}) { + const git: Array = []; + const record = + (name: string, value: A) => + (args: unknown) => + Effect.sync(() => { + git.push([name, args]); + return value; + }); + const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (args) => + Effect.sync(() => { + git.push(["execute", args.args]); + return processOutput(""); + }), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), + ensureRemote: (args) => + Effect.sync(() => { + git.push(["ensureRemote", args]); + return args.preferredName; + }), + fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), + setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), + switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, + listLocalBranchNames: () => + input.gitFailure === undefined + ? Effect.succeed([...(input.localBranches ?? [])]) + : Effect.fail(input.gitFailure as never), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), + }); + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (args) => + Effect.succeed( + args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), + ), + }); + const layer = Layer.mergeAll( + driver, + process, + Layer.mock(GitHubApi.GitHubApi)(input.api), + NodeServices.layer, + ); + return { layer, git }; +} + +/** The provider context for a checkout whose remote is on `host`. */ +/** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ +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}` }, + remoteName: "origin", + remoteUrl: `git@${host}:acme/web.git`, +}); + +describe("GitHubSourceControlProvider repository resolution", () => { + it.effect("reads the repository gh would pick from the remotes", () => { + const paths: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + paths.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "trunk", + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); + assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", + it.effect("reads an SSH alias remote through github.com", () => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github:acme/web.git"]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "main", + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.getDefaultBranch({ cwd: "/repo" }); + // A provider's host hint for the same alias (`github` here) resolves the same way. + yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual(changeRequest, { - provider: "github", - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - closedAt: null, - mergedAt: null, - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", + it.effect("names a missing repository rather than a pull request", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), + api: { + rest: (input) => + Effect.fail( + new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), + ), + }, }); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.include(error.detail, "Repository not found"); + }).pipe(Effect.provide(layer)); + }); -it.effect("adds safe request context while retaining GitHub API causes", () => - Effect.gen(function* () { - const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), + it.effect("fails clearly when no remote is on GitHub", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), + api: {}, }); - const provider = yield* makeProvider({ - getPullRequest: () => Effect.fail(cause), + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "SourceControlProviderError"); + assert.include(error.detail, "No GitHub repository"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider change request lookups by head", () => { + const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); + + it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { + const documents: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + documents.push(input); + return encodeJson({ + data: { + repository: { + h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, + h1: { nodes: [] }, + }, + }, + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const lookup = (headSelector: string) => + gh + .listChangeRequests({ + cwd: "/repo", + headSelector, + state: "all", + limit: 100, + context: githubContext("github.com"), + }) + .pipe(Effect.forkChild); + // Each branch's own git reads come first, so a sweep's lookups arrive spread out. + const firstLookup = yield* lookup("feature/a"); + yield* TestClock.adjust("200 millis"); + const secondLookup = yield* lookup("feature/b"); + yield* TestClock.adjust("300 millis"); + const first = yield* Fiber.join(firstLookup); + const second = yield* Fiber.join(secondLookup); + assert.deepStrictEqual( + first?.map((pr) => pr.number), + [7], + ); + assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); + assert.deepStrictEqual(second, []); + assert.strictEqual(documents.length, 1); + assert.include(documents[0]!.query, "headRefOid"); + assert.deepStrictEqual(documents[0]!.variables, { + owner: "acme", + name: "web", + h0: "feature/a", + s0: ["OPEN", "CLOSED", "MERGED"], + h1: "feature/b", + s1: ["OPEN", "CLOSED", "MERGED"], + }); + }).pipe(Effect.provide(layer)); + }); - const error = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", - }) - .pipe(Effect.flip); + it.effect("caps a background document at twenty-five heads", () => { + const headCounts: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); + headCounts.push(heads.length); + return encodeJson({ + data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const lookups = yield* Effect.all( + Array.from({ length: 26 }, (_, index) => + gh.listChangeRequests({ + cwd: "/repo", + headSelector: `feature/${index}`, + state: "all", + limit: 100, + context: githubContext("github.com"), + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(lookups); + assert.deepStrictEqual( + headCounts.toSorted((a, b) => a - b), + [1, 25], + ); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, + it.effect("matches an owner:branch selector on the head owner", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.succeed( + encodeJson({ + data: { + repository: { + h0: { + nodes: + input.variables?.h0 === "main" + ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] + : [], + }, + }, + }, + }), + ), }, - { - provider: "github", - operation: "getChangeRequest", + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const open = yield* gh + .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("50 millis"); + assert.deepStrictEqual( + (yield* Fiber.join(open)).map((pr) => pr.number), + [8], + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps API failures onto the errors callers handle", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.fail( + input.variables?.h0 === "missing" + ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) + : new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "x", + retryAt: 123, + }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const read = (headSelector: string) => + gh + .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) + .pipe(Effect.flip, Effect.forkChild); + const missing = yield* read("missing"); + yield* TestClock.adjust("500 millis"); + assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); + const limited = yield* read("limited"); + yield* TestClock.adjust("500 millis"); + const error = yield* Fiber.join(limited); + assert.include(error.detail, "rate limit exceeded"); + assert.propertyVal(error.cause, "retryAt", 123); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.getChangeRequest", () => { + it.effect("reads a pull request by number, and by URL on its own repository", () => { + const variables: Array = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: (input) => + Effect.sync(() => { + variables.push(input.variables); + return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual( + (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, + 42, + ); + yield* gh.getChangeRequest({ cwd: "/repo", - reference: "https://github.com/pingdotgg/t3code/pull/42", - detail: "Pull request not found. Check the PR number or URL and try again.", + reference: "https://github.com/other/thing/pull/42", + }); + assert.deepStrictEqual(variables, [ + { owner: "acme", name: "web", number: 42 }, + { owner: "other", name: "thing", number: 42 }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a missing pull request as not found", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); + assert.include(error.detail, "Pull request not found"); + }).pipe(Effect.provide(layer)); + }); +}); -it.effect("lists change request history through the batched head lookup", () => - Effect.gen(function* () { - let lookup: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - listPullRequestsByHead: (input) => { - lookup = input; - return Effect.succeed([ - { - number: 7, - title: "Merged work", - url: "https://enterprise.test/acme/web/pull/7", - baseRefName: "main", - headRefName: "feature/merged", - headSha: "a".repeat(40), - state: "merged", - mergedAt: "2026-01-01T00:00:00Z", - updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - }, - ]); +describe("GitHubSourceControlProvider writes", () => { + it.effect("creates a cross-repository pull request with an owner:branch head", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + requests.push(input); + return restResponse({ number: 1 }, 201); + }), }, }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Body"); + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "me:feature", + title: "Title", + bodyFile, + }); + assert.strictEqual(requests[0]!.method, "POST"); + assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + assert.deepStrictEqual(requests[0]!.body, { + base: "main", + head: "me:feature", + title: "Title", + body: "Body", + maintainer_can_modify: true, + }); + }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); + }); - const changeRequests = yield* provider.listChangeRequests({ - cwd: "/repo", - context: { - provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, - remoteName: "origin", - remoteUrl: "https://enterprise.test/acme/web.git", + it.effect("creates a repository under an organization the viewer is not", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "acme/new", + html_url: "https://github.com/acme/new", + ssh_url: "git@github.com:acme/new.git", + }); + }), }, - headSelector: "feature/merged", - state: "all", - limit: 10, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const urls = yield* gh.createRepository({ + cwd: "/repo", + repository: "acme/new", + visibility: "private", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "acme/new", + url: "https://github.com/acme/new", + sshUrl: "git@github.com:acme/new.git", + }); + assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { + const repository = (fullName: string, defaultBranch = "main") => + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: defaultBranch, }); - assert.deepStrictEqual(lookup, { - cwd: "/repo", - headSelector: "feature/merged", - state: "all", - limit: 10, - rateLimitHost: "enterprise.test", + it.effect("checks a same-repository pull request out from its head branch", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, }); - assert.strictEqual(changeRequests[0]?.provider, "github"); - assert.strictEqual(changeRequests[0]?.state, "merged"); - assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40)); - assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); - assert.deepStrictEqual( - changeRequests[0]?.updatedAt, - Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - ); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" }); + assert.deepStrictEqual(git, [ + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], + ["switchRef", { cwd: "/repo", refName: "feature/x" }], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); -it.effect("creates GitHub PRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - createPullRequest: (input) => { - createInput = input; - return Effect.void; + it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: () => + Effect.fail( + new GitHubApi.GitHubApiRequestError({ + host: "github.com", + operation: "x", + cause: "offline", + }), + ), }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); + // Nothing touched the local branches: `main` must not be reset to the fork's commit. + assert.deepStrictEqual(git, []); + }).pipe(Effect.provide(layer)); + }); - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["someone/main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: (input) => + Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }); + assert.deepStrictEqual(git, [ + [ + "ensureRemote", + { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, + ], + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, + ], + ["switchRef", { cwd: "/repo", refName: "someone/main" }], + ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + it.effect("keeps git's own output out of what a failed checkout reports", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, }); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh + .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) + .pipe(Effect.flip); + assert.strictEqual(error.detail, "The pull request could not be checked out with git."); + assert.notInclude(error.message, "secret-path"); + }).pipe(Effect.provide(layer)); + }); + + it("names the local branch the way gh pr checkout does", () => { + const name = (headRefName: string, isCrossRepository: boolean) => + GitHubSourceControlProvider.pullRequestCheckoutBranchName({ + headRefName, + headOwner: "someone", + isCrossRepository, + defaultBranch: "main", + }); + assert.strictEqual(name("main", true), "someone/main"); + assert.strictEqual(name("feature", true), "feature"); + assert.strictEqual(name("main", false), "main"); + }); +}); it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( @@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])( "resolves %s subjects on the linked host without using the checkout", (kind) => Effect.gen(function* () { - const provider = yield* makeProvider( - {}, - { - rest: (input) => { - assert.strictEqual(input.host, "github.com"); - assert.strictEqual(input.path, "repos/owner/repo/issues/42"); - return Effect.succeed( - restResponse( - encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), - ), - ); - }, + const provider = yield* makeProvider({ + rest: (input) => { + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.path, "repos/owner/repo/issues/42"); + return Effect.succeed( + restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), + ); }, - ); + }); const lookup = provider.resolveLink?.({ cwd: "/unrelated", url: new URL(`https://github.com/owner/repo/${kind}/42`), @@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)( operation: "resolveLink", status: 500, }); - const provider = yield* makeProvider( - {}, - { - rest: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed(restResponse("private response text")), - }, - ); + const provider = yield* makeProvider({ + rest: () => + stage === "read" + ? Effect.fail(cause) + : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), + }); const lookup = provider.resolveLink?.({ cwd: "/repo", url: new URL("https://github.com/owner/repo/issues/42"), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index a3b7a22f8d55..86edda994cce 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,21 +1,42 @@ -import * as Schema from "effect/Schema"; -import * as DateTime from "effect/DateTime"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { DEFAULT_SERVER_SETTINGS, SourceControlProviderError, + TrimmedNonEmptyString, type ChangeRequest, type GitHubSettings, type SourceControlProviderDiscoveryItem, + type SourceControlRepositoryCloneUrls, } from "@t3tools/contracts"; - +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import { + decodeGitHubPullRequestEntries, + type NormalizedGitHubPullRequestRecord, +} from "./gitHubPullRequests.ts"; +import { + parseFetchRemotes, + parseGitHubRepositorySelector, + parsePullRequestReference, + resolveGitHubRepository, + type GitHubRepositoryLocator, +} from "./gitHubRepositoryResolution.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { - return { - provider: "github", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: - summary.updatedAt === undefined - ? Option.none() - : Option.some(DateTime.makeUnsafe(summary.updatedAt)), - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; +function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { + return { provider: "github", ...record }; } function authAccounts(accounts: ReadonlyArray) { @@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () { } satisfies SourceControlManagedCliDiscoverySpec; }); +/** + * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the + * operation and request context; `cause` keeps the raw failure for logs, never for transport. + */ +class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ + readonly detail: string; + readonly cause: unknown; +}> {} + +const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); + +const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; +const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; + +/** `notFound` names what was missing, which only the operation knows. */ +function fromGitHubApiError( + error: GitHubApi.GitHubApiError, + notFound = PULL_REQUEST_NOT_FOUND, +): GitHubFailure { + switch (error._tag) { + case "GitHubCliMissingError": + return failure( + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", + error, + ); + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + // A missing or turned-off credential already says what to do about it. + return failure(error.message, error); + case "GitHubApiAuthenticationError": + return failure( + "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", + error, + ); + case "GitHubApiRateLimitError": + case "SourceControlRateLimitPausedError": + return failure( + "GitHub API rate limit exceeded. Requests resume when the limit resets.", + error, + ); + case "GitHubApiNotFoundError": + return failure(notFound, error); + case "GitHubCliFailedError": + case "GitHubApiResponseError": + case "GitHubApiRequestError": + // GitHub's own reason ("A pull request already existsโ€ฆ") when it gave one. + return failure(error.message.trim() || "GitHub request failed.", error); + } +} + +const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); + +const RawRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + ssh_url: TrimmedNonEmptyString, + default_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeRawRepository = decodeJsonResult(RawRepositorySchema); + +function repositoryCloneUrls( + raw: Schema.Schema.Type, +): SourceControlRepositoryCloneUrls { + return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; +} + +const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); + +type PullRequestListState = "open" | "closed" | "merged" | "all"; + +/** The pull request fields every read selects, in GraphQL. */ +const PULL_REQUEST_NODE_SELECTION = + "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; +const GRAPHQL_STATES: Record> = { + open: ["OPEN"], + closed: ["CLOSED"], + merged: ["MERGED"], + all: ["OPEN", "CLOSED", "MERGED"], +}; +/** + * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one + * single lookup, but half that keeps each answer near half a second. + */ +const HEAD_LOOKUPS_PER_DOCUMENT = 50; +/** + * How long a head lookup waits for company. Branch discovery reaches GitHub only after each + * branch's own git reads, so lookups started together arrive tens of milliseconds apart. + * A background sweep's lookups spread over up to ~300ms, and every document costs a point no + * matter how few heads it holds, so reads without reserve wait longer. + */ +const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; +const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; +/** + * Background documents fill up under the longer window, and a failed document fails every head + * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own + * processing limit; twenty-five took ~7s. + */ +const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; +/** A full document is 5,000 rows of well under 2 KB each. */ +const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; +/** + * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch + * named like a busy default (`main`) is the case this bounds; the owner's own row is near the + * top because the newest come first. + */ +const OWNER_HEAD_SCAN_LIMIT = 100; + +class PullRequestsByHeadRead extends Request.Class< + { + readonly host: string; + readonly owner: string; + readonly name: string; + readonly headRefName: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly allowReserve: boolean; + }, + ReadonlyArray, + GitHubFailure +> {} + +/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ +function buildPullRequestsByHeadQuery( + lookups: ReadonlyArray>, +): { readonly document: string; readonly variables: Record } { + const variables: Record = {}; + const declarations: string[] = ["$owner: String!", "$name: String!"]; + const selections: string[] = []; + for (const [index, lookup] of lookups.entries()) { + variables[`h${index}`] = lookup.headRefName; + variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; + declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); + // `gh pr list` orders the same way, so a head with more matches than the limit keeps the + // same rows. + selections.push( + ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, + ); + } + return { + document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, + variables, + }; +} + +const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } + } +}`; + +const decodePullRequestsByHead = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), + ), + ), + }), + }), +); + +const decodePullRequestByNumber = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), + }), + }), +); + +/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ +export function pullRequestCheckoutBranchName(input: { + readonly headRefName: string; + readonly headOwner: string | null; + readonly isCrossRepository: boolean; + readonly defaultBranch: string | null; +}): string { + // gh prefixes the owner only where the fork's branch would take over the default branch's + // name, which is the one collision every fork pull request from `main` would hit. + return input.isCrossRepository && + input.headOwner !== null && + input.defaultBranch !== null && + input.headRefName === input.defaultBranch + ? `${input.headOwner}/${input.headRefName}` + : input.headRefName; +} + +/** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ +const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => + context === undefined ? undefined : new URL(context.provider.baseUrl).host; + export const make = Effect.gen(function* () { - const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; + const process = yield* VcsProcess.VcsProcess; + const environment = yield* HostProcessEnvironment; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubSourceControlProvider.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + /** + * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else + * the remote the caller resolved the provider from, else the best-ranked GitHub remote. + */ + const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( + function* (input: { readonly cwd: string; readonly host?: string | undefined }) { + const envRepository = environment.GH_REPO?.trim(); + const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + if (envRepository) { + const locator = parseGitHubRepositorySelector(envRepository, defaultHost); + if (locator !== null) return locator; + } + const [remotes, resolved] = yield* Effect.all([ + gitRead(input.cwd, ["remote", "-v"]), + gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); + const { host, locator } = resolveGitHubRepository({ + remotes: remotes?.exitCode === 0 ? remotes.stdout : "", + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + hostHint: input.host, + defaultHost, + }); + if (locator !== null) return locator; + return yield* failure( + `No GitHub repository on ${host} was found among this checkout's git remotes.`, + ); + }, + ); + + const graphqlJson = ( + input: GitHubApi.GitHubGraphQlInput, + decode: (raw: string) => Result.Result, + decodeDetail: string, + ) => + api.graphql(input).pipe( + Effect.mapError(fromGitHubApiError), + Effect.flatMap((raw) => { + const decoded = decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(failure(decodeDetail, decoded.failure)); + }), + ); + + const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => + api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); + + const headResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + [ + request.host, + request.owner, + request.name, + String(request.allowReserve), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? "", + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ].join("\0"), + resolver: (entries) => { + const [first] = entries; + const { host, owner, name, allowReserve } = first.request; + const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); + return graphqlJson( + { + host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner, name, ...query.variables }, + allowReserve, + // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred + // fork pull requests, so the usual cap would cut the answer short. + maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, + }, + decodePullRequestsByHead, + "GitHub returned an invalid change request list.", + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail(notFound("The repository could not be read.")) + : Effect.succeed(decoded.data.repository), + ), + Effect.map((aliases) => { + for (const [index, entry] of entries.entries()) { + const alias = aliases[`h${index}`]; + entry.completeUnsafe( + Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), + ); + } + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); + const interactiveHeadResolver = headResolver.pipe( + RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), + ); + const backgroundHeadResolver = headResolver.pipe( + RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), + RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), + ); + + /** + * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the + * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together + * share one GraphQL document. + */ + const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly host?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); + // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the + // owner is matched on the rows it returns. + const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); + const rows = yield* Effect.request( + new PullRequestsByHeadRead({ + ...locator, + headRefName, + state: input.state, + limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, + allowReserve: input.allowReserve, + }), + input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, + ); + if (!ownerMatch) return rows; + const headOwner = ownerMatch[1]!.toLowerCase(); + return rows + .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) + .slice(0, limit); + }); - const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = - (input) => { - if (input.state === "open") { - return github - .listOpenPullRequests({ + const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly host?: string | undefined; + }) { + const parsed = parsePullRequestReference(input.reference); + if (parsed.kind === "branch") { + // `gh pr view ` prefers an open pull request, then the newest of any state. + const lookup = (state: PullRequestListState) => + listByHead({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); + headSelector: parsed.headSelector, + state, + limit: 1, + host: input.host, + allowReserve: true, + }); + const [open] = yield* lookup("open"); + const found = open ?? (yield* lookup("all"))[0]; + if (found === undefined) return yield* notFound("No pull request has this head branch."); + return found; } + const locator = + parsed.kind === "url" + ? parsed.locator + : yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const decodeDetail = "GitHub returned an invalid pull request."; + const decoded = yield* graphqlJson( + { + host: locator.host, + operation: "getPullRequest", + query: PULL_REQUEST_BY_NUMBER_QUERY, + variables: { owner: locator.owner, name: locator.name, number: parsed.number }, + allowReserve: true, + }, + decodePullRequestByNumber, + decodeDetail, + ); + const node = decoded.data.repository?.pullRequest; + if (node == null) return yield* notFound("The pull request does not exist."); + const [record] = decodeGitHubPullRequestEntries([node]); + if (record === undefined) + return yield* failure(decodeDetail, new Error("Malformed pull request.")); + return record; + }, + ); + + const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( + locator: GitHubRepositoryLocator, + ) { + const response = yield* rest( + { + host: locator.host, + operation: "getRepository", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, + allowReserve: true, + }, + REPOSITORY_NOT_FOUND, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return decoded.success; + }); + + const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( + host: string, + ) { + const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const decoded = decodeViewerLogin(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub request failed.", decoded.failure); + } + return decoded.success.login; + }); + + // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client + // sees is fixed and the raw error stays in `cause`. + const gitFailure = (cause: unknown) => + failure("The pull request could not be checked out with git.", cause); + + const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ + operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, + cwd, + args, + }); + + /** + * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a + * fork gets a remote of its own), checked out under the name gh would give it, and set to + * track the head. A head branch that is gone is read from the base's `refs/pull//head`. + * An existing branch fast-forwards, or with `force` is reset to the pull request. + */ + const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); + const base = + reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); + const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; + const isCrossRepository = + pullRequest.isCrossRepository ?? + (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); + const headOwner = + pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; + + const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.mapError(gitFailure), + ); + const remoteFor = (nameWithOwner: string) => + parseFetchRemotes(remotes).find( + (remote) => + normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === + nameWithOwner.toLowerCase(), + )?.name ?? null; + const baseRemote = Effect.suspend(() => { + const known = remoteFor(baseNameWithOwner); + return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); + }); + // A fork's branch named like the base's default branch is checked out under the owner's + // prefix. Without the default branch that collision cannot be ruled out, and the checkout + // would reset the local default branch to the fork's commit, so it fails instead. + const defaultBranch = isCrossRepository + ? yield* readRepository(base).pipe( + Effect.map((repository) => repository.default_branch ?? null), + ) + : null; + const localBranch = pullRequestCheckoutBranchName({ + headRefName: pullRequest.headRefName, + headOwner, + isCrossRepository, + defaultBranch, + }); - return github - .listPullRequestsByHead({ + /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ + const headRemote = Effect.gen(function* () { + if (!isCrossRepository) return yield* baseRemote; + if (headNameWithOwner === null) return yield* failure("The fork is gone."); + const known = remoteFor(headNameWithOwner); + if (known !== null) return known; + const [owner, name] = headNameWithOwner.split("/"); + const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); + const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ cwd: input.cwd, - headSelector: input.headSelector, - state: input.state, - limit: input.limit ?? 20, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map((items) => - items.map(({ updatedAt, ...summary }) => ({ - ...toChangeRequest({ - ...summary, - ...(Option.isSome(updatedAt) - ? { updatedAt: DateTime.formatIso(updatedAt.value) } - : {}), - }), - updatedAt, - })), - ), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); - }; + preferredName: headOwner ?? "fork", + url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, + }); + }); + + const exists = (yield* git + .listLocalBranchNames(input.cwd) + .pipe(Effect.mapError(gitFailure))).includes(localBranch); + + // The commit to check out, fetched from the head branch where it still exists. + const target = yield* Effect.gen(function* () { + const remoteName = yield* headRemote; + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch: pullRequest.headRefName, + }); + return { + ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, + upstream: { remoteName, remoteBranch: pullRequest.headRefName }, + }; + }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* runGit(input.cwd, "fetchPullRef", [ + "fetch", + "--quiet", + "--no-tags", + yield* baseRemote, + `refs/pull/${pullRequest.number}/head`, + ]); + const { commitSha } = yield* git.resolveCommit({ + cwd: input.cwd, + revision: "FETCH_HEAD", + }); + return { ref: commitSha, upstream: null }; + }), + ), + Effect.mapError(gitFailure), + ); + + yield* Effect.gen(function* () { + if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + if (exists) { + yield* runGit( + input.cwd, + "sync", + input.force === true + ? ["reset", "--hard", "--quiet", target.ref] + : ["merge", "--ff-only", "--quiet", target.ref], + ); + } + // Tracking is set once the branch is the pull request's, so a sync that fails leaves an + // existing branch's upstream as it was. + if (target.upstream !== null) { + yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); + } + }).pipe(Effect.mapError(gitFailure)); + }, + ); + + /** Reports a failure against the operation and request that made it, with transport-safe context. */ + const providerError = + ( + operation: string, + cwd: string, + context?: { readonly reference?: string; readonly repository?: string }, + ) => + (error: GitHubFailure) => + new SourceControlProviderError({ + provider: "github", + operation, + cwd, + ...(context?.reference === undefined + ? {} + : { + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference, + ), + }), + ...(context?.repository === undefined + ? {} + : { + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + context.repository, + ), + }), + detail: error.detail, + cause: error.cause, + }); const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( input: { readonly cwd: string; readonly url: URL }, @@ -385,121 +905,122 @@ export const make = Effect.gen(function* () { if (!match) return undefined; return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); }, - listChangeRequests, + listChangeRequests: (input) => + // An open lookup is a user waiting on a status; the rest may be a background sweep. + (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( + Effect.flatMap((allowReserve) => + listByHead({ + cwd: input.cwd, + headSelector: input.headSelector, + state: input.state, + limit: input.limit ?? (input.state === "open" ? 1 : 20), + host: contextHost(input.context), + allowReserve, + }), + ), + Effect.map((records) => records.map(toChangeRequest)), + Effect.mapError( + providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), + ), + ), getChangeRequest: (input) => - github - .getPullRequest({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), - ), + readPullRequest({ ...input, host: contextHost(input.context) }).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + providerError("getChangeRequest", input.cwd, { reference: input.reference }), ), + ), createChangeRequest: (input) => - github - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd }); + const body = yield* fileSystem + .readFileString(input.bodyFile) + .pipe( + Effect.mapError((cause) => + failure("The pull request description could not be read.", cause), + ), + ); + yield* rest({ + host: locator.host, + operation: "createPullRequest", + method: "POST", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, + // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. + // gh allows maintainer edits unless told otherwise; the API's default is not documented. + body: { + base: input.baseRefName, + head: input.headSelector, + title: input.title, + body, + maintainer_can_modify: true, + }, + }); + }).pipe( + Effect.mapError( + providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), ), + ), getRepositoryCloneUrls: (input) => - github.getRepositoryCloneUrls(input).pipe( + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + Effect.map((locator) => locator.host), + Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), + )).toLowerCase(); + const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + if (locator === null) return yield* failure("Repositories are named owner/name."); + return repositoryCloneUrls(yield* readRepository(locator)); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), ), ), createRepository: (input) => - github.createRepository(input).pipe( + Effect.gen(function* () { + const locator = parseGitHubRepositorySelector( + input.repository, + (environment.GH_HOST ?? "github.com").toLowerCase(), + ); + const viewer = locator === null ? null : yield* readViewerLogin(locator.host); + const owner = locator?.owner ?? viewer; + const name = locator?.name ?? input.repository.trim(); + const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); + const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); + const response = yield* rest( + { + host, + operation: "createRepository", + method: "POST", + path: + isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, + body: { name, private: input.visibility === "private" }, + }, + // GitHub answers 404 for an organization the account cannot create repositories in. + `No organization named ${owner ?? "that"} that this account can create repositories in.`, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return repositoryCloneUrls(decoded.success); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createRepository", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("createRepository", input.cwd, { repository: input.repository }), ), ), getDefaultBranch: (input) => - github - .getDefaultBranch({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - cwd: input.cwd, - detail: error.message, - cause: error, - }), - ), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ + cwd: input.cwd, + host: contextHost(input.context), + }); + const repository = yield* readRepository(locator); + const branch = repository.default_branch?.trim() ?? ""; + return branch.length > 0 ? branch : null; + }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), checkoutChangeRequest: (input) => - github.checkoutPullRequest(input).pipe( + checkoutPullRequest(input).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), + providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), ), ), }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index d9bbb51d7a61..6101d148a632 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index c9a7164c8b31..f715eff1dea0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,6 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,8 +97,8 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts new file mode 100644 index 000000000000..3b33f0c38e40 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts @@ -0,0 +1,87 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + gitHubApiHostForRemote, + selectGitHubBaseRepository, +} from "./gitHubRepositoryResolution.ts"; + +describe("selectGitHubBaseRepository", () => { + const remotes = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + const select = (input: { remotes: string; resolved?: string }) => + selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + + it("picks the repository gh reads without a prompt", () => { + assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { + owner: "me", + name: "web", + }); + const fork = remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/Acme/Web.git"], + ); + assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); + assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { + owner: "me", + name: "web", + }); + assert.deepStrictEqual( + select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), + { owner: "acme", name: "other" }, + ); + // gh ranks remote names in any case. + assert.deepStrictEqual( + select({ + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Upstream", "git@github.com:acme/web.git"], + ), + }), + { owner: "acme", name: "web" }, + ); + }); + + it("leaves gh to choose when it might weigh the remotes differently", () => { + for (const input of [ + { remotes: "" }, + { + remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["mirror", "git@gitlab.com:me/web.git"], + ), + }, + { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Origin", "git@github.com:acme/web.git"], + ), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", + }, + ]) { + assert.strictEqual(select(input), null); + } + }); +}); + +describe("gitHubApiHostForRemote", () => { + it("reads an SSH alias through github.com and a real host as itself", () => { + assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); + assert.strictEqual( + gitHubApiHostForRemote("git@github.example.com:a/b.git"), + "github.example.com", + ); + assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + }); +}); diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts new file mode 100644 index 000000000000..8d4cbf038bfd --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts @@ -0,0 +1,190 @@ +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +/** A repository on a GitHub host: the API it is read through, and its owner and name. */ +export interface GitHubRepositoryLocator { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +/** The fetch remotes in `git remote -v` output, by name. */ +export function parseFetchRemotes( + remotes: string, +): ReadonlyArray<{ readonly name: string; readonly url: string }> { + return remotes + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ name: match[1]!, url: match[2]! })); +} + +/** + * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a + * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin + * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of + * `git config --get-regexp '^remote\..*\.gh-resolved$'`. + * + * Null whenever gh might weigh the remotes differently: a remote on another host or under an + * SSH alias, more than one mark, or several remotes with none of those names. Callers then + * fall back to the provider's remote, then to the best-ranked remote on the host. + */ +export function selectGitHubBaseRepository(input: { + readonly remotes: string; + readonly resolved: string; + readonly host: string; +}): { readonly owner: string; readonly name: string } | null { + const host = input.host.toLowerCase(); + const repositories = new Map(); + for (const remote of parseFetchRemotes(input.remotes)) { + const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); + if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + repositories.set(remote.name, { owner, name }); + } + const marks = input.resolved + .split("\n") + .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); + if (marks.length > 1) return null; + const [mark] = marks; + if (mark) { + if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; + const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); + return owner && name && rest.length === 0 ? { owner, name } : null; + } + // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the + // top place has no defined winner. + const score = (remoteName: string) => + ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; + const ranked = [...repositories.entries()].toSorted( + ([left], [right]) => score(right) - score(left), + ); + const [top, next] = ranked; + return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) + ? top[1] + : null; +} + +/** + * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, + * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller + * names when no repository was found there. GH_REPO is the caller's to check first. + */ +export function resolveGitHubRepository(input: { + readonly remotes: string; + readonly resolved: string; + /** The checkout's GitHub API host, when the caller already knows it. */ + readonly hostHint: string | undefined; + readonly defaultHost: string; +}): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { + const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ + ...remote, + host: gitHubApiHostForRemote(remote.url), + })); + const host = + (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? + fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? + fetchRemotes.find((remote) => remote.host !== null)?.host ?? + input.defaultHost; + const selected = selectGitHubBaseRepository({ + remotes: input.remotes, + resolved: input.resolved, + host, + }); + if (selected !== null) return { host, locator: { host, ...selected } }; + // gh's own order without its prompt: upstream, github, origin, then the first remote, among + // the ones on this host (an SSH alias counts as its API host). + const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); + const candidates = fetchRemotes + .filter((remote) => remote.host === host) + .toSorted((left, right) => { + const l = rank(left.name); + const r = rank(right.name); + return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); + }); + for (const remote of candidates) { + const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); + if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } }; + } + return { host, locator: null }; +} + +/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ +export function parseGitHubRepositorySelector( + selector: string, + defaultHost: string, +): GitHubRepositoryLocator | null { + const trimmed = selector.trim().replace(/\.git$/i, ""); + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); + return owner && name && rest.length === 0 + ? { host: url.host.toLowerCase(), owner, name } + : null; + } catch { + return null; + } + } + const parts = trimmed.split("/").filter(Boolean); + if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; + if (parts.length === 3) + return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; + return null; +} + +/** + * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request + * URL, or a branch name. + */ +export function parsePullRequestReference( + reference: string, +): + | { readonly kind: "number"; readonly number: number } + | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } + | { readonly kind: "branch"; readonly headSelector: string } { + const trimmed = reference.trim(); + const numbered = /^#?([1-9]\d*)$/.exec(trimmed); + if (numbered) return { kind: "number", number: Number(numbered[1]) }; + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); + if (match) { + return { + kind: "url", + locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, + number: Number(match[3]), + }; + } + } catch { + // Not a URL after all; read it as a branch. + } + } + return { kind: "branch", headSelector: trimmed }; +} + +/** + * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH + * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through + * `github.com`, which is what such an alias almost always stands for (issue #6198). + */ +export function gitHubApiHostForRemote(remoteUrl: string): string | null { + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider === null) return null; + const host = new URL(provider.baseUrl).host.toLowerCase(); + // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. + if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { + return host.includes("github") ? "github.com" : null; + } + return provider.kind === "github" ? host : null; +} + +/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ +function apiHostForHint(host: string): string { + const normalized = host.toLowerCase(); + return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f64ecb12f87..55d5ec85b5a5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), From d720210996a514368ba99f4860063110033d93fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:26 -0700 Subject: [PATCH 038/530] refactor(server): GitHub rate limits read the response headers (#16986) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/measure-pr-preview.ts | 16 +- .../pullRequest/GitHubPullRequestApi.test.ts | 122 ++++----- .../src/sourceControl/GitHubApi.test.ts | 76 ++++-- apps/server/src/sourceControl/GitHubApi.ts | 50 ++-- .../GitHubSourceControlProvider.test.ts | 2 + .../GitHubSourceControlProvider.ts | 10 +- .../sourceControl/githubGraphQlBudget.test.ts | 241 ------------------ .../src/sourceControl/githubGraphQlBudget.ts | 150 ----------- .../src/sourceControl/githubQuota.test.ts | 86 +++++++ apps/server/src/sourceControl/githubQuota.ts | 122 +++++++++ 10 files changed, 350 insertions(+), 525 deletions(-) delete mode 100644 apps/server/src/sourceControl/githubGraphQlBudget.test.ts delete mode 100644 apps/server/src/sourceControl/githubGraphQlBudget.ts create mode 100644 apps/server/src/sourceControl/githubQuota.test.ts create mode 100644 apps/server/src/sourceControl/githubQuota.ts diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index f1fc491b1a2b..e8f0727ac93c 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -1,7 +1,7 @@ // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. -// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. +// Each GraphQL read asks for `rateLimit` here, so its cost is read off its own answer. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; @@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; import * as ServerSettings from "../src/serverSettings.ts"; -import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -33,6 +33,14 @@ const decodeCost = Schema.decodeUnknownOption( }), ), ); +/** The query with GitHub's own count of what it cost, for a read rather than a mutation. */ +function withRateLimit(query: string): string { + const end = query.lastIndexOf("}"); + return query.trimStart().startsWith("mutation") || end === -1 + ? query + : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; +} + const measuredApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { @@ -40,7 +48,7 @@ const measuredApi = Layer.effect( return GitHubApi.GitHubApi.of({ ...api, graphql: (input) => - api.graphql(input).pipe( + api.graphql({ ...input, query: withRateLimit(input.query) }).pipe( Effect.tap((body) => Effect.sync(() => reads.push({ @@ -66,7 +74,7 @@ const measuredApi = Layer.effect( }), ).pipe( Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 20fc1dd88526..294d053d6f05 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; @@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => ); /** - * A transport that answers from the mocks above but spends the real GraphQL budget, the way - * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. + * A transport that answers from the mocks above but spends the real GraphQL quota from each + * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. */ const mockApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; return GitHubApi.GitHubApi.of({ graphql: (input) => - budget - .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) - .pipe( - Effect.flatMap((query) => - (input.query.includes("query PullRequestStackMemberships") - ? mockedStackMemberships - : mockedExecute)({ kind: "graphql", ...input, query }), - ), - Effect.map((answer) => answer.body), - Effect.tap((body) => budget.observe(input.host, body)), + GitHubApi.AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + quota.admit(input.host, "graphql", { + allowReserve: input.allowReserve ?? interactive, + }), ), + Effect.andThen(() => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input }), + ), + Effect.tap((answer) => quota.observe(input.host, answer.headers)), + Effect.map((answer) => answer.body), + ), rest: (input) => mockedExecute({ kind: "rest", ...input }), credential: (host) => mockedCredential(host), }); @@ -98,7 +101,7 @@ const mockApi = Layer.effect( const layer = it.layer( GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provide(NodeCrypto.layer), Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), @@ -109,8 +112,19 @@ function output( body: string, truncated = false, invalidUtf8 = false, + headers: Readonly> = {}, ): GitHubApi.GitHubRestResponse { - return { status: 200, headers: {}, body, truncated, invalidUtf8 }; + return { status: 200, headers, body, truncated, invalidUtf8 }; +} + +/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ +function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { + return { + "x-ratelimit-resource": "graphql", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), + }; } /** @@ -493,7 +507,7 @@ it.effect( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.merge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), @@ -538,15 +552,13 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { - it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => + it.effect("holds background previews once an answer reports the reserve, until it resets", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const resetAt = "2099-08-13T14:00:00Z"; - yield* budget.observe( - "preview-budget.example", - encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), - ); + yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); + // GitHub's count, not ours: the answer below reports the reserve has been reached. mockedExecute.mockReturnValue( Effect.succeed( output( @@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => { author: null, }, }, - rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, }, }), + false, + false, + graphqlQuota(499, resetAt), ), ), ); @@ -576,18 +590,9 @@ layer("GitHubPullRequestApi.layer", (it) => { host: "preview-budget.example", number: 7, }; - const results = yield* Effect.all( - Array.from({ length: 20 }, (_, index) => - cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), - ), - { concurrency: "unbounded" }, - ); - expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); - for (const result of results) { - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); - } - } + yield* cli.getPullRequestPreview(input); + const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); + expect(held._tag).toBe("SourceControlRateLimitPausedError"); expect(mockedExecute).toHaveBeenCalledTimes(1); yield* TestClock.setTime(Date.parse(resetAt)); yield* cli.getPullRequestPreview(input); @@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - it.effect("accounts for the avatar lookup in the GraphQL budget", () => + it.effect("looks up avatars by node id in one GraphQL read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( Effect.succeed( @@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => { encodeJson({ data: { nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 4_999, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), ), @@ -3249,7 +3248,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); expect(avatars.get("octocat")).toBe("https://avatars/octocat"); }), ); @@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { const response = coreResponse(); mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - ...response, - data: { - ...response.data, - rateLimit: { - cost: 1, - limit: 5000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), + Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { @@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ); @@ -4145,7 +4125,6 @@ layer("GitHubPullRequestApi.layer", (it) => { } as const; yield* cli.getViewerAccess(input); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); const error = yield* Effect.flip(cli.getViewerAccess(input)); @@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index a5c0f82be443..60e2c6f1b45d 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp ); const layer = GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); return { layer, requests, invalidations: () => invalidations }; @@ -97,15 +97,8 @@ describe("environmentToken", () => { }); describe("GitHubApi", () => { - it.effect("sends GraphQL with the token and records the reported budget", () => { - const { layer, requests } = harness(() => - json({ - data: { - viewer: { login: "julius" }, - rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" }, - }, - }), - ); + it.effect("sends GraphQL with the token, and the document as written", () => { + const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); return Effect.gen(function* () { yield* TestClock.setTime(NOW); const api = yield* GitHubApi.GitHubApi; @@ -117,11 +110,16 @@ describe("GitHubApi", () => { expect(body).toContain('"login":"julius"'); expect(requests[0]!.url).toBe("https://api.github.com/graphql"); expect(requests[0]!.headers.authorization).toBe("Bearer first"); + // No `rateLimit` selection is added: the quota is read off the response headers. + const sent = requests[0]!.body; + expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( + '"query":"query { viewer { login } }"', + ); }).pipe(Effect.provide(layer)); }); it.effect( - "traces the operation, path, query and cost, never the query string or variables", + "traces the operation, path, query and quota, never the query string or variables", () => { const spans: Array = []; const tracer = Tracer.make({ @@ -134,17 +132,7 @@ describe("GitHubApi", () => { const { layer } = harness((request) => request.url.endsWith("/graphql") ? json( - { - data: { - viewer: { login: "julius" }, - rateLimit: { - cost: 3, - limit: 5000, - remaining: 4990, - resetAt: "2026-10-05T13:00:00Z", - }, - }, - }, + { data: { viewer: { login: "julius" } } }, { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, ) : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), @@ -168,8 +156,10 @@ describe("GitHubApi", () => { expect(graphql).toMatchObject({ "github.operation": "getPullRequestDetail", "github.graphql.query": "query($body: String!) { viewer { login } }", - "github.graphql.cost": 3, - "github.graphql.remaining": 4990, + }); + expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ + "github.ratelimit.remaining": 4990, + "github.ratelimit.resource": "graphql", }); expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); @@ -258,6 +248,42 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { + const reset = Math.floor(NOW / 1000) + 600; + const quota = (remaining: number) => ({ + "x-ratelimit-resource": "core", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset), + }); + let remaining = 499; + const { layer, requests } = harness((request) => + request.url.endsWith("/graphql") + ? json({ data: { viewer: { id: "1" } } }) + : json({ ok: true }, { headers: quota(remaining--) }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. + yield* api.rest(read); + const refused = yield* Effect.flip(api.rest(read)); + expect(refused).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + // A user's own request may spend the reserve, and GraphQL has a quota of its own. + yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); + expect(requests).toHaveLength(3); + // The reset gives the background its quota back. + yield* TestClock.setTime(reset * 1000); + yield* api.rest(read); + expect(requests).toHaveLength(4); + }).pipe(Effect.provide(layer)); + }); + it.effect("carries GitHub's own reason for a refused REST request", () => { const { layer } = harness(() => json( diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 0da53e0230b1..5f29e8b4053d 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const DEFAULT_TIMEOUT = Duration.seconds(30); @@ -218,16 +218,6 @@ function rateLimitAttributes( return attributes; } -const decodeGraphQlCost = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }), - }), - }), - ), -); - /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ function retryAtFrom( headers: Readonly>, @@ -354,7 +344,7 @@ function classify(input: { export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const credentials = yield* GitHubCredentials.GitHubCredentials; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const gate = yield* Semaphore.make(CONCURRENCY); @@ -403,10 +393,17 @@ export const make = Effect.gen(function* () { }); const { token, fingerprint } = yield* credential(host); const scope = yield* SourceControlRateLimit.CredentialScope; + // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path + // 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 run = Effect.gen(function* () { - const lease = yield* limits - .check(key, input.allowReserve ? { allowPaused: true } : undefined) + const lease = yield* quota + .admit(host, resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + ) .pipe( Effect.tapError((paused) => Effect.annotateCurrentSpan({ @@ -452,6 +449,7 @@ export const make = Effect.gen(function* () { ); const headers = response.headers; const status = response.status; + yield* quota.observe(host, headers); yield* Effect.annotateCurrentSpan({ "http.response.status_code": status, ...rateLimitAttributes(headers), @@ -556,17 +554,15 @@ export const make = Effect.gen(function* () { : input.query, }); return yield* Effect.gen(function* () { - const query = yield* budget.query( - host, - input.query, - allowReserve ? { allowReserve: true } : undefined, - ); const response = yield* send({ host, operation: input.operation, request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), ), maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, allowReserve, @@ -581,14 +577,6 @@ export const make = Effect.gen(function* () { status: response.status, }); } - yield* budget.observe(host, response.body); - const cost = Option.getOrUndefined(decodeGraphQlCost(response.body)); - if (cost !== undefined) { - yield* Effect.annotateCurrentSpan({ - "github.graphql.cost": cost.data.rateLimit.cost, - "github.graphql.remaining": cost.data.rateLimit.remaining, - }); - } return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); }, @@ -600,11 +588,11 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(GitHubApi, make); /** - * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An - * application builds this once so every GitHub reader shares one budget and one pause per host. + * The transport with the credential, quota reserve and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one view of each quota per host. */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index b70bfff78f6b..caa91a5ac937 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => { }); assert.strictEqual(requests[0]!.method, "POST"); assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + // A user's own write, so the background's reserve is not held against it. + assert.strictEqual(requests[0]!.allowReserve, true); assert.deepStrictEqual(requests[0]!.body, { base: "main", head: "me:feature", diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 86edda994cce..223a2ec58a26 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -679,7 +679,12 @@ export const make = Effect.gen(function* () { const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( host: string, ) { - const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const response = yield* rest({ + host, + operation: "getViewer", + path: "user", + allowReserve: true, + }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { return yield* failure("GitHub request failed.", decoded.failure); @@ -944,6 +949,8 @@ export const make = Effect.gen(function* () { host: locator.host, operation: "createPullRequest", method: "POST", + // A user's own write may spend the reserve the background leaves for it. + allowReserve: true, path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. // gh allows maintainer edits unless told otherwise; the API's default is not documented. @@ -990,6 +997,7 @@ export const make = Effect.gen(function* () { host, operation: "createRepository", method: "POST", + allowReserve: true, path: isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, body: { name, private: input.visibility === "private" }, diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts deleted file mode 100644 index 26f9747f3202..000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; - -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import { CredentialScope } from "./SourceControlRateLimit.ts"; - -const RESET_AT = "2026-08-13T14:00:00.000Z"; -const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; -const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z"); -const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z"); - -function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT, cost = 14): string { - return JSON.stringify({ - data: { - viewer: { login: "bilal" }, - rateLimit: { cost, limit, remaining, resetAt }, - }, - }); -} - -describe("GitHub GraphQL budget", () => { - it.effect("adds rate metadata to a read query", () => - Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - - const query = yield* budget.query( - "github.com", - 'query { repository(owner: "acme", name: "web") { name } }', - ); - - expect(query).toContain("rateLimit { cost limit remaining resetAt }"); - expect(query).toContain('repository(owner: "acme", name: "web") { name }'); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("protects the last ten percent with the shared provider cooldown error", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - provider: "github", - host: "github.com", - retryAt: Date.parse(RESET_AT), - }); - expect(error.message).toBe( - "github requests to github.com are paused until the rate limit resets.", - ); - - yield* TestClock.setTime(AFTER_RESET); - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps hosts isolated", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(0)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error._tag).toBe("SourceControlRateLimitPausedError"); - expect(yield* budget.query("github.example.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("isolates query reservations and observations by credential", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = budget.query("github.com", "query { viewer { login } }"); - yield* budget - .observe("github.com", rateLimit(0)) - .pipe(Effect.provideService(CredentialScope, "first")); - yield* budget - .observe("github.com", rateLimit(5000)) - .pipe(Effect.provideService(CredentialScope, "second")); - yield* query.pipe(Effect.provideService(CredentialScope, "second")); - const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps the lower remaining value from out-of-order responses", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400)); - yield* budget.observe("github.com", rateLimit(600)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("learns a cheaper observed cost without restoring reserved quota", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = "query { viewer { login } }"; - yield* budget.observe("github.com", rateLimit(512, 5_000, RESET_AT, 8)); - yield* budget.query("github.com", query); - // The admission reserved eight points, but the completed read only cost one. - yield* budget.observe("github.com", rateLimit(511, 5_000, RESET_AT, 1)); - - for (let count = 0; count < 4; count += 1) { - expect(yield* budget.query("github.com", query)).toContain("rateLimit"); - } - const error = yield* Effect.flip(budget.query("github.com", query)); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores a response from an older reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - yield* budget.observe("github.com", rateLimit(1_000, 5_000, RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("accepts a response from a later reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1_000)); - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows reads above the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(515)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows an interactive read to use the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - expect( - yield* budget.query("github.com", "query { viewer { login } }", { - allowReserve: true, - }), - ).toContain("rateLimit"); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("reserves an admitted query cost before its response", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(514)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("stops interactive reads when the reserve is exhausted", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1)); - yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true }); - const error = yield* budget - .query("github.com", "query { viewer { login } }", { allowReserve: true }) - .pipe(Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores malformed or partial rate metadata", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", "{"); - yield* budget.observe( - "github.com", - '{"data":{"rateLimit":{"limit":0,"remaining":-1,"resetAt":"never"}}}', - ); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("does not add a read field to a mutation", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const mutation = "mutation { addComment(input: {}) { clientMutationId } }"; - yield* budget.observe("github.com", rateLimit(0)); - - expect(yield* budget.query("github.com", mutation)).toBe(mutation); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); -}); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts deleted file mode 100644 index 1021f6cde9fa..000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ /dev/null @@ -1,150 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Predicate from "effect/Predicate"; - -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; - -const GRAPHQL_RESERVE_RATIO = 0.1; -const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }"; - -interface GraphQlBudgetSnapshot { - readonly cost: number; - readonly limit: number; - readonly remaining: number; - readonly resetAtMs: number; -} - -export class GitHubGraphQlBudget extends Context.Service< - GitHubGraphQlBudget, - { - readonly query: ( - host: string, - document: string, - options?: { readonly allowReserve: boolean }, - ) => Effect.Effect; - readonly observe: (host: string, raw: string) => Effect.Effect; - } ->()("t3/sourceControl/githubGraphQlBudget") {} - -function hostKey(host: string): string { - return host.trim().toLowerCase(); -} - -function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { - try { - const parsed: unknown = JSON.parse(raw); - if ( - !Predicate.isObject(parsed) || - !Predicate.isObject(parsed.data) || - !Predicate.isObject(parsed.data.rateLimit) - ) { - return null; - } - const { cost, limit, remaining, resetAt } = parsed.data.rateLimit; - if ( - typeof cost !== "number" || - !Number.isFinite(cost) || - cost < 0 || - typeof limit !== "number" || - !Number.isFinite(limit) || - limit <= 0 || - typeof remaining !== "number" || - !Number.isFinite(remaining) || - remaining < 0 || - typeof resetAt !== "string" - ) { - return null; - } - const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; - } catch { - return null; - } -} - -function isReadOperation(document: string): boolean { - const operation = document.trimStart(); - return operation.startsWith("query") || operation.startsWith("{"); -} - -function withRateLimit(document: string): string { - if (!isReadOperation(document)) return document; - const end = document.lastIndexOf("}"); - if (end === -1 || document.includes(RATE_LIMIT_SELECTION)) return document; - return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const snapshots = yield* Ref.make>(new Map()); - - const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( - function* (host, document, options) { - if (!isReadOperation(document)) return document; - const now = yield* Clock.currentTimeMillis; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - const retryAt = yield* Ref.modify(snapshots, (current) => { - const snapshot = current.get(key); - if (snapshot === undefined) return [null, current] as const; - if (snapshot.resetAtMs <= now) { - const next = new Map(current); - next.delete(key); - return [null, next] as const; - } - const remaining = snapshot.remaining - Math.max(1, snapshot.cost); - if ( - remaining < 0 || - (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) - ) { - return [snapshot.resetAtMs, current] as const; - } - const next = new Map(current); - next.set(key, { ...snapshot, remaining }); - return [null, next] as const; - }); - if (retryAt !== null) { - return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", - host: hostKey(host), - retryAt, - }); - } - return withRateLimit(document); - }, - ); - - const observe: GitHubGraphQlBudget["Service"]["observe"] = Effect.fn( - "GitHubGraphQlBudget.observe", - )(function* (host, raw) { - const snapshot = snapshotFrom(raw); - if (snapshot === null) return; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - yield* Ref.update(snapshots, (current) => { - const previous = current.get(key); - // Concurrent reads can finish out of order. Quota only falls within one reset window, and - // an answer from an older window must not replace the current one. - if (previous !== undefined && snapshot.resetAtMs < previous.resetAtMs) { - return current; - } - const next = new Map(current); - // Keep the conservative balance, but learn the observed cost even when our reservation - // was larger. Otherwise one expensive read makes every later cheap read spend its cost. - next.set( - key, - previous !== undefined && - snapshot.resetAtMs === previous.resetAtMs && - snapshot.remaining >= previous.remaining - ? { ...previous, cost: snapshot.cost } - : snapshot, - ); - return next; - }); - }); - - return GitHubGraphQlBudget.of({ query, observe }); -}); - -export const layer = Layer.effect(GitHubGraphQlBudget, make); diff --git a/apps/server/src/sourceControl/githubQuota.test.ts b/apps/server/src/sourceControl/githubQuota.test.ts new file mode 100644 index 000000000000..e21970cecca7 --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import * as GitHubQuota from "./githubQuota.ts"; +import { CredentialScope } from "./SourceControlRateLimit.ts"; + +const RESET = Date.parse("2099-08-13T14:00:00Z"); +const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ + "x-ratelimit-resource": resource, + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset / 1000), +}); + +describe("GitHubQuota", () => { + it.effect("refuses a background request below the reserve, per quota and account", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(499)); + const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); + assert.strictEqual(refused.retryAt, RESET); + // Another quota, another host and another account each have their own balance. + yield* quota.admit("github.com", "core"); + yield* quota.admit("ghe.example", "graphql"); + yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("takes the latest balance in a window, and ignores an older window", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // GitHub can report more left later in the same window, from another region. + yield* quota.observe("github.com", headers(900)); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // An answer from an older window says nothing about this one. + yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // A later window replaces it. + yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); + yield* quota.admit("github.com", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("lets interactive requests spend the reserve but not an empty quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(1)); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + yield* quota.observe("github.com", headers(0)); + const refused = yield* Effect.flip( + quota.admit("github.com", "graphql", { allowReserve: true }), + ); + assert.strictEqual(refused.retryAt, RESET); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(501, "core")); + // Conditional reads answer 304 with the balance unchanged, however many are sent. + for (let index = 0; index < 5; index++) { + yield* quota.admit("github.com", "core"); + yield* quota.observe("github.com", headers(501, "core")); + } + yield* quota.admit("github.com", "core"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(0)); + yield* TestClock.setTime(RESET); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); + yield* quota.admit("other.example", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); +}); diff --git a/apps/server/src/sourceControl/githubQuota.ts b/apps/server/src/sourceControl/githubQuota.ts new file mode 100644 index 000000000000..c553535019f4 --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.ts @@ -0,0 +1,122 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +/** The share of each quota a background read leaves for a user's next click. */ +const RESERVE_RATIO = 0.1; + +/** + * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, โ€ฆ). + * Each is spent and reset on its own, so each is tracked on its own. + */ +type GitHubQuotaResource = string; + +interface QuotaSnapshot { + readonly limit: number; + readonly remaining: number; + readonly resetAtMs: number; +} + +/** + * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer + * carries. Null when the answer named no quota, which an Enterprise host with limits off does. + */ +function quotaFromHeaders( + headers: Readonly>, +): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { + const resource = headers["x-ratelimit-resource"]?.trim(); + const limit = Number(headers["x-ratelimit-limit"]); + const remaining = Number(headers["x-ratelimit-remaining"]); + const reset = Number(headers["x-ratelimit-reset"]); + if ( + !resource || + !Number.isFinite(limit) || + limit <= 0 || + !Number.isFinite(remaining) || + remaining < 0 || + !Number.isFinite(reset) + ) { + return null; + } + return { resource, limit, remaining, resetAtMs: reset * 1_000 }; +} + +/** + * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that + * would dip below it is refused until the quota resets, so a user's next click still has budget. + * + * The balance is GitHub's own count from the latest answer's headers, so it already includes + * what anything else spent with the same token, such as an agent's `gh` commands. Requests in + * flight together all see that one balance; the reserve absorbs what they spend before their + * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub + * spends nothing, and a local count would drift below GitHub's for the rest of the window. + * + * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. + */ +export class GitHubQuota extends Context.Service< + GitHubQuota, + { + readonly admit: ( + host: string, + resource: GitHubQuotaResource, + options?: { readonly allowReserve: boolean }, + ) => Effect.Effect; + readonly observe: ( + host: string, + headers: Readonly>, + ) => Effect.Effect; + } +>()("t3/sourceControl/githubQuota") {} + +const make = Effect.gen(function* () { + const snapshots = yield* Ref.make>(new Map()); + const keyOf = (host: string, resource: string, scope: string) => + `${host.trim().toLowerCase()}\0${resource}\0${scope}`; + + const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( + function* (host, resource, options) { + const now = yield* Clock.currentTimeMillis; + const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); + const snapshot = (yield* Ref.get(snapshots)).get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return; + const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; + if (snapshot.remaining >= floor) return; + return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: "github", + host: host.trim().toLowerCase(), + retryAt: snapshot.resetAtMs, + }); + }, + ); + + const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( + function* (host, headers) { + const quota = quotaFromHeaders(headers); + if (quota === null) return; + const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); + yield* Ref.update(snapshots, (current) => { + const previous = current.get(key); + // The latest answer wins. GitHub serves requests from several regions, so a later answer + // can report more left in the same window, and keeping the lower one would hold + // background work at the reserve until the reset. An answer from an older window says + // nothing of this one. + if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; + const next = new Map(current); + next.set(key, { + limit: quota.limit, + remaining: quota.remaining, + resetAtMs: quota.resetAtMs, + }); + return next; + }); + }, + ); + + return GitHubQuota.of({ admit, observe }); +}); + +export const layer = Layer.effect(GitHubQuota, make); From 83a82a46b0631234ac85748b912566d6f4b1dee3 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Wed, 7 Oct 2026 18:36:19 -0700 Subject: [PATCH 039/530] chore(review): remove the custom Approvability check (#17018) --- .coderabbit.config.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 5a9505c70276..2394e757b72d 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,21 +1,7 @@ import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -const approvability = `Fail when a maintainer should read this pull request before CodeRabbit approves it, and name the rule and file. Fail if it: - -- Changes a product default: a setting's default value, or what users get without opting in. Making a feature do what it already promises is a bug fix, not a default change. -- Adds or broadens a directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic, including file-level, line-level, and configuration-level overrides. -- Adds a subsystem or user workflow, or is a large refactor across apps or packages. -- Changes packages/contracts or persisted data in a way that existing clients or stored data might not accept. -- Changes authentication, pairing, credentials, secrets, or remote connection trust. -- Adds or changes an external side effect, such as acting on GitHub, publishing a release, or calling a webhook. -- Adds, upgrades, or patches a dependency. -- Changes CI or release configuration, agent or contributor instructions, or any review tool's configuration, including .github/, AGENTS.md, CONTRIBUTING.md, .agents/, and .coderabbit.config.ts. - -Otherwise pass. A focused bug fix, copy or layout fix, revert, or docs-only or test-only change passes unless a rule above applies. If you cannot decide, fail rather than report inconclusive. When failing, say that the pull request needs a maintainer's review. -`; - // Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit -// requests changes until its comments are resolved and its checks pass, then approves. +// requests changes until its comments are resolved, then approves. const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); @@ -31,13 +17,6 @@ export default defineConfig((ctx) => ({ pre_merge_checks: { docstrings: { mode: "off" }, override_requested_reviewers_only: isGated(ctx), - custom_checks: [ - { - name: "Approvability", - mode: isGated(ctx) ? "error" : "off", - instructions: approvability, - }, - ], }, path_filters: [ // Vendored read-only reference checkouts of upstream Effect and Alchemy From f9e16b3f66476ad1e213152708c9aa8b035085a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 19:21:59 -0700 Subject: [PATCH 040/530] perf(mobile): show the cached thread list sooner and stop the freeze after it (#16713) Co-authored-by: Claude Opus 5.5 (1M context) --- .../environment-cache-store.test.ts | 75 ++++ .../src/connection/environment-cache-store.ts | 15 +- .../src/connection/shell-cache-decoding.ts | 19 + .../src/platform/persistence.ts | 3 +- .../src/state/environmentHttpAuth.test.ts | 20 +- .../src/state/shell-sync.test.ts | 346 +++++++++++++++++- packages/client-runtime/src/state/shell.ts | 131 ++++++- .../src/state/shellPullRequests.ts | 97 +++++ .../src/state/shellReducer.test.ts | 44 ++- .../client-runtime/src/state/shellReducer.ts | 36 ++ .../src/state/shellSnapshotHttp.ts | 51 ++- 11 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/connection/shell-cache-decoding.ts create mode 100644 packages/client-runtime/src/state/shellPullRequests.ts diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 8e4ce3e62114..e1d627b50466 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -10,12 +10,14 @@ import { ThreadId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadDetailSnapshot, + type ThreadPullRequestLink, type VcsListRefsResult, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; @@ -254,6 +256,79 @@ describe("mobile SQLite environment cache store", () => { }), ); + it.live("returns cached threads before their pull request links, which load after", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe( + Effect.provideService(MobileDatabase.MobileDatabase, memory.database), + ); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "agent", + linkedAt: "2026-07-29T12:00:00.000Z", + snapshot: null, + stack: null, + }; + const linked = { ...SHELL_SNAPSHOT.threads[0]!, pullRequests: [link] }; + const unlinked = { ...SHELL_SNAPSHOT.threads[0]!, id: ThreadId.make("thread-2") }; + yield* store.saveShell(ENVIRONMENT_ID, { + ...SHELL_SNAPSHOT, + threads: [linked, unlinked], + }); + + const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); + expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]); + const links = yield* shell.loadPullRequests!; + expect([...links]).toEqual([[THREAD_ID, [link]]]); + }), + ); + + it.live("keeps cached threads when their pull request links cannot be read", () => { + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe( + Effect.provideService(MobileDatabase.MobileDatabase, memory.database), + ); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 5, + url: "https://github.com/pingdotgg/t3code/pull/5", + source: "agent", + linkedAt: "2026-07-29T12:00:00.000Z", + snapshot: null, + stack: null, + }; + const readable = { + ...SHELL_SNAPSHOT.threads[0]!, + id: ThreadId.make("thread-2"), + pullRequests: [link], + }; + yield* store.saveShell(ENVIRONMENT_ID, { + ...SHELL_SNAPSHOT, + threads: [SHELL_SNAPSHOT.threads[0]!, readable], + }); + const id = cacheId(ENVIRONMENT_ID, "shell", "snapshot"); + const payload = JSON.parse(memory.values.get(id)!); + payload.snapshot.threads[0].pullRequests = [{ number: -1 }]; + memory.values.set(id, JSON.stringify(payload)); + + const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); + expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]); + // Only the thread with the unreadable link loses its links. + expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]); + expect(messages).toHaveLength(1); + expect(memory.removed).toEqual([]); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + it.effect("round-trips schema-validated VCS refs", () => Effect.gen(function* () { const memory = makeDatabase(); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 70c2a91f67af..99019d1d6921 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,5 @@ import { ORCHESTRATION_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, StoredOrchestrationThreadSnapshot, Persistence, } from "@t3tools/client-runtime/platform"; @@ -16,6 +15,7 @@ import { attachProjectFaviconDatabase, projectFaviconDatabaseCache, } from "../lib/projectFaviconDatabaseCache"; +import { decodeStoredShellSnapshot } from "./shell-cache-decoding"; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; @@ -32,9 +32,6 @@ const StoredVcsRefs = Schema.Struct({ refs: VcsListRefsResult, }); -const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( - Schema.fromJsonString(StoredOrchestrationShellSnapshot), -); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredOrchestrationThreadSnapshot), ); @@ -113,7 +110,15 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), + }).pipe( + // Read cached project icons in parallel with the shell so the first rows can show + // them, instead of making the thread list wait for the icon read after the decode. + Effect.zipWith( + Effect.promise(() => projectFaviconDatabaseCache.hydrate()), + (snapshot) => snapshot, + { concurrent: true }, + ), + ), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ diff --git a/apps/mobile/src/connection/shell-cache-decoding.ts b/apps/mobile/src/connection/shell-cache-decoding.ts new file mode 100644 index 000000000000..ef70036e5241 --- /dev/null +++ b/apps/mobile/src/connection/shell-cache-decoding.ts @@ -0,0 +1,19 @@ +import { StoredOrchestrationShellSnapshot } from "@t3tools/client-runtime/platform"; +import { deferPullRequests, detachPullRequests } from "@t3tools/client-runtime/state/shell"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeStoredShell = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot); + +/** + * Decode the shell cache payload with each thread's pull request links left out, so the + * thread list can paint first; `loadPullRequests` decodes the links afterwards. + */ +export const decodeStoredShellSnapshot = Effect.fnUntraced(function* (raw: string) { + const parsed = yield* decodeJson(raw); + const rawLinks = Predicate.isObject(parsed) ? detachPullRequests(parsed.snapshot) : []; + const stored = yield* decodeStoredShell(parsed); + return { ...stored, snapshot: deferPullRequests(stored.snapshot, rawLinks) }; +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index c146adee1e88..29463ce6da58 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; +import type { DeferredShellSnapshot } from "../state/shellPullRequests.ts"; import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; export class ConnectionPersistenceError extends Schema.TaggedError()( @@ -82,7 +83,7 @@ export class EnvironmentCacheStore extends Context.Service< { readonly loadShell: ( environmentId: EnvironmentId, - ) => Effect.Effect, ConnectionPersistenceError>; + ) => Effect.Effect, ConnectionPersistenceError>; readonly saveShell: ( environmentId: EnvironmentId, snapshot: OrchestrationV2ShellSnapshot, diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index c7c7fc53197a..61504668427e 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -204,7 +204,11 @@ const LOADERS: ReadonlyArray<{ path: "/api/orchestration/shell", response: SHELL, expected: SHELL, - load: fetchEnvironmentShellSnapshot, + // Pull request links decode separately; the rows match the response on their own. + load: (input: HttpInput) => + fetchEnvironmentShellSnapshot(input).pipe( + Effect.map(({ loadPullRequests: _links, ...snapshot }) => snapshot), + ), }, { name: "thread snapshot", @@ -254,6 +258,20 @@ describe("authenticated environment HTTP requests", () => { }), ); + it.effect("keeps the status of a shell snapshot error that is not a declared error", () => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json({ error: "bad_gateway" }, { status: 502 })); + const error = yield* fetchEnvironmentShellSnapshot(harness.input).pipe( + Effect.provide(harness.httpLayer), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "RemoteEnvironmentAuthUndeclaredStatusError", + status: 502, + }); + }), + ); + it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json(loader.response)); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 69d728316498..b20d3e9cda19 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -3,6 +3,8 @@ import { ORCHESTRATION_V2_WS_METHODS, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, + ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; @@ -29,7 +31,7 @@ import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { makeEnvironmentShellState } from "./shell.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; +import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -953,4 +955,346 @@ describe("environment shell synchronization", () => { expect(Option.getOrThrow(state.snapshot).projects[0]?.title).toBe("Server reset"); }), ); + + it.effect("fills deferred cached pull request links without overwriting newer rows", () => + Effect.gen(function* () { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }); + const otherThreadId = ThreadId.make("thread-other"); + const cachedSnapshot: OrchestrationV2ShellSnapshot = { + ...v2ShellSnapshot, + snapshotSequence: 5, + threads: [v2ThreadShell, { ...v2ThreadShell, id: otherThreadId }], + }; + const linksReady = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => + Effect.succeedSome({ + ...cachedSnapshot, + loadPullRequests: Deferred.await(linksReady).pipe( + Effect.as( + new Map([ + [v2ThreadShell.id, [link(1)]], + [otherThreadId, [link(2)]], + ]), + ), + ), + }), + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // No HTTP snapshot, so the cached rows stay until the socket sends deltas. + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => Effect.succeedNone, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + + const threadsOf = (state: { + readonly snapshot: Option.Option; + }) => Option.getOrThrow(state.snapshot).threads; + expect(threadsOf(yield* SubscriptionRef.get(shellState))).toEqual(cachedSnapshot.threads); + + // The server updates one row before the cached links finish decoding. + const serverRow = { ...v2ThreadShell, title: "From server", pullRequests: [link(9)] }; + yield* Queue.offer(events, { + kind: "thread.updated", + sequence: 6, + location: "active", + thread: serverRow, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => threadsOf(state)[0]?.title === "From server"), + Stream.runHead, + ); + + yield* Deferred.succeed(linksReady, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map(threadsOf), + Stream.filter((threads) => threads[1]?.pullRequests !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled[0]).toBe(serverRow); + expect(filled[1]?.pullRequests).toEqual([link(2)]); + }), + ); + + it.effect("shows HTTP rows before their pull request links and saves them once filled", () => + Effect.gen(function* () { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const httpSnapshot: OrchestrationV2ShellSnapshot = { + ...v2ShellSnapshot, + snapshotSequence: 3, + }; + const linksReady = yield* Deferred.make(); + const saved = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: (_environmentId, snapshot) => Queue.offer(saved, snapshot), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Effect.succeedSome({ + ...httpSnapshot, + loadPullRequests: Deferred.await(linksReady).pipe( + Effect.as(new Map([[v2ThreadShell.id, [link]]])), + ), + }), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + + const rows = yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => Option.isSome(state.snapshot)), + Stream.runHead, + Effect.map((state) => Option.getOrThrow(Option.getOrThrow(state).snapshot)), + ); + expect(rows.threads[0]?.pullRequests).toBeUndefined(); + expect(rows).not.toHaveProperty("loadPullRequests"); + + yield* Deferred.succeed(linksReady, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map((state) => Option.getOrThrow(state.snapshot).threads[0]), + Stream.filter((thread) => thread?.pullRequests !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled?.pullRequests).toEqual([link]); + + // The cache must end up with the links: the last save carries them. + yield* TestClock.adjust("30 seconds"); + const saves = yield* Queue.takeAll(saved); + expect(saves.at(-1)?.threads[0]?.pullRequests).toEqual([link]); + }), + ); + + it.effect("does not save rows whose deferred links are still pending", () => + Effect.gen(function* () { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + url: "https://github.com/pingdotgg/t3code/pull/8", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const saved = yield* Ref.make>([]); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: (_environmentId, snapshot) => Ref.update(saved, (all) => [...all, snapshot]), + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Effect.succeedSome({ + ...v2ShellSnapshot, + snapshotSequence: 3, + // Links never finish decoding before the shell closes. + loadPullRequests: Effect.never.pipe(Effect.as(new Map([[v2ThreadShell.id, [link]]]))), + }), + }); + const scope = yield* Scope.make(); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + Scope.provide(scope), + ); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => Option.isSome(state.snapshot)), + Stream.runHead, + ); + + // Neither the throttled worker nor the closing flush may save the rows without links. + yield* TestClock.adjust("30 seconds"); + yield* Scope.close(scope, Exit.void); + expect(yield* Ref.get(saved)).toEqual([]); + }), + ); + + it.effect("drops an older snapshot's links once a newer snapshot reuses its rows", () => + Effect.gen(function* () { + const link = (number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }); + const firstLinks = yield* Deferred.make(); + const secondLinks = yield* Deferred.make(); + // Set once each snapshot's links start decoding, i.e. once its rows are applied. + const firstFillStarted = yield* Deferred.make(); + const loads = yield* Ref.make(0); + const subscriptions = yield* Ref.make(0); + const client = { + [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => + Stream.unwrap(Ref.update(subscriptions, (n) => n + 1).pipe(Effect.as(Stream.never))), + } as unknown as WsRpcProtocolClient; + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // Both snapshots carry the same row; only its pull request links differ. + const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ + load: () => + Ref.updateAndGet(loads, (n) => n + 1).pipe( + Effect.map((n) => + Option.some({ + ...v2ShellSnapshot, + snapshotSequence: n, + loadPullRequests: (n === 1 + ? Deferred.succeed(firstFillStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstLinks)), + ) + : Deferred.await(secondLinks) + ).pipe(Effect.as(new Map([[v2ThreadShell.id, [link(n)]]]))), + }), + ), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), + ); + yield* Deferred.await(firstFillStarted); + + // A new session loads a second snapshot that reuses the bare row. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter( + (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 2, + ), + Stream.runHead, + ); + + // The superseded first fill finishes first, then the second. + yield* Deferred.succeed(firstLinks, undefined); + yield* Deferred.succeed(secondLinks, undefined); + const filled = yield* SubscriptionRef.changes(shellState).pipe( + Stream.map((state) => Option.getOrThrow(state.snapshot).threads[0]?.pullRequests), + Stream.filter((links) => links !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(filled).toEqual([link(2)]); + }), + ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 2c479489e318..dbc4405a9faa 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -3,10 +3,14 @@ import { type EnvironmentId, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, + type OrchestrationV2ThreadShell, type ServerConfig, + type ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -25,7 +29,13 @@ import { runCachePersistence } from "./cachePersistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; import type { RpcSession } from "../rpc/session.ts"; import * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; -import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; +import type { DeferredShellSnapshot } from "./shellPullRequests.ts"; +import { + applyShellStreamEvent, + mergeShellSnapshotProjects, + reuseUnchangedThreadShells, + sameThreadPullRequests, +} from "./shellReducer.ts"; import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -49,6 +59,12 @@ function shellStatusForSnapshot( return Option.isSome(snapshot) ? "cached" : "empty"; } +function withoutDeferredPullRequests(value: DeferredShellSnapshot): OrchestrationV2ShellSnapshot { + if (value.loadPullRequests === undefined) return value; + const { loadPullRequests: _deferred, ...snapshot } = value; + return snapshot; +} + const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { @@ -57,17 +73,18 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const snapshotLoader = yield* ShellSnapshotLoader.ShellSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; - const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( + const cached = yield* cache.loadShell(environmentId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached environment shell.").pipe( Effect.annotateLogs({ environmentId, ...safeErrorLogAttributes(error), }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ); + const cachedSnapshot = Option.map(cached, withoutDeferredPullRequests); const state = yield* SubscriptionRef.make({ snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), @@ -83,12 +100,16 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const persistenceLock = yield* Semaphore.make(1); let lastPersisted: OrchestrationV2ShellSnapshot | undefined; + // Rows still waiting for their deferred pull request links, which must not be saved: + // the cache would keep them without links until the next server refresh. + let pendingLinkRows: ReadonlySet = new Set(); const persistLatest = Effect.fn("EnvironmentShellState.persistLatest")(function* ( flush: boolean, ) { while (true) { const latest = yield* Ref.get(latestLiveSnapshot); if (Option.isNone(latest) || latest.value === lastPersisted) return; + if (latest.value.threads.some((thread) => pendingLinkRows.has(thread))) return; const snapshot = latest.value; const saved = yield* cache.saveShell(environmentId, snapshot).pipe( Effect.as(true), @@ -155,11 +176,17 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ), ); + // Server items and deferred link fills both read the snapshot and write it back; one at a + // time, so neither can overwrite what the other wrote in between. + const snapshotWriteLock = yield* Semaphore.make(1); + // Apply each received batch with one state write. The RPC client's bounded // buffer can split a server chunk, so a bulk action can still need several // writes, but each write includes every event in that batch. const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* ( items: ReadonlyArray, + // Ids of rows whose links will fill later; they are held out of saves before one queues. + pendingLinkIds?: ReadonlySet, ) { const initial = yield* SubscriptionRef.get(state); let waiting = yield* Ref.get(awaitingCompletion); @@ -201,6 +228,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } yield* Ref.set(awaitingCompletion, waiting); if (next === initial) return; + if (pendingLinkIds !== undefined && Option.isSome(next.snapshot)) { + pendingLinkRows = new Set( + next.snapshot.value.threads.filter((thread) => pendingLinkIds.has(thread.id)), + ); + } if (Option.isSome(next.snapshot)) { yield* Ref.set(latestLiveSnapshot, next.snapshot); } @@ -214,7 +246,81 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") if (next.snapshot !== initial.snapshot && Option.isSome(next.snapshot)) { yield* Queue.offer(persistence, next.snapshot.value); } + }, snapshotWriteLock.withPermit); + + // Rows can arrive before their pull request links (see DeferredShellSnapshot), so the list + // paints first. The links then fill into the rows that still hold the object they arrived + // with; a row the server has replaced since carries its own links. + // Captured so fills started from the subscription callback still end with this state. + const stateScope = yield* Effect.scope; + // Each full snapshot supersedes the previous one's fill; links from an older snapshot must + // not land on rows a newer snapshot reused. A cancelled fill's rows stay pending until the + // next snapshot replaces the pending set. + let activeFill: Fiber.Fiber | undefined; + const cancelActiveFill = Effect.suspend(() => { + const fill = activeFill; + activeFill = undefined; + return fill === undefined ? Effect.void : Fiber.interrupt(fill); }); + const fillDeferredPullRequests = Effect.fnUntraced(function* ( + source: DeferredShellSnapshot, + applied: OrchestrationV2ShellSnapshot, + ) { + yield* cancelActiveFill; + if (source.loadPullRequests === undefined) return; + const arrivedRows = new Set(applied.threads); + const arrivedIds = new Set(source.threads.map((thread) => thread.id)); + pendingLinkRows = new Set(applied.threads.filter((thread) => arrivedIds.has(thread.id))); + activeFill = yield* source.loadPullRequests.pipe( + Effect.flatMap((linksByThreadId) => + writeFilledLinks(linksByThreadId, arrivedRows, arrivedIds), + ), + Effect.forkIn(stateScope), + ); + }); + const writeFilledLinks = Effect.fnUntraced( + function* ( + linksByThreadId: ReadonlyMap>, + arrivedRows: ReadonlySet, + arrivedIds: ReadonlySet, + ) { + const filled = yield* SubscriptionRef.modify( + state, + (current): [Option.Option, EnvironmentShellState] => { + if (Option.isNone(current.snapshot)) return [Option.none(), current]; + const snapshot = current.snapshot.value; + let changed = false; + const threads = snapshot.threads.map((thread) => { + if (!arrivedRows.has(thread) || !arrivedIds.has(thread.id)) return thread; + const links = linksByThreadId.get(thread.id); + // A reused row may already hold these links from an earlier fill. + if (sameThreadPullRequests(thread.pullRequests, links)) return thread; + changed = true; + if (links === undefined) { + const { pullRequests: _previous, ...row } = thread; + return row; + } + return { ...thread, pullRequests: links }; + }); + if (!changed) return [Option.none(), current]; + const next = { ...snapshot, threads }; + return [Option.some(next), { ...current, snapshot: Option.some(next) }]; + }, + ); + // Saves skipped while links were pending are queued again. Cached rows are never live: + // until a server snapshot arrives there is nothing new to save. + pendingLinkRows = new Set(); + const live = yield* Ref.get(latestLiveSnapshot); + if (Option.isNone(live)) return; + const latest = Option.isSome(filled) ? filled : live; + yield* Ref.set(latestLiveSnapshot, latest); + yield* Queue.offer(persistence, latest.value); + }, + (effect) => snapshotWriteLock.withPermit(effect), + ); + if (Option.isSome(cached) && Option.isSome(cachedSnapshot)) { + yield* fillDeferredPullRequests(cached.value, cachedSnapshot.value); + } const foregroundResubscriptions = Option.match(wakeups, { onNone: () => Stream.never, @@ -258,9 +364,23 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ); const httpSnapshot = yield* snapshotLoader.load(prepared); if (Option.isSome(httpSnapshot)) { - yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); + yield* cancelActiveFill; + const previous = yield* SubscriptionRef.get(state); + const snapshot = reuseUnchangedThreadShells( + Option.getOrNull(previous.snapshot), + withoutDeferredPullRequests(httpSnapshot.value), + ); + const pendingLinkIds = new Set( + httpSnapshot.value.loadPullRequests === undefined + ? [] + : httpSnapshot.value.threads.map((thread) => thread.id), + ); + yield* applyItems([{ kind: "snapshot", snapshot }], pendingLinkIds); canResume = true; current = yield* SubscriptionRef.get(state); + if (Option.isSome(current.snapshot)) { + yield* fillDeferredPullRequests(httpSnapshot.value, current.snapshot.value); + } } } @@ -288,7 +408,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, - ).pipe(Stream.runForEachArray(applyItems)), + ).pipe(Stream.runForEachArray((items) => applyItems(items))), ); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { @@ -449,5 +569,6 @@ export function createEnvironmentShellAtoms( export * from "./models.ts"; export * from "./shellCommands.ts"; export * from "./shellReducer.ts"; +export * from "./shellPullRequests.ts"; export * as ShellSnapshotLoader from "./shellSnapshotHttp.ts"; export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellPullRequests.ts b/packages/client-runtime/src/state/shellPullRequests.ts new file mode 100644 index 000000000000..aedc90d25d90 --- /dev/null +++ b/packages/client-runtime/src/state/shellPullRequests.ts @@ -0,0 +1,97 @@ +import { + OrchestrationV2ShellSnapshot, + type ThreadId, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +/** + * A shell snapshot whose threads may arrive before their pull request links are decoded. Those + * links are most of a large shell's decode cost and only feed PR badges, so the thread list + * can paint first. `loadPullRequests` then decodes the links by thread id; until it runs, + * those threads carry no `pullRequests`. + */ +export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot { + readonly loadPullRequests?: Effect.Effect< + ReadonlyMap> + >; +} + +// A zero sleep is a microtask yield, which on React Native runs before the next frame. +// A 1ms sleep takes a host timer, so the rows can render before the deferred decode. +const yieldToHost = Effect.sleep("1 millis"); + +const decodeThreadLinks = Schema.decodeUnknownEffect( + Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))), +); +// Bounds each uninterrupted stretch of decoding, so taps and frames run between batches. +const LINKS_PER_BATCH = 400; + +/** + * Removes each parsed thread row's pull request links from `snapshot` in place and returns + * them in row order, for `deferPullRequests` once the rows are decoded. + */ +export function detachPullRequests(snapshot: unknown): ReadonlyArray { + if (!Predicate.isObject(snapshot)) return []; + const rows: unknown = snapshot.threads; + if (!Array.isArray(rows)) return []; + return rows.map((row: unknown) => { + if (!Predicate.isObject(row)) return undefined; + const links = row.pullRequests; + delete row.pullRequests; + return links; + }); +} + +/** + * Attaches a `loadPullRequests` that decodes the detached links after yielding to the host. + * A thread whose links cannot be read gets none; its row stays usable. + */ +export function deferPullRequests( + snapshot: S, + rawLinks: ReadonlyArray, +): S & Required> { + const threadIds = snapshot.threads.map((thread) => thread.id); + // Schema decoding runs when called, so each thread's decode is built after a yield. + const loadPullRequests = Effect.gen(function* () { + const linksByThreadId = new Map>(); + let batchSize = LINKS_PER_BATCH; + for (const [index, threadId] of threadIds.entries()) { + const raw = rawLinks[index]; + if (raw === undefined) continue; + const size = Array.isArray(raw) ? raw.length : 1; + if (batchSize + size > LINKS_PER_BATCH) { + yield* yieldToHost; + batchSize = 0; + } + batchSize += size; + // An unreadable thread keeps no links; the others still fill in. + const links = yield* decodeThreadLinks(raw).pipe( + Effect.catch((cause) => + Effect.logWarning("Discarding unreadable shell pull request links.", { + threadId, + cause: String(cause), + }).pipe(Effect.as(undefined)), + ), + ); + if (links !== undefined) linksByThreadId.set(threadId, links); + } + return linksByThreadId as ReadonlyMap>; + }); + return { ...snapshot, loadPullRequests }; +} + +const decodeShellSnapshotJson = Schema.decodeUnknownEffect( + Schema.toCodecJson(OrchestrationV2ShellSnapshot), +); + +/** Decodes a JSON shell snapshot with its threads' pull request links deferred. */ +export const decodeShellSnapshotDeferringPullRequests = Effect.fnUntraced(function* ( + json: unknown, +) { + const rawLinks = detachPullRequests(json); + const snapshot = yield* decodeShellSnapshotJson(json); + return deferPullRequests(snapshot, rawLinks); +}); diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 7a3bd4c75254..b7a69b2a5152 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -1,8 +1,12 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; -import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; +import { + applyShellStreamEvent, + mergeShellSnapshotProjects, + reuseUnchangedThreadShells, +} from "./shellReducer.ts"; const repositoryIdentity = { canonicalKey: "github.com/example/repo", @@ -463,3 +467,39 @@ describe("applyShellStreamEvent", () => { expect(next).toBe(v2ShellSnapshot); }); }); + +describe("reuseUnchangedThreadShells", () => { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "pingdotgg/t3code", + number: 3, + url: "https://github.com/pingdotgg/t3code/pull/3", + source: "agent", + linkedAt: "2026-06-20T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const linked = { ...v2ThreadShell, pullRequests: [link] }; + const other = { ...v2ThreadShell, id: ThreadId.make("thread-other"), title: "Other" }; + + it("keeps the previous object for an unchanged row, even without its deferred links", () => { + const previous = { ...v2ShellSnapshot, threads: [linked, other] }; + const { pullRequests: _links, ...withoutLinks } = linked; + const next = reuseUnchangedThreadShells(previous, { + ...v2ShellSnapshot, + snapshotSequence: 5, + threads: [withoutLinks, { ...other, title: "Renamed" }], + }); + expect(next.snapshotSequence).toBe(5); + expect(next.threads[0]).toBe(linked); + expect(next.threads[1]).not.toBe(other); + expect(next.threads[1]?.title).toBe("Renamed"); + }); + + it("returns the incoming snapshot when no row is unchanged", () => { + const incoming = { ...v2ShellSnapshot, threads: [{ ...other, title: "Renamed" }] }; + expect(reuseUnchangedThreadShells({ ...v2ShellSnapshot, threads: [other] }, incoming)).toBe( + incoming, + ); + }); +}); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 331b271d6eb7..f99f5e7681a4 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -1,5 +1,6 @@ import { OrchestrationV2ThreadShell, + ThreadPullRequestLink, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, type OrchestrationV2ShellStreamItem, @@ -7,6 +8,41 @@ import { import * as Schema from "effect/Schema"; const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell); +const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink))); + +/** Whether two rows' pull request links are equal, including both absent. */ +export function sameThreadPullRequests( + left: OrchestrationV2ThreadShell["pullRequests"], + right: OrchestrationV2ThreadShell["pullRequests"], +): boolean { + return left === right || sameLinks(left, right); +} + +/** + * Keep the previous object for each row a full snapshot left unchanged, so the list only + * re-renders rows that changed. Links are compared apart from the rest of the row because a + * deferred snapshot arrives without them; such a row keeps its links until they fill in. + */ +export function reuseUnchangedThreadShells( + previous: OrchestrationV2ShellSnapshot | null, + next: OrchestrationV2ShellSnapshot, +): OrchestrationV2ShellSnapshot { + if (previous === null || previous.threads.length === 0) return next; + const previousById = new Map(previous.threads.map((thread) => [thread.id, thread] as const)); + let reused = 0; + const threads = next.threads.map((thread) => { + const prior = previousById.get(thread.id); + if (prior === undefined) return thread; + const candidate = + thread.pullRequests === undefined && prior.pullRequests !== undefined + ? { ...thread, pullRequests: prior.pullRequests } + : thread; + if (!sameThreadShell(prior, candidate)) return thread; + reused += 1; + return prior; + }); + return reused === 0 ? next : { ...next, threads }; +} function upsertById( items: ReadonlyArray, diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index c4154e009c78..14e57be1fa67 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -1,10 +1,11 @@ -import type { OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; +import { EnvironmentHttpCommonError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { HttpClient } from "effect/http"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientError, type HttpClientResponse } from "effect/http"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import type { PreparedConnection } from "../connection/model.ts"; @@ -14,6 +15,10 @@ import { executeAuthenticatedEnvironmentHttpRequest, withOrchestrationProtocolHeader, } from "./environmentHttpAuth.ts"; +import { + decodeShellSnapshotDeferringPullRequests, + type DeferredShellSnapshot, +} from "./shellPullRequests.ts"; // Long enough for a slow but alive server to finish. On timeout the socket asks // the same server for the same full snapshot, so a short deadline only throws @@ -45,11 +50,45 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, + // The body is decoded here so each thread's pull request links can decode after its + // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps + // the error the credential retry checks for. request: ({ client, headers }) => - client.shellSnapshot({ headers: withOrchestrationProtocolHeader(headers) }), + client + .shellSnapshot({ + headers: withOrchestrationProtocolHeader(headers), + responseMode: "response-only", + }) + .pipe(Effect.flatMap(decodeShellSnapshotResponse)), }); }); +const decodeEnvironmentHttpCommonError = Schema.decodeUnknownEffect( + Schema.toCodecJson(EnvironmentHttpCommonError), +); + +const decodeShellSnapshotResponse = ( + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect< + DeferredShellSnapshot, + HttpClientError.HttpClientError | Schema.SchemaError | EnvironmentHttpCommonError +> => { + if (response.status === 200) { + return response.json.pipe(Effect.flatMap(decodeShellSnapshotDeferringPullRequests)); + } + // A body that is not a declared error keeps its status, as the generated decoder reported. + const statusError = new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request: response.request, response }), + }); + return response.json.pipe( + Effect.flatMap(decodeEnvironmentHttpCommonError), + Effect.matchEffect({ + onFailure: () => Effect.fail(statusError), + onSuccess: Effect.fail, + }), + ); +}; + /** * Loads the environment shell snapshot over HTTP, returning `Option.none()` when * it cannot be loaded (so the caller falls back to the socket-embedded snapshot). @@ -61,7 +100,7 @@ export class ShellSnapshotLoader extends Context.Service< { readonly load: ( prepared: PreparedConnection, - ) => Effect.Effect>; + ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {} @@ -78,14 +117,14 @@ export const layer: Layer.Layer fetchEnvironmentShellSnapshot({ prepared, signer, remoteAuthorization }).pipe( - Effect.map(Option.some), + Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catchCause((cause) => Effect.logWarning( "Could not load the environment shell snapshot over HTTP; using the socket snapshot instead.", ).pipe( Effect.annotateLogs({ cause: Cause.pretty(cause) }), - Effect.as(Option.none()), + Effect.as(Option.none()), ), ), ), From 9fba2093e50f208c52d9041c70a793296e311477 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:36:42 -0400 Subject: [PATCH 041/530] fix(server): replaying a command no longer freezes the server (#17041) Co-authored-by: Claude Opus 5.5 (1M context) --- .../OrchestrationEventStore.sequence.test.ts | 58 +++++++++++++++++++ .../persistence/OrchestrationEventStore.ts | 10 +++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts index 82d4838fb27a..9c7f87b3c9b8 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.sequence.test.ts @@ -272,3 +272,61 @@ it.effect("uses indexed high-water lookups for populated history without OR scan ), ), ); + +it.effect("replays a command's events through the command index, not the sequence range", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + yield* sql` + WITH RECURSIVE history(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 25000 + ) + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, actor_kind, payload_json, metadata_json, application_event_version + ) + SELECT 'history:' || n, 'thread', 'thread', n, 'provider-session.detached', ${occurredAt}, + CASE WHEN n IN (12, 24990) THEN 'retried-command' ELSE 'command:' || n END, + 'server', + '{"providerSessionId":"session","detachedAt":"' || ${occurredAt} || '"}', + '{}', 2 + FROM history + `; + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); + const replayed = yield* store + .readAgentEvents({ commandId: CommandId.make("retried-command") }) + .pipe(Stream.runCollect, Effect.withTracer(tracer)); + assert.deepEqual( + replayed.map((event) => event.sequence), + [12, 24990], + ); + assert.equal(statements.length, 1); + const plan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statements[0]}`, + [0, Number.MAX_SAFE_INTEGER, "retried-command", 500], + ); + assert.match( + plan.map((row) => row.detail).join("\n"), + /SEARCH orchestration_events USING INDEX idx_orch_events_command_id \(command_id=\?/, + ); + }).pipe( + Effect.provide( + OrchestrationEventStore.layer.pipe( + Layer.provideMerge(NodeSqliteClient.layer({ filename: ":memory:" })), + ), + ), + ), +); diff --git a/apps/server/src/persistence/OrchestrationEventStore.ts b/apps/server/src/persistence/OrchestrationEventStore.ts index 588defc0d6ca..ef8b49c2c53f 100644 --- a/apps/server/src/persistence/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/OrchestrationEventStore.ts @@ -407,9 +407,13 @@ const makeEventStore = Effect.gen(function* () { correlation_id FROM orchestration_events ${ - input.onlyAgentEvents === true - ? sql`` - : sql`INDEXED BY idx_orchestration_events_application_high_water` + // The sequence range otherwise wins and a command lookup scans every + // V2 event, which blocks the server for seconds on large histories. + input.commandId !== undefined + ? sql`INDEXED BY idx_orch_events_command_id` + : input.onlyAgentEvents === true + ? sql`` + : sql`INDEXED BY idx_orchestration_events_application_high_water` } WHERE sequence > ${input.afterSequence} AND sequence <= ${input.throughSequence ?? Number.MAX_SAFE_INTEGER} From 04ad17425a80484bf323a7d5076bd477a9499119 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:16:52 -0400 Subject: [PATCH 042/530] fix(server): opening a long thread no longer blocks the server for a second (#17029) Co-authored-by: Claude Opus 5.5 (1M context) --- .../orchestration-v2/ProjectionStore.test.ts | 44 +++++++++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 24 ++++++---- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/055_OrchestrationV2.test.ts | 4 +- .../060_ThreadSnapshotWindowIndexes.ts | 23 ++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 + 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index f47b4d6829e8..194d7507f2e6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/sql/SqlClient"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; @@ -588,12 +589,26 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }); yield* sql`INSERT INTO orchestration_v2_projection_turn_items ${sql.insert(rows)}`; } + const statements: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query === "string") statements.push(query); + }; + return span; + }, + }); const initial = yield* Effect.acquireUseRelease( Effect.sync(() => vi.spyOn(JSON, "parse")), (parse) => projectionStore .getThreadSnapshotWindow(threadId, { rowLimit: 77, userTurnLimit: 10 }) .pipe( + Effect.withTracer(tracer), Effect.tap(() => Effect.sync(() => { // Tool outputs must not be allocated again just to collect cohort IDs. @@ -606,6 +621,35 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ), (parse) => Effect.sync(() => parse.mockRestore()), ); + // Large threads blocked the server for over a second here. The boundary + // must come from the user-message index rather than a scan of every tool + // row, and payloads must not go through a sort after they are fetched. + const windowStatement = statements.find((statement) => statement.includes("turn_anchors")); + assert.isDefined(windowStatement); + const windowPlan = yield* sql.unsafe<{ readonly parent: number; readonly detail: string }>( + `EXPLAIN QUERY PLAN ${windowStatement}`, + ); + assert.match( + windowPlan.map((row) => row.detail).join("\n"), + /SEARCH item USING INDEX orchestration_v2_projection_turn_items_user_message_idx \(thread_id=\? AND ordinal<\?\)/, + ); + const topLevel = windowPlan.filter((row) => row.parent === 0).map((row) => row.detail); + assert.include( + topLevel, + "SEARCH item USING INDEX sqlite_autoindex_orchestration_v2_projection_turn_items_1 (turn_item_id=?)", + ); + assert.notInclude(topLevel, "USE TEMP B-TREE FOR ORDER BY"); + const nodeStatement = statements.find((statement) => + statement.includes("WITH RECURSIVE retained"), + ); + assert.isDefined(nodeStatement); + const nodePlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${nodeStatement}`, + ); + assert.include( + nodePlan.map((row) => row.detail), + "SEARCH orchestration_v2_projection_nodes USING INDEX orchestration_v2_projection_nodes_live_idx (thread_id=?)", + ); // Only the selected turn cohort and two lookahead anchors are decoded. assert.lengthOf(initial.projection.turnItems, 12 * 102); const bounded = buildBoundedThreadProjection({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 07d847e2b91d..4da19774075b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2762,7 +2762,10 @@ export const layer: Layer.Layer = END AS ordinal FROM user_anchors ), selected AS ( - SELECT payload_json, ordinal, turn_item_id, run_id, type + -- Choose and sort rows by ID, then fetch payloads in that + -- order. A turn window can hold megabytes of tool output, and + -- carrying it through the union and sort cost about a second. + SELECT ordinal, turn_item_id, run_id, type FROM eligible WHERE ordinal >= (SELECT ordinal FROM boundary) ORDER BY ordinal DESC, turn_item_id DESC @@ -2771,10 +2774,10 @@ export const layer: Layer.Layer = WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 ELSE ${window.rowLimit} END - ), retained AS ( - SELECT payload_json, ordinal, turn_item_id FROM selected + ), retained AS MATERIALIZED ( + SELECT ordinal, turn_item_id FROM selected UNION - SELECT request.payload_json, request.ordinal, request.turn_item_id + SELECT request.ordinal, request.turn_item_id FROM orchestration_v2_projection_turn_items AS request WHERE request.run_id IN ( SELECT run_id FROM selected @@ -2782,9 +2785,9 @@ export const layer: Layer.Layer = ) AND request.type = 'run_interrupt_request' UNION - SELECT latest.payload_json, latest.ordinal, latest.turn_item_id + SELECT latest.ordinal, latest.turn_item_id FROM ( - SELECT payload_json, ordinal, turn_item_id + SELECT ordinal, turn_item_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND ${window.anchorItemId ?? null} IS NULL @@ -2792,10 +2795,15 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT 1 ) AS latest + ORDER BY ordinal ASC, turn_item_id ASC ) - SELECT payload_json + -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite + -- skips sorting again once the payloads are attached. + SELECT item.payload_json FROM retained - ORDER BY ordinal ASC, turn_item_id ASC + CROSS JOIN orchestration_v2_projection_turn_items AS item + ON item.turn_item_id = retained.turn_item_id + ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Reuse the decoded items for cohort IDs and the resulting projection. // Parsing these rows separately duplicates every retained tool output. diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1e1f207a7dac..35c1a3fdce4b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts" import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; +import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -146,6 +147,7 @@ export const migrationEntries = [ [57, "ScheduledTaskWebhooks", Migration0057], [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], + [60, "ThreadSnapshotWindowIndexes", Migration0060], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5f2e05f2809f..5e45ed05d302 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 59 }, (_, index) => index + 1), + Array.from({ length: 60 }, (_, index) => index + 1), ); }), ); @@ -31,6 +31,7 @@ layer("055_OrchestrationV2", (it) => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -56,6 +57,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 57, name: "ScheduledTaskWebhooks" }, { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, + { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts new file mode 100644 index 000000000000..6977a165a303 --- /dev/null +++ b/apps/server/src/persistence/Migrations/060_ThreadSnapshotWindowIndexes.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // A bounded thread snapshot finds its turn boundary from the newest user + // messages. Without this index it reads every turn item in the thread to + // find a few dozen of them. ProjectionStore's turn_anchors CTE relies on it. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_user_message_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE type = 'user_message' + `; + // The same snapshot keeps every unfinished node. A long thread has thousands + // of finished ones, and reading them all to find a few live ones was more + // than half of the node query's time. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_nodes_live_idx + ON orchestration_v2_projection_nodes(thread_id) + WHERE status IN ('pending', 'starting', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 826dc4831d8d..675d5f5db021 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -40,6 +40,7 @@ describe("V2 preview upgrade", () => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -122,6 +123,7 @@ describe("V2 preview upgrade", () => { [57, "ScheduledTaskWebhooks"], [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], + [60, "ThreadSnapshotWindowIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 0f09627a111f7e9adc22c256c725565672676f1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 20:59:26 -0700 Subject: [PATCH 043/530] fix(server,web,mobile): thread links reference the thread id, not a baked title (#17017) --- .../src/features/threads/ThreadFeed.tsx | 30 +++-- .../mobile/src/lib/nativeMarkdownText.test.ts | 4 +- apps/mobile/src/state/entities.ts | 36 +++++ apps/server/scripts/migrate-dev-db.test.ts | 59 ++++++++ apps/server/scripts/migrate-dev-db.ts | 126 ++++++++++++++---- apps/server/src/mcp/OrchestratorMcpService.ts | 35 +---- ...OrchestratorMcpToolkit.integration.test.ts | 3 - .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- .../src/mcp/toolkits/project/handlers.test.ts | 16 +-- .../src/mcp/toolkits/project/handlers.ts | 8 +- apps/server/src/mcp/toolkits/project/tools.ts | 4 +- .../provider/T3OrchestrationInstructions.ts | 1 + apps/web/src/components/ChatMarkdown.tsx | 15 ++- .../components/chat/MarkdownThreadLink.tsx | 28 ++-- .../orchestrator-mcp-server.md | 9 +- packages/contracts/src/orchestratorMcp.ts | 4 - packages/shared/src/threadLinks.test.ts | 85 +++++++++--- packages/shared/src/threadLinks.ts | 97 ++++++++------ 18 files changed, 384 insertions(+), 180 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b5f258a0889f..b2e10812cb5e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -198,6 +198,7 @@ import { } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; +import { useLiveThreadLinkLabels } from "../../state/entities"; import { useThreadSelection } from "../../state/use-thread-selection"; import { composerDocumentAttachmentRecord } from "../../lib/composerContext"; import * as Option from "effect/Option"; @@ -906,16 +907,15 @@ interface MarkdownLinkHandlers { const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; + readonly environmentId: EnvironmentId; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { - const segments = useMemo( - () => splitCodexArtifactTemplateMarkdown(props.markdown), - [props.markdown], - ); + const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); + const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); return segments.map((segment) => { if (segment.kind === "artifact-template") { @@ -1905,6 +1905,7 @@ function renderFeedEntry( (null); const navigation = useNavigation(); const { selectedThread } = useThreadSelection(); - const text = replaceComposerContextReferences(props.text, (ref) => { + const liveText = useLiveThreadLinkLabels(props.text, props.environmentId); + const text = replaceComposerContextReferences(liveText, (ref) => { const available = props.context?.records.some((record) => record.contextId === ref.contextId); return `[${ref.label}${available ? "" : " (unavailable)"}](t3-context://v1/${ref.kind}/${ref.contextId})`; }); @@ -2292,11 +2294,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { - const threadLink = parseThreadLinkHref(href); - if (threadLink) { + // A thread link names a thread in this feed's environment. + const linkedThreadId = parseThreadLinkHref(href); + if (linkedThreadId) { navigation.navigate("Thread", { - environmentId: String(threadLink.environmentId), - threadId: String(threadLink.threadId), + environmentId: String(props.environmentId), + threadId: String(linkedThreadId), }); return; } @@ -2504,13 +2507,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (text: string) => ( ), - [markdownStyles.assistant, markdownLinkHandlers, renderMarkdownImage, props.skills], + [ + markdownStyles.assistant, + markdownLinkHandlers, + renderMarkdownImage, + props.skills, + props.environmentId, + ], ); const reviewCommentColors = useReviewCommentColors(); const unsettledTurnId = threadFeedRunIsUnsettled(props.latestRun) ? props.latestRun.runId : null; diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 62f0de0c1b95..e43685407f85 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -91,12 +91,12 @@ describe("nativeMarkdownTextRuns", () => { children: [ { type: "link", - href: "t3-thread://v1/env/thread-1", + href: "t3-thread://v1/thread-1", children: [{ type: "text", content: "Fix the build" }], }, ], }), - ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/env/thread-1" }]); + ).toEqual([{ text: "Fix the build", href: "t3-thread://v1/thread-1" }]); }); it("preserves the destination of a link with a code-formatted label", () => { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 94ab662c7291..0d3b33d582d5 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -12,8 +12,11 @@ import type { ScopedProjectRef, ScopedThreadRef, ServerConfig, + ThreadId, } from "@t3tools/contracts"; +import { hasThreadLinks, relabelThreadLinks } from "@t3tools/shared/threadLinks"; import { Atom } from "effect/reactivity"; +import { useMemo } from "react"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom, serverEnvironment } from "./server"; @@ -28,6 +31,25 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-server-config:empty"), ); +const EMPTY_THREAD_TITLES: ReadonlyMap = new Map(); +const EMPTY_THREAD_TITLES_ATOM = Atom.make(EMPTY_THREAD_TITLES).pipe( + Atom.withLabel("mobile-thread-titles:empty"), +); + +/** Thread titles in one environment. Emits when a title changes, not on every shell update. */ +const threadTitlesAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_THREAD_TITLES; + return Atom.make((get) => { + const index = get(environmentThreadShells.environmentThreadIndexAtom(environmentId)); + const unchanged = + index.size === previous.size && + Array.from(index).every(([threadId, shell]) => previous.get(threadId) === shell.title); + if (!unchanged) { + previous = new Map(Array.from(index, ([threadId, shell]) => [threadId, shell.title])); + } + return previous; + }).pipe(Atom.withLabel(`mobile-thread-titles:${environmentId}`)); +}); /** Resolves when the project event reaches the live client store. */ export function waitForProject( @@ -76,6 +98,20 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +/** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ +export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { + const titles = useAtomValue( + hasThreadLinks(markdown) ? threadTitlesAtom(environmentId) : EMPTY_THREAD_TITLES_ATOM, + ); + return useMemo( + () => + titles.size === 0 + ? markdown + : relabelThreadLinks(markdown, (threadId) => titles.get(threadId)), + [markdown, titles], + ); +} + export function useEnvironmentServerConfig( environmentId: EnvironmentId | null, ): ServerConfig | null { diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 731a67d94cb6..07d3dc61c791 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -142,6 +142,65 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + it.effect("never copies a settled thread's rows, and new events append after the source's", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-dest-" }); + const source = yield* createFixtureSource(sourceDir); + const [sourceSequence] = yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + }), + ); + + // Keep every family, so only the copy can leave the settled one out. + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 100 }, + { sharedHome: sourceDir }, + ); + + const copied = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [settledRows] = yield* sql<{ count: number }>` + SELECT + (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread') + + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread') + AS count`; + const [sequence] = yield* sql<{ seq: number }>` + SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; + return { settledRows: settledRows?.count, sequence: sequence?.seq }; + }), + ); + assert.equal(copied.settledRows, 0); + assert.equal(copied.sequence, sourceSequence?.seq); + }), + ); + + it.effect("upgrades a source from before the V2 thread tables", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-dest-" }); + const stateDir = path.join(sourceDir, "userdata"); + const source = path.join(stateDir, "statev2.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase(source, runMigrations({ toMigrationInclusive: 54 })); + + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ); + assert.include(result.executedMigrations, "55_OrchestrationV2"); + }), + ); + it.effect("fails loudly on a migration slot collision", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 3e9121fa2a9f..4cfdf6233346 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -6,9 +6,12 @@ * * `vp run migrate-dev-db` from a worktree: * 1. Nukes `/.t3/userdata/statev2.sqlite`. - * 2. Snapshots the real db (`~/.t3/userdata/statev2.sqlite`, read-only - * VACUUM INTO) and prunes it to the most recently updated projects and, - * per project, the most recent threads that have fully stopped, with + * 2. Copies a slice of the real db (`~/.t3/userdata/statev2.sqlite`, + * attached read-only): the schema, and every row except those of + * deleted, archived, and settled threads, which are nearly all of a + * long-lived database. It then prunes that to the most recently updated + * projects and, per project, the most recent threads that have fully + * stopped, with * their forks and subagents. Working, settled, and archived threads, and * threads with pending recovery, are skipped, and scheduled tasks and * queued effects are dropped, so the dev server never adopts live work. @@ -28,6 +31,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; @@ -242,6 +246,87 @@ const RECOVERY_KINDS: ReadonlyArray = [ "delegated-completions", ]; +/** Tables the prune empties. The copy skips their rows. */ +const CLEARED_TABLES: ReadonlyArray = [ + // Pending work the dev server would otherwise pick up and run. + "scheduled_tasks", + "orchestration_v2_effect_outbox", + "orchestration_v2_thread_launch_workflows", + "orchestration_command_receipts", + "provider_session_runtime", + "auth_sessions", + "auth_pairing_links", +]; + +/** Shared between threads; the prune keeps the ones a kept thread is bound to. */ +const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; + +/** Whether the thread aliased `root` is shown: not deleted, archived, or settled. */ +const rootIsVisible = (sql: SqlClient.SqlClient) => sql` + root.deleted_at IS NULL + AND json_extract(root.payload_json, '$.deletedAt') IS NULL + AND json_extract(root.payload_json, '$.archivedAt') IS NULL + AND json_extract(root.payload_json, '$.settledAt') IS NULL + AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`; + +/** + * Copies the source into the empty snapshot, without the rows of thread families whose root is + * hidden. The prune drops those anyway, and they hold nearly all of a long-lived database's + * events, so copying them is what used to make this take a full-size copy of the real db. + * A source from before the V2 thread tables has no visibility to filter on, so it is copied + * whole. One transaction reads the source, so the copy is consistent while the real server writes. + */ +const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`; + const schema = yield* sql<{ type: string; name: string; sql: string }>` + SELECT type, name, sql FROM src.sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; + const threadColumns = yield* sql<{ name: string; notnull: number }>` + SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c + WHERE m.type = 'table' AND c.name = 'thread_id'`; + const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); + const tables = schema.filter((entry) => entry.type === "table"); + const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); + + yield* sql.withTransaction( + Effect.gen(function* () { + for (const table of tables) yield* sql.unsafe(table.sql).unprepared; + if (filtered) { + yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; + yield* sql`INSERT OR IGNORE INTO copied_threads + SELECT family.thread_id FROM src.orchestration_v2_projection_threads family + JOIN src.orchestration_v2_projection_threads root ON root.thread_id = + COALESCE(json_extract(family.payload_json, '$.lineage.rootThreadId'), family.thread_id) + WHERE ${rootIsVisible(sql)}`; + } + for (const { name } of tables) { + if (CLEARED_TABLES.includes(name)) continue; + const nullable = threadIdNullable.get(name); + const filter = !filtered + ? "" + : name === "orchestration_events" + ? // Two ranges rather than `<>`, so SQLite seeks the (aggregate_kind, stream_id) + // index instead of scanning every event. + `WHERE (aggregate_kind = 'thread' AND stream_id IN (SELECT thread_id FROM copied_threads)) + OR aggregate_kind < 'thread' OR aggregate_kind > 'thread'` + : nullable === undefined || name === PROVIDER_SESSIONS_TABLE + ? "" + : `WHERE thread_id IN (SELECT thread_id FROM copied_threads)${nullable ? " OR thread_id IS NULL" : ""}`; + yield* sql.unsafe(`INSERT INTO main."${name}" SELECT * FROM src."${name}" ${filter}`) + .unprepared; + } + // The source's AUTOINCREMENT high-water marks, so new events append after them. + yield* sql`DELETE FROM main.sqlite_sequence`; + yield* sql`INSERT INTO main.sqlite_sequence SELECT name, seq FROM src.sqlite_sequence`; + for (const entry of schema) { + if (entry.type !== "table") yield* sql.unsafe(entry.sql).unprepared; + } + }), + ); + yield* sql`DETACH DATABASE src`; +}); + const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigrateDevDbInput) { const sql = yield* SqlClient.SqlClient; @@ -278,11 +363,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at FROM thread_families f JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id - WHERE root.deleted_at IS NULL - AND json_extract(root.payload_json, '$.deletedAt') IS NULL - AND json_extract(root.payload_json, '$.archivedAt') IS NULL - AND json_extract(root.payload_json, '$.settledAt') IS NULL - AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled' + WHERE ${rootIsVisible(sql)} GROUP BY f.root_id, root.project_id HAVING SUM(f.thread_id IN (SELECT thread_id FROM live_threads)) = 0`; @@ -323,13 +404,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra const threadTables = yield* sql<{ name: string }>` SELECT m.name FROM sqlite_master m, pragma_table_info(m.name) c WHERE m.type = 'table' AND c.name = 'thread_id' - AND m.name <> 'orchestration_v2_projection_provider_sessions'`; + AND m.name <> ${PROVIDER_SESSIONS_TABLE}`; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`DELETE FROM projection_projects WHERE project_id NOT IN (SELECT project_id FROM kept_projects)`; - yield* sql`DELETE FROM orchestration_v2_projection_provider_sessions + yield* sql`DELETE FROM ${sql(PROVIDER_SESSIONS_TABLE)} WHERE COALESCE(thread_id, '') NOT IN (SELECT thread_id FROM kept_threads) AND provider_session_id NOT IN ( SELECT provider_session_id FROM orchestration_v2_projection_provider_session_bindings @@ -348,14 +429,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra AND stream_id NOT IN (SELECT thread_id FROM kept_threads)) OR (aggregate_kind = 'project' AND stream_id NOT IN (SELECT project_id FROM kept_projects))`; - // Pending work the dev server would otherwise pick up and run. - yield* sql`DELETE FROM scheduled_tasks`; - yield* sql`DELETE FROM orchestration_v2_effect_outbox`; - yield* sql`DELETE FROM orchestration_v2_thread_launch_workflows`; - yield* sql`DELETE FROM orchestration_command_receipts`; - yield* sql`DELETE FROM provider_session_runtime`; - yield* sql`DELETE FROM auth_sessions`; - yield* sql`DELETE FROM auth_pairing_links`; + for (const table of CLEARED_TABLES) { + yield* sql`DELETE FROM ${sql(table)}`; + } }), ); @@ -457,22 +533,16 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( ); yield* removeDatabaseFiles(snapshotPath); - // The snapshot is a full-size copy of the source; make sure it is removed - // even when a phase fails partway through. + // Make sure the snapshot is removed even when a phase fails partway through. const { executedMigrations, pruned } = yield* Effect.gen(function* () { - yield* Console.log(`Snapshotting ${sourcePath} (read-only)...`); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`VACUUM INTO ${snapshotPath}`; - }).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: sourcePath, readonly: true })), + yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`); + yield* copySourceSlice(sourcePath).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), wrapPhase("snapshot", sourcePath), ); // Migrate before pruning: a source older than this checkout would // otherwise crash the prune queries on columns that don't exist yet. - // Running against the full snapshot also exercises new migrations on the - // same data volume the real database would face. yield* Console.log("Running migrations on the snapshot..."); const executed = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index dccda1b49519..ebb1aa9e4c1a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,5 +1,4 @@ import { - type EnvironmentId, CommandId, type RunId, isProviderAvailable, @@ -59,7 +58,6 @@ import { ThreadId, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -633,23 +631,12 @@ function threadSnooze( }; } -/** Where and when a thread is shown: the environment for its link, and the time for snooze state. */ -interface ThreadViewContext { - readonly environmentId: EnvironmentId; - readonly nowMs: number; -} - function listItemFromShell( shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadListItem { return { threadId: shell.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: shell.id, - title: shell.title, - }), title: shell.title, createdBy: shell.createdBy, creationSource: shell.creationSource, @@ -661,7 +648,7 @@ function listItemFromShell( interactionMode: shell.interactionMode, linkedPullRequest: shell.linkedPullRequest ?? null, ...threadSettlement(shell), - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -674,17 +661,12 @@ function threadDetail( projection: Pick, itemCount: number, shell: OrchestrationV2ThreadShell, - context: ThreadViewContext, + nowMs: number, ): OrchestratorMcpThreadDetail { const latest = ThreadManagementService.latestRun(projection); const active = ThreadManagementService.latestActiveRun(projection); return { threadId: projection.thread.id, - link: formatThreadLink({ - environmentId: context.environmentId, - threadId: projection.thread.id, - title: projection.thread.title, - }), projectId: projection.thread.projectId, title: projection.thread.title, createdBy: projection.thread.createdBy, @@ -717,7 +699,7 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), // From the shell, like the list, so read and list agree on snooze state. - ...threadSnooze(shell, context.nowMs), + ...threadSnooze(shell, nowMs), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; @@ -2293,9 +2275,7 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => - listItemFromShell(shell, { environmentId: scope.environmentId, nowMs }), - ), + threads: page.map((shell) => listItemFromShell(shell, nowMs)), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; @@ -2375,10 +2355,7 @@ const make = Effect.gen(function* () { } } return { - thread: threadDetail(target, timeline.totalItems, shell, { - environmentId: scope.environmentId, - nowMs: yield* Clock.currentTimeMillis, - }), + thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis), recentRuns: target.runs .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 673830dd66d3..e59f8fff9a05 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -2233,9 +2233,6 @@ describe("orchestrator MCP toolkit", () => { }); expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); - expect(metadataRead.thread.link).toBe( - `[Metadata-managed thread](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(emptyThread.threadId)})`, - ); yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make("command:mcp-empty:snooze"), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 256b08638ac0..e7ef07101a39 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -178,7 +178,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. Each thread has a link: paste it when you mention the thread so the user can open it.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early when it asks for something, fails, or completes. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -192,7 +192,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread has a link and snooze state: paste the link when you mention the thread so the user can open it.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 255829ff094c..d657938ff206 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -69,7 +69,7 @@ it.effect("attributes a launched thread's first message to the calling thread", return Effect.succeed({ threadId: input.threadId, projection: { - thread: { id: input.threadId, projectId, modelSelection, title: input.title }, + thread: { id: input.threadId, projectId, modelSelection }, runs: [], }, resumed: false, @@ -94,11 +94,7 @@ it.effect("attributes a launched thread's first message to the calling thread", const result = yield* toolkit .handle("t3_thread_launch", { title: "Audit", message: "Review the change" }) .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - expect(result.at(-1)?.result).toMatchObject({ - projectId, - modelSelection, - link: expect.stringMatching(/^\[Audit\]\(t3-thread:\/\/v1\/environment\//), - }); + expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); expect(launchedSender).toBe(sourceThreadId); }), ); @@ -144,12 +140,7 @@ it.effect("launches a scratch thread into the Scratch project", () => return Effect.succeed({ threadId: input.threadId, projection: { - thread: { - id: input.threadId, - projectId: input.projectId, - modelSelection, - title: input.title, - }, + thread: { id: input.threadId, projectId: input.projectId, modelSelection }, runs: [], }, resumed: false, @@ -338,7 +329,6 @@ const clientLaunchHarness = (input: { id: launch.threadId, projectId: launch.projectId, modelSelection: launch.modelSelection, - title: launch.title, }, runs: [], }, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 4f3b66e8dc70..9a7d7024111e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -1,5 +1,4 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -58,7 +57,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input, { runtimeMode, interactionMode }) => Effect.gen(function* () { const context = yield* readCaller(); - const { caller, scope } = context; + const { caller } = context; const commandId = yield* newCommandId(); const threadId = ThreadId.make(commandId); const messageId = MessageId.make(commandId); @@ -147,11 +146,6 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, - link: formatThreadLink({ - environmentId: scope.environmentId, - threadId: thread.id, - title: thread.title, - }), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index bb81b8c83e0e..ee489d4e104c 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -103,7 +103,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", { const ThreadLaunchTool = Tool.make("t3_thread_launch", { ...shared, description: - 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', + 'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread\'s current title. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.', parameters: Schema.Struct({ projectId: Schema.optional(ProjectId), scratch: Schema.optional( @@ -132,8 +132,6 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index fe700c0e2084..2280d4d25619 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -11,6 +11,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. +- To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title. ### Choose the workspace before starting a new thread diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e595efdc5a07..d926f5aaf412 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3167,13 +3167,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; - // A thread link opens the thread here, never a browser. - const threadLink = href ? parseThreadLinkHref(href) : null; - if (threadLink) { - return ( - - {children} - + // A thread link names a thread in this message's environment and opens it in the app. + const linkedThreadId = href ? parseThreadLinkHref(href) : null; + if (linkedThreadId) { + const label = hastPlainTextDeep(node) || linkedThreadId; + return environmentId ? ( + + ) : ( + {label} ); } const contextReference = href ? parseComposerContextHref(href) : null; diff --git a/apps/web/src/components/chat/MarkdownThreadLink.tsx b/apps/web/src/components/chat/MarkdownThreadLink.tsx index cc3f5bdb0efb..4b1b44d7c62b 100644 --- a/apps/web/src/components/chat/MarkdownThreadLink.tsx +++ b/apps/web/src/components/chat/MarkdownThreadLink.tsx @@ -1,31 +1,41 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { formatThreadLink, percentDecodedThreadLinkId } from "@t3tools/shared/threadLinks"; import { Link } from "@tanstack/react-router"; import { MessageSquareTextIcon } from "lucide-react"; -import type { ReactNode } from "react"; import { useProject, useThreadShell } from "../../state/entities"; import { ProjectFavicon } from "../ProjectFavicon"; /** - * A `t3-thread://` link in chat. It leads with the thread's project icon, like - * a web link leads with its favicon, so it reads as a thread link before you - * hover it. Opens the thread in the app. + * A `t3-thread://` link in chat. It shows the thread's current title, so a rename + * reaches every message that links to it, and leads with the thread's project icon + * the way a web link leads with its favicon. `label` is what the message wrote, + * shown only when this client cannot see the thread. Opens the thread in the app. */ export function MarkdownThreadLink(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; - readonly children: ReactNode; + readonly label: string; }) { - const thread = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decodedId = percentDecodedThreadLinkId(props.threadId); + const written = useThreadShell(scopeThreadRef(props.environmentId, props.threadId)); + const decoded = useThreadShell( + written === null && decodedId !== null ? scopeThreadRef(props.environmentId, decodedId) : null, + ); + const thread = written ?? decoded; + const threadId = thread?.id ?? props.threadId; const project = useProject( thread === null ? null : scopeProjectRef(props.environmentId, thread.projectId), ); + const title = thread?.title.trim() || props.label; return ( )} - {props.children} + {title} ); } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 08c2f19bdabf..5fc670a99c6d 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,9 +352,12 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -List, read, and launch results include `link`, a Markdown link of the form -`[title](t3-thread://v1//)` that clients open as the -thread. List and read results also report `snoozed` and `snoozedUntil`, and +Agents mention another thread as `[title](t3-thread://v1/)`. The +link carries only the id, which resolves in the environment of the message that +holds it. Clients show the thread's current title rather than the label, so a +rename never leaves a stale link. + +List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early when it has a pending request, fails, or completes after the snooze. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d13cf419f19f..0ab3e0b9b86c 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -304,8 +304,6 @@ export type OrchestratorMcpThreadListInput = typeof OrchestratorMcpThreadListInp export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, title: Schema.String, createdBy: OrchestrationV2Actor, creationSource: OrchestrationV2CreationSource, @@ -353,8 +351,6 @@ export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInp export const OrchestratorMcpThreadDetail = Schema.Struct({ threadId: ThreadId, - /** Paste this whenever you mention the thread, so the user can click to open it. */ - link: Schema.String, projectId: ProjectId, title: Schema.String, createdBy: OrchestrationV2Actor, diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index b6f853e9827f..087aa8894f6a 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -1,28 +1,75 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatThreadLink, parseThreadLinkHref } from "./threadLinks.ts"; +import { formatThreadLink, parseThreadLinkHref, relabelThreadLinks } from "./threadLinks.ts"; describe("thread links", () => { - it("round-trips ids that contain URL characters", () => { - const link = formatThreadLink({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - title: "Fix [the] build\nnow", - }); - const href = /\]\((.+)\)$/.exec(link)![1]!; - expect(link.startsWith("[Fix the build now](")).toBe(true); - // A raw parenthesis would end the Markdown link early. - expect(href).not.toMatch(/[()]/); - expect(parseThreadLinkHref(href)).toEqual({ - environmentId: "studio mac", - threadId: "mcp:(1)/2", - }); + it("takes the thread id verbatim, percent escapes included", () => { + expect(parseThreadLinkHref("t3-thread://v1/mcp:1234")).toBe("mcp:1234"); + expect(parseThreadLinkHref("t3-thread://v1/thread:delegated-task:mcp%3A1")).toBe( + "thread:delegated-task:mcp%3A1", + ); }); - it("rejects other links and malformed ones", () => { + it("rejects other links and an empty id", () => { expect(parseThreadLinkHref("https://t3.codes")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/only-one")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/env/%E0%A4%A")).toBeNull(); - expect(parseThreadLinkHref("t3-thread://v1/%20/thread")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/")).toBeNull(); + expect(parseThreadLinkHref("t3-thread://v1/ ")).toBeNull(); + }); + + it("resolves a percent-encoded id when the id as written names no thread", () => { + const titles = new Map([ + ["thread:project:1", "Decoded"], + ["provider%3A1", "Literal escape"], + ]); + expect( + relabelThreadLinks( + "[a](t3-thread://v1/thread%3Aproject%3A1) [b](t3-thread://v1/provider%3A1)", + (threadId) => titles.get(threadId), + ), + ).toBe( + "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)", + ); + // A thread with an empty title still exists, so its link is not redirected. + const untitled = new Map([ + ["a%3A1", ""], + ["a:1", "Other"], + ]); + expect( + relabelThreadLinks("[Kept](t3-thread://v1/a%3A1)", (threadId) => untitled.get(threadId)), + ).toBe("[Kept](t3-thread://v1/a%3A1)"); + }); + + it("leaves links inside code spans and fences as written", () => { + const markdown = [ + "Live [old](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [old](t3-thread://v1/t1)", + ].join("\n"); + expect(relabelThreadLinks(markdown, () => "New")).toBe( + [ + "Live [New](t3-thread://v1/t1), literal `[old](t3-thread://v1/t1)`.", + "```md", + "[old](t3-thread://v1/t1)", + "```", + "After [New](t3-thread://v1/t1)", + ].join("\n"), + ); + }); + + it("formats a label that would otherwise break the Markdown link", () => { + expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); + expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); + }); + + it("relabels links with the current title and leaves unknown threads alone", () => { + const titles = new Map([["renamed", "Fix [the] build\nnow"]]); + expect( + relabelThreadLinks( + "See [Old name](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted).", + (threadId) => titles.get(threadId), + ), + ).toBe("See [Fix the build now](t3-thread://v1/renamed) and [Gone](t3-thread://v1/deleted)."); }); }); diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 179350c1ee6b..15384aeea4d4 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -1,58 +1,73 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; /** - * In-app links to threads. Agents put them in Markdown as - * `[title](t3-thread://v1//)`, and clients open the - * thread instead of a browser. Thread tools return a ready `link` for each - * thread, so agents never build one by hand. + * Agents mention another thread in Markdown as `[title](t3-thread://v1/)`. The id is + * the reference and resolves in the environment of the message that holds it. Titles change, so + * clients show the thread's current title; the label only stands in for a thread they cannot see. */ export const THREAD_LINK_PROTOCOL = "t3-thread"; const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; -const LINK_LABEL_MAX_CHARS = 120; +// Code comes first in the alternation so a link written inside a code span or fence is skipped. +const THREAD_LINK_OUTSIDE_CODE = + /(?(`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(`+)[^\n]*?\4)|\[[^\]\n]*\]\((?t3-thread:\/\/v1\/[^\s)]+)\)/g; -const decodeEnvironmentId = Schema.decodeUnknownOption(EnvironmentId); const decodeThreadId = Schema.decodeUnknownOption(ThreadId); -// encodeURIComponent keeps parentheses, and a raw `)` would end the Markdown link early. -function encodeIdSegment(id: string): string { - return encodeURIComponent(id).replace(/\(/g, "%28").replace(/\)/g, "%29"); -} - -function formatThreadLinkHref(environmentId: string, threadId: string): string { - return `${THREAD_LINK_HREF_PREFIX}${encodeIdSegment(environmentId)}/${encodeIdSegment(threadId)}`; -} - -/** A Markdown link to the thread, labeled with its title. */ -export function formatThreadLink(thread: { - readonly environmentId: string; - readonly threadId: string; - readonly title: string; -}): string { - const label = - thread.title - .replace(/[[\]\\\r\n]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, LINK_LABEL_MAX_CHARS) || "Untitled thread"; - return `[${label}](${formatThreadLinkHref(thread.environmentId, thread.threadId)})`; +/** The id as written. Thread ids can hold percent escapes of their own, so it is not decoded. */ +export function parseThreadLinkHref(href: string): ThreadId | null { + if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; + return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } -export function parseThreadLinkHref( - href: string, -): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { - if (!href.startsWith(THREAD_LINK_HREF_PREFIX)) return null; - const parts = href.slice(THREAD_LINK_HREF_PREFIX.length).split("/"); - if (parts.length !== 2) return null; +/** + * Agents often percent-encode the id anyway. When the id as written names no thread, clients try + * this decoded form. Null when decoding changes nothing or fails. + */ +export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { try { - const environmentId = decodeEnvironmentId(decodeURIComponent(parts[0]!)); - const threadId = decodeThreadId(decodeURIComponent(parts[1]!)); - return Option.isSome(environmentId) && Option.isSome(threadId) - ? { environmentId: environmentId.value, threadId: threadId.value } - : null; + const decoded = decodeURIComponent(threadId); + return decoded === threadId ? null : Option.getOrNull(decodeThreadId(decoded)); } catch { - // Malformed percent encoding. return null; } } + +/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ +export function formatThreadLink(threadId: string, label: string): string { + const cleaned = label + .replace(/[[\]\\\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return `[${cleaned || threadId}](${THREAD_LINK_HREF_PREFIX}${threadId})`; +} + +export function hasThreadLinks(markdown: string): boolean { + return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); +} + +/** + * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from. + * A link it returns nothing for keeps its label. + */ +export function relabelThreadLinks( + markdown: string, + title: (threadId: ThreadId) => string | undefined, +): string { + if (!hasThreadLinks(markdown)) return markdown; + return markdown.replace(THREAD_LINK_OUTSIDE_CODE, (source, ...args) => { + const href = (args.at(-1) as { href?: string }).href; + if (href === undefined) return source; + const written = parseThreadLinkHref(href); + if (written === null) return source; + // The decoded id only stands in when the id as written names no thread. + const decoded = percentDecodedThreadLinkId(written); + const threadId = + title(written) === undefined && decoded !== null && title(decoded) !== undefined + ? decoded + : written; + const label = title(threadId)?.trim(); + return label ? formatThreadLink(threadId, label) : source; + }); +} From 6f83358ae6d85b5dee7d3280e645e0e9a0aeff62 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:01 -0700 Subject: [PATCH 044/530] fix(web): messages with quotes or links no longer collapse when short (#16624) --- .../components/chat/AssistantCitationChip.tsx | 8 ++-- .../chat/MessagesTimeline.logic.test.ts | 37 ++++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 31 ++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 14 +------ packages/shared/src/assistantCitations.ts | 5 +++ 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index e03872553a88..9f5d3a2e1e55 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -1,5 +1,8 @@ import type { AssistantCitation } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { + assistantCitationLabel, + serializeAssistantCitation, +} from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; import { PencilIcon, QuoteIcon } from "lucide-react"; import { @@ -94,8 +97,7 @@ export function AssistantCitationChip({ }, } : undefined; - const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); - const label = preview.length > 64 ? `${preview.slice(0, 64)}โ€ฆ` : preview; + const label = assistantCitationLabel(citation); const sourceLinkProps = { to: "/$environmentId/$threadId" as const, params: { environmentId: citation.environmentId, threadId: citation.threadId }, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 98339041f68b..93fc89598be8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -21,12 +21,14 @@ import { import { makeStreamingTimelineFixture } from "../../test-fixtures"; import type { TurnDiffSummary } from "../../types"; import { describe, expect, it } from "vite-plus/test"; -import { MessageId, RunId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, deriveMessagesTimelineRows, deriveMessagesTimelineRowsWithState, + shouldCollapseUserMessage, liveWorkEntryLabel, normalizeCompactToolLabel, resolveAssistantMessageCopyState, @@ -5162,3 +5164,36 @@ describe("live subagents after their parent turn settles", () => { expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); }); }); + +describe("shouldCollapseUserMessage", () => { + it("measures a quote chip by its label, not its encoded link", () => { + const quote = "A long assistant paragraph that the user quoted. ".repeat(40); + const citation = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("source"), + text: quote, + comment: "Why does this matter?", + start: 0, + end: quote.length, + prefix: "", + suffix: "", + }); + + expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); + expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); + }); + + it("measures file links and context chips by their label", () => { + const links = Array.from( + { length: 8 }, + (_, index) => + `[file${index}.ts](/workspace/projects/example/packages/some/deeply/nested/directory/file${index}.ts)`, + ); + const text = `Compare ${links.join(", ")} with [terminal 1](t3-context://v1/terminal/${"a".repeat(36)}).`; + + expect(text.length).toBeGreaterThan(600); + expect(shouldCollapseUserMessage(text)).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 74f3991396ef..4a81905eaa2b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,6 +2,10 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; +import { + assistantCitationLabel, + collectAssistantCitations, +} from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; import { @@ -2226,3 +2230,30 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean } } } + +const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; +const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; +const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g; + +function visibleUserMessageText(text: string): string { + const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1"); + let visible = ""; + let cursor = 0; + for (const match of collectAssistantCitations(text)) { + visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); + cursor = match.end; + } + return visible + linkLabels(text.slice(cursor)); +} + +export function shouldCollapseUserMessage(text: string): boolean { + const visible = visibleUserMessageText(text); + if (visible.trim().length === 0) { + return false; + } + + return ( + visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || + visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6145098f6043..8fd9789d9d33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -207,6 +207,7 @@ import { resolveTimelineMinimapNavigationInteractive, resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, + shouldCollapseUserMessage, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, threadReadLabelPrefix, @@ -4522,22 +4523,9 @@ function UserMessageContextReferenceChip(props: { }); } -const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; -const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; -function shouldCollapseUserMessage(text: string): boolean { - if (text.trim().length === 0) { - return false; - } - - return ( - text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES - ); -} - const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index e27e35f352bb..86ca0701f20f 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -98,6 +98,11 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu } } +export function assistantCitationLabel(citation: AssistantCitation): string { + const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); + return preview.length > 64 ? `${preview.slice(0, 64)}โ€ฆ` : preview; +} + export function serializeAssistantCitation(citation: AssistantCitation): string { return `[Assistant quote](${formatAssistantCitationHref(citation)})`; } From 843c593ab2dfe432abfba7b928292f3f8e9c7d44 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:18:29 -0700 Subject: [PATCH 045/530] refactor(web): PR loading skeleton shares the detail panel's layout (#15583) --- .../pullRequest/PullRequestDetailLayout.tsx | 130 ++++++ .../pullRequest/PullRequestDetailPanel.tsx | 413 ++++++++---------- .../pullRequest/PullRequestGhosts.tsx | 195 ++++----- 3 files changed, 412 insertions(+), 326 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx new file mode 100644 index 000000000000..92bd0ab324d5 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx @@ -0,0 +1,130 @@ +import { ArrowLeftIcon, FileDiffIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; + +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { PullRequestMetaLine } from "./pullRequestPresentation"; + +export function PullRequestDetailHeaderBody({ + title, + author, + updated, + checkout, + base, + head, + files, + diffStat, +}: { + title: ReactNode; + author: ReactNode; + updated: ReactNode; + checkout?: ReactNode; + base: ReactNode; + head: ReactNode; + files: ReactNode; + diffStat: ReactNode; +}) { + return ( +
    + {title} +
    + + {author} + {updated} + + {checkout} +
    +
    + + {base} + + {head} + + + + + {files} + + {diffStat} + +
    +
    + ); +} + +export function PullRequestDetailTitleRow({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ); +} + +export function PullRequestDetailTabBar({ + tabs, + value, + onValueChange, + onTabIntent, + inert, + children, +}: { + tabs: ReadonlyArray<{ readonly value: Tab; readonly label: string }>; + value: Tab; + onValueChange?: (tab: Tab) => void; + onTabIntent?: (tab: Tab) => void; + inert?: boolean; + children?: ReactNode; +}) { + return ( + + ); +} + +export function PullRequestChecksStatusLine({ + icon, + label, + className, + ...props +}: Omit, "children"> & { icon: ReactNode; label: ReactNode }) { + return ( + + {icon} + {label} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index f44896073985..7f6a4592a0b4 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -102,7 +102,6 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { PullRequestEditButton } from "./PullRequestEditButton"; import { Input } from "../ui/input"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Menu, MenuItem, @@ -117,6 +116,12 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { + PullRequestChecksStatusLine, + PullRequestDetailHeaderBody, + PullRequestDetailTabBar, + PullRequestDetailTitleRow, +} from "./PullRequestDetailLayout"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; @@ -2373,77 +2378,79 @@ export function PullRequestDetailPanel({ inert={condensed} > {detail ? ( -
    - {titleDraft === null ? ( -
    - - - {detail.title} - - } - /> - {detail.title} - - {canEditPullRequestChangeRequest(detail) ? ( - setTitleScope({ pullRequestKey, text: detail.title })} - /> - ) : null} -
    - ) : ( - // A title is one line of text, not markdown, so it takes an input rather than - // the editor the description and the remarks share. -
    - - setTitleScope({ pullRequestKey, text: event.target.value }) - } - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveTitle(titleDraft); - } else if (event.key === "Escape") { - event.preventDefault(); - setTitleScope(null); - } - }} - /> -
    - - + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveTitle(titleDraft); + } else if (event.key === "Escape") { + event.preventDefault(); + setTitleScope(null); + } + }} + /> +
    + + +
    -
    - )} -
    - - - updated {formatRelativeTimeLabel(detail.updatedAt)} - - {checkoutCommand ? ( + ) + } + author={ + + } + updated={updated {formatRelativeTimeLabel(detail.updatedAt)}} + checkout={ + checkoutCommand ? ( - ) : null} -
    - -
    - - {/* An out-of-date base wears the warning on the branch name itself, so the + ) : null + } + base={ + /* An out-of-date base wears the warning on the branch name itself, so the name is amber and pointing at either the name or the mark opens the way - out. Up to date, the name keeps its plain tooltip. */} - {freshness ? ( - void perform("update-branch", undefined, method)} - className="max-w-[40%]" - > - {isStackedPullRequest ? ( - - ) : null} - - - - - ) : ( - - - {isStackedPullRequest ? ( - - ) : null} - - - - - } + out. Up to date, the name keeps its plain tooltip. */ + freshness ? ( + void perform("update-branch", undefined, method)} + className="max-w-[40%]" + > + {isStackedPullRequest ? ( + - - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` - : detail.baseBranch} - - - )} - - - - - - - {detail.changedFiles.toLocaleString()}{" "} - {detail.changedFiles === 1 ? "file" : "files"} - - - -
    -
    + ) : null} + + + + + ) : ( + + + {isStackedPullRequest ? ( + + ) : null} + + + + + } + /> + + {isStackedPullRequest + ? `Stacked on ${detail.baseBranch}` + : detail.baseBranch} + + + ) + } + head={ + + } + files={`${detail.changedFiles.toLocaleString()} ${detail.changedFiles === 1 ? "file" : "files"}`} + diffStat={ + + } + /> ) : null}
    {detail ? ( - + ) : null}
    diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 85c9802c2170..7e155882a34a 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -13,7 +13,6 @@ import { ChevronRightIcon, EllipsisIcon, ExternalLinkIcon, - FileDiffIcon, PanelRightIcon, TagIcon, UserPlusIcon, @@ -26,20 +25,24 @@ import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Button, InlineButton } from "../ui/button"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { MiddleTruncate } from "../ui/middle-truncate"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; +import { + PullRequestChecksStatusLine, + PullRequestDetailHeaderBody, + PullRequestDetailTabBar, + PullRequestDetailTitleRow, +} from "./PullRequestDetailLayout"; import { PullRequestActorLabel, PullRequestDiffStat, PullRequestLabelChip, - PullRequestMetaLine, pullRequestChecksStatePresentation, resolvePullRequestState, } from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { - return
    ; + return ; } /** Widths cycle rather than randomize, so the ghost renders the same on every pass. */ @@ -202,32 +205,35 @@ export function PullRequestDetailGhost({
    -
    - {seed ? ( -
    -

    {seed.title}

    -
    - ) : ( -
    - -
    - )} -
    - {seed ? ( - - - updated {formatRelativeTimeLabel(seed.updatedAt)} - + + {seed ? ( +

    {seed.title}

    + ) : ( + + )} + + } + author={ + seed ? ( + ) : ( - - - - - - - - )} - {checkout ? ( + + + + + ) + } + updated={ + seed ? ( + updated {formatRelativeTimeLabel(seed.updatedAt)} + ) : ( + + ) + } + checkout={ + checkout ? ( - ) : null} -
    - -
    - - {seed ? ( - - - - - - ) : ( - - - - )} - - {seed ? ( - - ) : ( - - )} - - - - - {changedFiles === null ? ( - - ) : ( - `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` - )} - + ) : null + } + base={ + {seed ? ( - + + + ) : ( - + )} -
    -
    + } + head={ + seed ? ( + + ) : ( + + ) + } + files={ + changedFiles === null ? ( + + ) : ( + `${changedFiles.toLocaleString()} ${changedFiles === 1 ? "file" : "files"}` + ) + } + diffStat={ + seed ? ( + + ) : ( + + ) + } + />
    - +
    From 2b7fa212a89edda7aad8f73fc3b4afd5b56c442c Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:20:36 -0700 Subject: [PATCH 046/530] fix(web): PR file headers keep the file name in narrow panels (#15562) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 9af92ee96a81..caadd569b9c5 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -858,7 +858,7 @@ function PullRequestCodeTab({ ) : ( - "Viewed" + Viewed )} @@ -1503,7 +1503,7 @@ function PullRequestCodeTab({ // diff crosses the overflow boundary. The viewer is itself focusable for keyboard // interaction, but its native host outline clips and competes with the focus // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" + className="@container h-full overflow-auto [scrollbar-gutter:stable]" viewerRef={setViewer} items={items} selectedLines={selectedLines} From b24f0fbba09d8623c896081b4ff70aa7190c8fda Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:22:25 -0700 Subject: [PATCH 047/530] fix(web): PR timeline no longer shifts when its scrollbar appears (#15588) --- apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index 4b3997bf5f62..c6174b54f135 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -577,7 +577,7 @@ export function PullRequestTimelineTab({ }; return ( -
    +
    From 0bfd9dd19d0c9418a936190525ec8f4c4fdae09b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 21:49:54 -0700 Subject: [PATCH 048/530] fix(server): held queued wakes no longer keep delegated tasks running (#17028) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 + ...OrchestratorMcpToolkit.integration.test.ts | 14 ++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../DelegatedCompletionDelivery.test.ts | 192 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 32 +-- .../ProjectionRecovery.test.ts | 19 +- .../src/orchestration-v2/ProjectionStore.ts | 8 +- .../SubagentProjection.test.ts | 8 + .../orchestration-v2/SubagentProjection.ts | 6 +- 9 files changed, 265 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ebb1aa9e4c1a..78d4ee48e666 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -370,9 +370,11 @@ export function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { + // Held queued runs wait for the user to resume the child; task_cancel holds them. return childProjection.runs.some( (run) => !ThreadManagementService.isTerminalRunStatus(run.status) && + !(run.status === "queued" && run.queueHeld === true) && (delegatedRun === undefined || run.ordinal > delegatedRun.ordinal), ); } diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index e59f8fff9a05..0dbb2f571683 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1873,6 +1873,20 @@ describe("orchestrator MCP toolkit", () => { legacyDelegatedRun, ), ).toBe(true); + // A queue Stop held waits for the user; it is not pending child work. + expect( + hasPendingChildRuns( + { + ...legacyChildProjection, + runs: legacyChildProjection.runs.map((run) => + run.id === activeChildFollowup.runId + ? { ...run, status: "queued", queueHeld: true } + : run, + ), + }, + legacyDelegatedRun, + ), + ).toBe(false); const completedTaskCancelCall = yield* invoke("task_cancel", { taskId: delegated.taskId, reason: "Stop the child's later work too.", diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index e7ef07101a39..ac0ebb63b7dd 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -76,7 +76,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. Turns held in a stopped queue wait for the user and do not count. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 6f1819a672b7..0344e3aed4cf 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -14,6 +14,7 @@ import { ProviderThreadId, RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -1204,6 +1205,197 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); + it.effect("settles a cancelled child whose held wakes wait behind it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:held-wake-parent"); + const projectId = ProjectId.make("project:held-wake-parent"); + const runId = RunId.make("run:held-wake-parent"); + const rootNodeId = NodeId.make("node:held-wake-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:held-wake-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "held-wake-child", + completionWake: "always", + continuationPending: false, + now, + }); + // Pull request watch wakes queued while the child ran; the restart held them. + const held = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:held-wake-child:2"), + ordinal: 2, + status: "queued", + now, + }); + yield* eventSink.write({ + commandId: CommandId.make("command:held-wake-child:held"), + events: [ + { + ...held, + payload: { ...held.payload, startedAt: null, completedAt: null, queueHeld: true }, + }, + ], + }); + + yield* orchestrator.recoverDelegatedTasks; + + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = recovered.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "cancelled"); + assert.isNotNull(task?.result ?? null); + assert.isTrue( + recovered.contextTransfers.some( + (transfer) => + transfer.type === "subagent_result" && transfer.sourceThreadId === child.childThreadId, + ), + ); + assert.isFalse(yield* orchestrator.delegatedTaskResultPending(child.childThreadId)); + // The held wake stays held for the user to resume or discard. + const childProjection = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepEqual( + childProjection.runs.map((run) => [run.status, run.queueHeld ?? false]), + [ + ["cancelled", false], + ["queued", true], + ], + ); + }), + ); + + it.effect("settles a child whose provider failure holds its queued wakes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:failed-hold-parent"); + const projectId = ProjectId.make("project:failed-hold-parent"); + const runId = RunId.make("run:failed-hold-parent"); + const rootNodeId = NodeId.make("node:failed-hold-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:failed-hold-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "failed-hold-child", + completionWake: "always", + continuationPending: false, + now, + }); + // The child is resumed, and a wake queues behind its running turn. + const activeRun = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:failed-hold-child:2"), + ordinal: 2, + status: "running", + now, + }); + const queued = runEvent({ + threadId: child.childThreadId, + runId: RunId.make("run:failed-hold-child:3"), + ordinal: 3, + status: "queued", + now, + }); + yield* eventSink.write({ + commandId: CommandId.make("command:failed-hold-child:resumed"), + events: [ + activeRun, + { ...queued, payload: { ...queued.payload, startedAt: null, completedAt: null } }, + ], + }); + const afterSequence = yield* eventSink.latestSequence(); + const errorItemId = TurnItemId.make("turn-item:failed-hold-child:error"); + yield* eventSink.write({ + commandId: CommandId.make("command:failed-hold-child:failed"), + events: [ + { + id: EventId.make("event:failed-hold-child:error"), + type: "turn-item.updated", + threadId: child.childThreadId, + runId: activeRun.runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: errorItemId, + type: "error", + threadId: child.childThreadId, + runId: activeRun.runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "failed", + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error", + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + { + ...activeRun, + id: EventId.make("event:failed-hold-child:failed"), + payload: { ...activeRun.payload, status: "failed", completedAt: now }, + }, + ], + }); + + // The failure holds the queue, which leaves the failure as the task's result. + const settled = yield* eventSink + .stream({ afterSequence, eventType: "subagent.updated" }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && stored.event.payload.id === child.taskId, + ), + Stream.take(1), + Stream.runHead, + ); + assert.isTrue(settled._tag === "Some"); + const recovered = yield* orchestrator.getThreadProjection(threadId); + assert.equal(recovered.subagents.find((row) => row.id === child.taskId)?.status, "failed"); + const childProjection = yield* orchestrator.getThreadProjection(child.childThreadId); + assert.deepEqual( + childProjection.runs.map((run) => [run.status, run.queueHeld ?? false]), + [ + ["cancelled", false], + ["failed", false], + ["queued", true], + ], + ); + }), + ); + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f..e0d288282b1a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10613,25 +10613,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // while holding the parent lock, so nesting the parent lock inside the // child lock here would invert that order, and the keyed executor's // semaphores are neither reentrant nor deadlock-aware. - const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); - if (parentThreadId !== undefined) { - yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); - } if (stored.event.type === "run.updated") { yield* threadDispatch.withLock( threadId, finalizeDelegatedCompletionDelivery(threadId, stored.event.payload.id), ); } - yield* threadDispatch.withLock( - threadId, - startNextQueuedRun( + yield* threadDispatch + .withLock( threadId, - stored.event.type === "run.updated" && stored.event.payload.status === "failed" - ? { failedRunId: stored.event.payload.id } - : undefined, - ), - ); + startNextQueuedRun( + threadId, + stored.event.type === "run.updated" && stored.event.payload.status === "failed" + ? { failedRunId: stored.event.payload.id } + : undefined, + ), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to start the next queued V2 run", { threadId, cause }), + ), + ); + // After the queue decision: a provider failure holds the child's queued + // wakes, and only then is the failed run the task's result. + const parentThreadId = yield* appOwnedSubagentParentThreadId(threadId); + if (parentThreadId !== undefined) { + yield* threadDispatch.withLock(parentThreadId, finalizeAppOwnedSubagent(threadId)); + } }).pipe( Effect.catchCause((cause) => Effect.logWarning("Failed to react to terminal V2 run", { diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 6bde9f596e2e..c7ea0530d351 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -231,7 +231,7 @@ it.effect("recovers terminal subagent results until their cross-thread transfer const now = yield* DateTime.now; const parent = yield* createThread("subagent-parent"); const children: Array = []; - for (const name of ["terminal", "archived", "deleted", "running"]) { + for (const name of ["terminal", "archived", "deleted", "running", "held", "queued"]) { const child = yield* createThread(`subagent-${name}`, { lineage: { parentThreadId: parent, relationshipToParent: "subagent", rootThreadId: parent }, forkedFrom: { type: "node", nodeId: NodeId.make(`node:${name}`) }, @@ -239,12 +239,22 @@ it.effect("recovers terminal subagent results until their cross-thread transfer deletedAt: name === "deleted" ? now : null, }); yield* createRun(child, name === "running" ? "running" : "completed"); + // A wake queued behind the result: held by Stop or a restart, or still deliverable. + if (name === "held" || name === "queued") { + yield* createRun(child, "queued", { + ordinal: 2, + startedAt: null, + ...(name === "held" ? { queueHeld: true } : {}), + }); + } children.push(child); } + const terminalChildren = new Set([children[0]!, children[1]!, children[4]!]); assert.deepEqual( new Set(yield* projections.getRecoveryThreadIds("subagent-results")), - new Set(children.slice(0, 2)), + terminalChildren, ); + const completed = children[0]!; const transferId = ContextTransferId.make("transfer:recovery:subagent-result"); yield* projections.apply({ @@ -273,7 +283,10 @@ it.effect("recovers terminal subagent results until their cross-thread transfer }); const archivedChild = children[1]; assert.isDefined(archivedChild); - assert.deepEqual(yield* projections.getRecoveryThreadIds("subagent-results"), [archivedChild]); + assert.deepEqual( + new Set(yield* projections.getRecoveryThreadIds("subagent-results")), + new Set([archivedChild, children[4]!]), + ); assert.deepEqual(yield* projections.getUnreadableThreadIds(), []); yield* sql` UPDATE orchestration_v2_projection_context_transfers SET payload_json = '{}' diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4da19774075b..11f98f2330b5 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -514,7 +514,7 @@ function needsRecovery( parentThreadId !== null && projection.thread.forkedFrom?.type === "node" && ["completed", "interrupted", "failed", "cancelled", "rolled_back"].includes( - projection.runs.at(-1)?.status ?? "idle", + latestUnheldRun(projection.runs)?.status ?? "idle", ) && !projection.contextTransfers.some( (transfer) => @@ -3505,9 +3505,15 @@ export const layer: Layer.Layer = json_extract(child.payload_json, '$.lineage.relationshipToParent') = 'subagent' AND json_extract(child.payload_json, '$.lineage.parentThreadId') IS NOT NULL AND json_extract(child.payload_json, '$.forkedFrom.type') = 'node' + -- A held queue waits for the user, so the newest unheld run + -- decides, matching latestUnheldRun. AND ( SELECT status FROM orchestration_v2_projection_runs WHERE thread_id = child.thread_id + AND NOT ( + status = 'queued' + AND json_extract(payload_json, '$.queueHeld') IS 1 + ) ORDER BY ordinal DESC LIMIT 1 ) IN ('completed', 'interrupted', 'failed', 'cancelled', 'rolled_back') AND NOT EXISTS ( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 23d180102cd3..e2691e08e0db 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -237,6 +237,14 @@ it("waits for nested work and retains the report across monitor acknowledgements startedAt: null, }; assert.equal(delegatedTaskProgress({ ...projection, runs: [run, pending] }).state, "working"); + // Stop or a restart holds queued wakes for the user; they are not work the task owes. + const cancelled = { ...run, status: "cancelled" as const }; + const held = delegatedTaskProgress({ + ...projection, + runs: [cancelled, { ...pending, queueHeld: true }], + }); + assert.equal(held.state, "result_available"); + assert.equal(held.resultRun?.id, cancelled.id); const report = { ...pending, status: "completed" as const, startedAt: parentCreatedAt }; const monitor = { ...report, id: RunId.make("monitor"), ordinal: 3 }; const artifacts = makeSubagentConversationArtifacts({ diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index f28dc1aa4be5..7ee8d7c1aa8d 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -229,7 +229,11 @@ export function delegatedTaskProgress(projection: { const workRuns = projection.runs.filter( (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", ); - const active = workRuns.some((run) => !terminal(run.status)); + // A held queue waits for the user to resume it (after Stop, a restart, or a + // provider failure), so its runs are not work the task still owes. + const active = workRuns.some( + (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), + ); const children = projection.subagents.some( (task) => From c28cd905572e2a28934a6229d88bcf2975963032 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 22:20:16 -0700 Subject: [PATCH 049/530] fix(server): a message sent during a rollback no longer undoes it (#17079) Co-authored-by: Claude Opus 5.5 (1M context) --- .../CheckpointRestoreSafety.test.ts | 2 + .../CheckpointRollbackService.test.ts | 21 ++- .../CheckpointRollbackService.ts | 131 ++++++++++-------- .../src/orchestration-v2/runtimeLayer.ts | 1 + .../testkit/ProviderReplayHarness.ts | 1 + 5 files changed, 95 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index 5f79aefdd0b7..34bf5cce6a53 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; @@ -102,6 +103,7 @@ it.effect.each([ Layer.provide( Layer.mergeAll( NodeServices.layer, + ThreadCommandExecutor.layer, IdAllocator.layer, Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeed(Option.some({ workspaceRoot: parent } as never)), diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 433a3be43ac3..bb37290363ac 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -20,6 +20,8 @@ import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "./IdAllocator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -64,6 +66,7 @@ it.effect("rejects a non-ready checkpoint before opening a session or restoring const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -141,6 +144,7 @@ it.effect("rejects a rollback when another provider thread became active", () => const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -221,6 +225,7 @@ it.effect("rejects a rollback when provider selection changed before execution", const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -292,6 +297,7 @@ it.effect("reports a missing provider turn as a structured rollback failure", () const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + ThreadCommandExecutor.layer, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }), Layer.mock(EventSink.EventSinkV2)({}), IdAllocator.layer, @@ -352,6 +358,15 @@ it.effect.each([ const checkpointId = CheckpointId.make("rewind-start"); const scopeId = CheckpointScopeId.make("rewind-scope"); const calls: string[] = []; + let lockedThreads = Effect.succeed>([]); + const layerThreadCommands = Layer.effect( + ThreadCommandExecutor.ThreadCommandExecutor, + Effect.tap(KeyedLock.make(), (lock) => + Effect.sync(() => { + lockedThreads = lock.activeKeys; + }), + ), + ); const providerThread = { id: providerThreadId, providerSessionId, @@ -390,6 +405,7 @@ it.effect.each([ const layerTest = layerCheckpointRollbackService.pipe( Layer.provide( Layer.mergeAll( + layerThreadCommands, Layer.mock(CheckpointService.CheckpointServiceV2)({ restore: () => Effect.sync(() => { @@ -398,7 +414,10 @@ it.effect.each([ }), Layer.mock(EventSink.EventSinkV2)({ write: ({ events }) => - Effect.sync(() => { + Effect.map(lockedThreads, (locked) => { + // A thread command that planned against the old provider thread + // must not commit after this and undo the rollback. + assert.include(locked, threadId); assert.ok( events.some( (event) => event.type === "run.updated" && event.payload.status === "rolled_back", diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 1710e7a8cd84..b1532a74c6dc 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -26,6 +26,7 @@ import * as ProjectStore from "./ProjectStore.ts"; import type { ProviderAdapterV2RollbackTarget } from "./ProviderAdapter.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; export const ROLLBACK_FAILED_MESSAGE = "The provider could not roll back this conversation. Try again; if it keeps failing, check the provider and server logs."; @@ -91,6 +92,7 @@ export const layer: Layer.Layer< | FileSystem.FileSystem | Path.Path | ProjectStore.ProjectStoreV2 + | ThreadCommandExecutor.ThreadCommandExecutor > = Layer.effect( CheckpointRollbackServiceV2, Effect.gen(function* () { @@ -103,6 +105,7 @@ export const layer: Layer.Layer< const fileSystem = yield* FileSystem.FileSystem; const projects = yield* ProjectStore.ProjectStoreV2; const path = yield* Path.Path; + const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const execute = Effect.fn("orchestrationV2.checkpointRollback.execute")(function* (input: { readonly threadId: ThreadId; @@ -274,72 +277,80 @@ export const layer: Layer.Layer< yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); } - const now = yield* DateTime.now; - const makeEvent = (event: Omit) => - Effect.map( - ids.allocate.event({ threadId: event.threadId }), - (id) => - ({ - ...event, - id, - }) as Event, - ); - const events: Array = []; - events.push( - yield* makeEvent({ - type: "provider-thread.updated", - threadId: input.threadId, - driver: providerThread.driver, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { - ...snapshot.providerThread, - lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, - updatedAt: now, - }, - }), - ); - for (const staleCheckpoint of staleCheckpoints) { - events.push( - yield* makeEvent({ - type: "checkpoint.captured", - threadId: input.threadId, - ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), - nodeId: staleCheckpoint.nodeId, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { ...staleCheckpoint, status: "stale" }, - }), - ); - } - for (const run of runsToRollback) { - const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); - events.push( - yield* makeEvent({ - type: "run.updated", - threadId: input.threadId, - runId: run.id, - ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), - providerInstanceId: run.providerInstanceId, - occurredAt: now, - payload: { ...run, status: "rolled_back", completedAt: now }, - }), - ); - if (rootNode !== undefined) { + // Thread commands write full provider thread rows under this lock. Without + // it, a message sent during the rollback can plan against the old native + // session and commit after this, undoing the provider's fork. + yield* threadCommands.withLock( + input.threadId, + Effect.gen(function* () { + const now = yield* DateTime.now; + const makeEvent = (event: Omit) => + Effect.map( + ids.allocate.event({ threadId: event.threadId }), + (id) => + ({ + ...event, + id, + }) as Event, + ); + const events: Array = []; events.push( yield* makeEvent({ - type: "node.updated", + type: "provider-thread.updated", threadId: input.threadId, - runId: run.id, - nodeId: rootNode.id, - providerInstanceId: run.providerInstanceId, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, occurredAt: now, - payload: { ...rootNode, status: "rolled_back", completedAt: now }, + payload: { + ...snapshot.providerThread, + lastRunOrdinal: targetOrdinal === 0 ? null : targetOrdinal, + updatedAt: now, + }, }), ); - } - } - yield* eventSink.write({ events }); + for (const staleCheckpoint of staleCheckpoints) { + events.push( + yield* makeEvent({ + type: "checkpoint.captured", + threadId: input.threadId, + ...(staleCheckpoint.runId === null ? {} : { runId: staleCheckpoint.runId }), + nodeId: staleCheckpoint.nodeId, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { ...staleCheckpoint, status: "stale" }, + }), + ); + } + for (const run of runsToRollback) { + const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); + events.push( + yield* makeEvent({ + type: "run.updated", + threadId: input.threadId, + runId: run.id, + ...(rootNode === undefined ? {} : { nodeId: rootNode.id }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "rolled_back", completedAt: now }, + }), + ); + if (rootNode !== undefined) { + events.push( + yield* makeEvent({ + type: "node.updated", + threadId: input.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "rolled_back", completedAt: now }, + }), + ); + } + } + yield* eventSink.write({ events }); + }), + ); }); return CheckpointRollbackServiceV2.of({ diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 98e05de9a5a2..9cf3351f5d25 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -182,6 +182,7 @@ const layerCheckpointRollbackServiceProvided = CheckpointRollbackService.layer.p IdAllocator.layer, ProjectionStore.layer, layerProviderSessionManagerProvided, + ThreadCommandExecutor.layer, layerRuntimePolicyProvided, ), ), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 83762ef06cea..230ad7aa0feb 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -395,6 +395,7 @@ export function layerWithRegistry( IdAllocator.layer, layerStores, layerProviderSessionManagerProvided, + ThreadCommandExecutor.layer, layerRuntime, ), ), From 6053a7878d24e5fd596d2b417ed94964a4b9c1c6 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:24:58 -0700 Subject: [PATCH 050/530] fix(web): PR file stats ignore the hide-whitespace toggle (#16162) --- .../pullRequest/PullRequestCodeTab.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index caadd569b9c5..d531259e5781 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -37,6 +37,7 @@ import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, fnv1a32, + getDiffLineStat, getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, @@ -596,6 +597,23 @@ function PullRequestCodeTab({ ), [loadedSlices], ); + const sourceFileStats = useMemo( + () => + new Map( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" + ? parsed.files.map( + (file, index) => + [ + buildFileDiffRenderKey(file), + getDiffLineStat([parsed.sourceFiles[index] ?? file]), + ] as const, + ) + : [], + ), + ), + [parsedSlices], + ); const fileKeys = useMemo(() => items.map((item) => item.id), [items]); const collapsedFileKeys = useMemo( () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)), @@ -811,12 +829,8 @@ function PullRequestCodeTab({ const renderHeaderMetadata = useCallback( (item: CodeViewItem) => { if (item.type !== "diff") return null; - let additions = 0; - let deletions = 0; - for (const hunk of item.fileDiff.hunks) { - additions += hunk.additionLines; - deletions += hunk.deletionLines; - } + let { additions, deletions } = + sourceFileStats.get(item.id) ?? getDiffLineStat([item.fileDiff]); const path = resolveFileDiffPath(item.fileDiff); if (additions === 0 && deletions === 0) { const withheld = omittedFileStats.get(path); @@ -864,7 +878,7 @@ function PullRequestCodeTab({ ); }, - [omittedFileStats], + [omittedFileStats, sourceFileStats], ); const diffViewOptions = useMemo( From 28f5516a9acfcfc1e203487e78869a406fd23b76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:47 -0700 Subject: [PATCH 051/530] perf(mobile): omit duplicated turn items from bounded thread snapshots (#15385) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/ThreadStream.test.ts | 103 +++++ .../src/orchestration-v2/ThreadStream.ts | 40 +- .../boundedSnapshotTransport.test.ts | 370 ++++++++++++++++++ apps/server/src/orchestration-v2/http.ts | 10 +- apps/server/src/ws.ts | 6 +- .../state/boundedThreadSnapshotHttp.test.ts | 77 ++++ .../src/state/boundedThreadSnapshotHttp.ts | 16 +- .../src/state/threads-sync.test.ts | 111 ++++++ packages/client-runtime/src/state/threads.ts | 7 +- packages/contracts/src/environmentHttp.ts | 9 + .../contracts/src/orchestrationV2.test.ts | 83 ++++ packages/contracts/src/orchestrationV2.ts | 13 + packages/shared/package.json | 4 + .../orchestrationV2BoundedSnapshot.test.ts | 147 +++++++ .../src/orchestrationV2BoundedSnapshot.ts | 58 +++ 15 files changed, 1038 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts create mode 100644 packages/shared/src/orchestrationV2BoundedSnapshot.test.ts create mode 100644 packages/shared/src/orchestrationV2BoundedSnapshot.ts diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 5ef09f776da0..d64de56cbf8a 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,5 +1,6 @@ import { EventId, + OrchestrationV2ThreadStreamItem, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -8,6 +9,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { buildBoundedThreadStreamSnapshot, @@ -262,3 +265,103 @@ describe("decideThreadResume", () => { ).toEqual({ mode: "replay", afterSequence: 9, throughSequence: 10 }); }); }); + +describe("compact bounded snapshots", () => { + const FORK_PARENT = ThreadId.make("thread-stream-parent"); + const decodeStreamItem = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadStreamItem), + ); + const encodeStreamItem = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadStreamItem)); + + /** Fork child: inherited parent rows and marker, then local rows, plus an older interrupt request. */ + function forkProjection(): OrchestrationV2ThreadProjection { + const base = timelineProjection(6); + const local = base.visibleTurnItems.slice(3).map((row, index) => ({ ...row, position: index })); + const inherited = base.visibleTurnItems.slice(0, 3).map((row, index) => { + const item = { ...row.item, id: TurnItemId.make(`parent-${index}`), threadId: FORK_PARENT }; + return { + position: index, + visibility: "inherited" as const, + sourceThreadId: FORK_PARENT, + sourceItemId: item.id, + item, + }; + }); + const request = { + ...base.turnItems[0]!, + id: TurnItemId.make("interrupt-request"), + type: "run_interrupt_request", + ordinal: 0, + message: "Stop", + } as unknown as OrchestrationV2TurnItem; + return { + ...base, + turnItems: [request, ...local.map((row) => row.item)], + visibleTurnItems: [...inherited, ...local].map((row, position) => ({ ...row, position })), + }; + } + + it.each([ + ["linear", () => timelineProjection(80)], + ["fork with retained interrupt request", forkProjection], + ])("restores the exact opted-out snapshot for %s threads", (_, makeProjection) => { + const base = makeProjection(); + // The shared fixture omits a model; the wire codec requires one. + const projection = { + ...base, + thread: { ...base.thread, modelSelection: { instanceId: "codex", model: "gpt-5" } }, + } as OrchestrationV2ThreadProjection; + const full = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, projection }); + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 44, + projection, + compactTurnItems: true, + }); + + // Opted-out clients get exactly the previous representation. + expect("turnItemsOmitLocalVisible" in full).toBe(false); + expect(compact.turnItemsOmitLocalVisible).toBe(true); + expect(compact.projection.turnItems.length).toBeLessThan(full.projection.turnItems.length); + const { turnItemsOmitLocalVisible: _marker, ...compactFields } = compact; + expect({ ...compactFields, projection: full.projection }).toEqual(full); + + // Decode the wire form like a client, then restore before use. + const decodedFull = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(full)))); + const decodedCompact = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(compact)))); + if (decodedFull.kind !== "snapshot" || decodedCompact.kind !== "snapshot") { + throw new Error("Expected snapshots"); + } + expect(boundedSnapshotProjection(decodedCompact)).toEqual(decodedFull.projection); + }); + + it("keeps the retained interrupt request outside the visible window", () => { + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 1, + projection: forkProjection(), + compactTurnItems: true, + }); + expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); + expect(boundedSnapshotProjection(compact).turnItems.map((item) => String(item.id))).toEqual([ + "item-3", + "item-4", + "item-5", + "interrupt-request", + ]); + }); + + it("omits the marker when the window has no local rows", () => { + const fork = forkProjection(); + const inheritedOnly = { + ...fork, + turnItems: fork.turnItems.slice(0, 1), + visibleTurnItems: fork.visibleTurnItems.slice(0, 3), + }; + const compact = buildBoundedThreadStreamSnapshot({ + snapshotSequence: 1, + projection: inheritedOnly, + compactTurnItems: true, + }); + expect("turnItemsOmitLocalVisible" in compact).toBe(false); + expect(compact.projection.turnItems.map((item) => item.id)).toEqual(["interrupt-request"]); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index ca8e2170c06d..fad28abf677a 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -3,7 +3,12 @@ import type { OrchestrationV2ThreadStreamItem, } from "@t3tools/contracts"; -import { buildBoundedThreadProjection } from "./threadHistoryPaging.ts"; +import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; + +import { + buildBoundedThreadProjection, + type BoundedProjectionResult, +} from "./threadHistoryPaging.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; /** Maximum number of reducer applications allowed during a thread resume. */ @@ -39,10 +44,34 @@ type ThreadSnapshotStreamItem = Extract< { readonly kind: "snapshot" } >; +/** + * Bounded snapshot fields shared by the HTTP route and socket fallbacks. Only + * clients that opted in get compact `turnItems` and the marker; everyone else + * gets the unchanged representation. + */ +export function boundedSnapshotResponseFields(input: { + readonly bounded: BoundedProjectionResult; + readonly compactTurnItems: boolean; +}) { + const { bounded } = input; + const compactProjection = input.compactTurnItems + ? omitLocalVisibleTurnItems(bounded.projection) + : null; + return { + projection: compactProjection ?? bounded.projection, + historyCursor: bounded.historyCursor, + hasMoreHistory: bounded.hasMoreHistory, + latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, + payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...(compactProjection === null ? {} : { turnItemsOmitLocalVisible: true as const }), + }; +} + /** Build the same bounded, wire-projected snapshot for every socket fallback path. */ export function buildBoundedThreadStreamSnapshot(input: { readonly snapshotSequence: number; readonly projection: OrchestrationV2ThreadProjection; + readonly compactTurnItems?: boolean; }): ThreadSnapshotStreamItem { const bounded = buildBoundedThreadProjection({ snapshotSequence: input.snapshotSequence, @@ -51,11 +80,10 @@ export function buildBoundedThreadStreamSnapshot(input: { return { kind: "snapshot", snapshotSequence: input.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...boundedSnapshotResponseFields({ + bounded, + compactTurnItems: input.compactTurnItems === true, + }), }; } diff --git a/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts new file mode 100644 index 000000000000..a86b19be95f7 --- /dev/null +++ b/apps/server/src/orchestration-v2/boundedSnapshotTransport.test.ts @@ -0,0 +1,370 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthSessionId, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentHttpApi, + EventId, + MessageId, + NodeId, + ORCHESTRATION_PROTOCOL_HEADER, + ORCHESTRATION_PROTOCOL_VERSION, + OrchestrationV2ThreadBoundedSnapshot, + OrchestrationV2ThreadStreamItem, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { Etag, HttpRouter } from "effect/http"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; +import { subscribeOrchestrationV2Thread } from "../ws.ts"; +import * as OrchestrationHttp from "./http.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; + +const decodeBounded = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadBoundedSnapshot)), +); +const decodeStreamItem = Schema.decodeUnknownSync( + Schema.toCodecJson(OrchestrationV2ThreadStreamItem), +); +const encodeStreamItem = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2ThreadStreamItem)); + +const provider = ProviderInstanceId.make("codex"); +const modelSelection = { instanceId: provider, model: "gpt-5.4" }; +const projectId = ProjectId.make("project:compact-transport"); +const PARENT = ThreadId.make("thread:compact-transport:parent"); +const FORK = ThreadId.make("thread:compact-transport:fork"); +const LONG = ThreadId.make("thread:compact-transport:long"); +const parentRun = RunId.make("run:compact-transport:parent"); +const longRun = RunId.make("run:compact-transport:long"); + +const store = Layer.mergeAll( + ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), + SqlitePersistence.layerMemory, +); +const management = Layer.unwrap( + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + return Layer.mock(ThreadManagementService.ThreadManagementService)({ + ensureLegacyTranscript: () => Effect.void, + getThreadSnapshot: (id) => projections.getThreadSnapshot(id).pipe(Effect.orDie), + getThreadSnapshotWindow: (id, options) => + projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), + streamStoredEventsFrom: () => Stream.empty, + }); + }), +); + +class OrchestrationApi extends HttpApi.make("environment").add( + EnvironmentHttpApi.groups.orchestration, +) {} +const auth = Layer.succeed(EnvironmentAuthenticatedAuth, (effect) => + effect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("compact-transport"), + subject: "compact-transport", + method: "browser-session-cookie", + scopes: new Set([AuthOrchestrationReadScope]), + }), + ), +); + +function threadCreated(id: ThreadId, at: DateTime.Utc, forkOf?: RunId): OrchestrationV2DomainEvent { + return { + id: EventId.make(`event:${id}:created`), + type: "thread.created", + threadId: id, + occurredAt: at, + payload: { + createdBy: "user", + creationSource: "web", + id, + projectId, + title: String(id), + providerInstanceId: provider, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: + forkOf === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: id } + : { parentThreadId: PARENT, relationshipToParent: "fork", rootThreadId: PARENT }, + forkedFrom: forkOf === undefined ? null : { type: "run", threadId: PARENT, runId: forkOf }, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }; +} + +function runCreated( + threadId: ThreadId, + runId: RunId, + at: DateTime.Utc, +): OrchestrationV2DomainEvent { + const rootNodeId = NodeId.make(`node:${runId}`); + return { + id: EventId.make(`event:${runId}:created`), + type: "run.created", + threadId, + runId, + nodeId: rootNodeId, + driver: ProviderDriverKind.make("codex"), + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: provider, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make(`message:${runId}`), + rootNodeId, + activeAttemptId: null, + status: "completed", + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }; +} + +function itemUpdated( + threadId: ThreadId, + runId: RunId | null, + key: string, + ordinal: number, + at: DateTime.Utc, + type: + | "command_execution" + | "run_interrupt_request" + | "run_interrupt_result" = "command_execution", +): OrchestrationV2DomainEvent { + const base = { + id: TurnItemId.make(`turn-item:${threadId}:${key}`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: key, + startedAt: at, + completedAt: at, + updatedAt: at, + }; + const payload: OrchestrationV2TurnItem = + type === "command_execution" + ? { ...base, type, input: `echo ${key}`, output: `output ${key}`, exitCode: 0 } + : { ...base, type, message: key }; + return { + id: EventId.make(`event:${threadId}:${key}`), + type: "turn-item.updated", + threadId, + ...(runId === null ? {} : { runId }), + occurredAt: at, + payload, + }; +} + +const seed = Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const at = yield* DateTime.now; + const events: OrchestrationV2DomainEvent[] = [ + threadCreated(PARENT, at), + runCreated(PARENT, parentRun, at), + ...[1, 2, 3].map((n) => itemUpdated(PARENT, parentRun, `parent-${n}`, n, at)), + threadCreated(FORK, at, parentRun), + ...[1, 2, 3, 4].map((n) => itemUpdated(FORK, null, `fork-${n}`, n, at)), + threadCreated(LONG, at), + runCreated(LONG, longRun, at), + // The request sits far outside the recent window; its result is inside it. + itemUpdated(LONG, longRun, "request", 1, at, "run_interrupt_request"), + ...Array.from({ length: 90 }, (_, index) => + itemUpdated(LONG, longRun, `tool-${index}`, index + 2, at), + ), + itemUpdated(LONG, longRun, "result", 92, at, "run_interrupt_result"), + ]; + for (const event of events) yield* projections.apply(event); +}); + +// Bounded subscriptions without afterSequence never read the event store, and +// thread routes never touch projects. +const TestLayer = Layer.mergeAll( + management, + Layer.effectDiscard(seed), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + Layer.mock(ProjectEnrichmentService.ProjectEnrichmentService)({}), +).pipe(Layer.provideMerge(store)); + +const withHttp = (use: (get: (path: string) => Promise) => Promise) => + Effect.gen(function* () { + const context = yield* Effect.context>(); + const appLayer = HttpApiBuilder.layer(OrchestrationApi).pipe( + Layer.provide(OrchestrationHttp.layer), + Layer.provide(auth), + Layer.provide(Layer.succeedContext(context)), + Layer.provide(NodeHttpPlatform.layer), + Layer.provide(Etag.layerWeak), + Layer.provide(NodeServices.layer), + ); + return yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(appLayer, { disableLogger: true })), + ({ handler }) => + Effect.promise(() => + use((path) => + handler( + new Request(`http://environment.test${path}`, { + headers: { + [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION), + }, + }), + ), + ), + ), + ({ dispose }) => Effect.promise(() => dispose()), + ); + }); + +const firstSnapshot = (input: Parameters[0]) => + subscribeOrchestrationV2Thread(input).pipe( + Effect.flatMap((stream) => Stream.runHead(stream)), + Effect.map((head) => { + if (head._tag !== "Some" || head.value.kind !== "snapshot") throw new Error("No snapshot"); + // Through the wire codec, as a client receives it. + const decoded = decodeStreamItem(JSON.parse(JSON.stringify(encodeStreamItem(head.value)))); + if (decoded.kind !== "snapshot") throw new Error("No snapshot"); + return { raw: head.value, decoded }; + }), + ); + +function itemIds(projection: OrchestrationV2ThreadProjection): string[] { + return projection.turnItems.map((item) => String(item.id)); +} + +it.layer(TestLayer)("compact bounded snapshot transport", (it) => { + describe.each([ + ["fork", FORK], + ["long run with a retained interrupt request", LONG], + ])("%s", (_, threadId) => { + it.effect("HTTP: legacy, ignored and opted-in requests restore to the same snapshot", () => + withHttp(async (get) => { + const base = `/api/orchestration/threads/${encodeURIComponent(threadId)}/bounded`; + const legacyBody = await (await get(base)).text(); + const ignoredBody = await (await get(`${base}?compactTurnItems=yes&unknown=1`)).text(); + const compactResponse = await get(`${base}?compactTurnItems=1`); + assert.strictEqual(compactResponse.status, 200); + const compactBody = await compactResponse.text(); + + // Requests without the exact opt-in get the unchanged representation. + assert.notInclude(legacyBody, "turnItemsOmitLocalVisible"); + assert.strictEqual(ignoredBody, legacyBody); + assert.isBelow(compactBody.length, legacyBody.length); + + const legacy = decodeBounded(legacyBody); + const compact = decodeBounded(compactBody); + assert.isTrue(compact.turnItemsOmitLocalVisible); + const { turnItemsOmitLocalVisible: _marker, projection: _p, ...compactRest } = compact; + const { projection: _legacyProjection, ...legacyRest } = legacy; + assert.deepStrictEqual(compactRest, legacyRest); + assert.deepStrictEqual(boundedSnapshotProjection(compact), legacy.projection); + return { legacy, compact }; + }).pipe( + Effect.tap(({ legacy }) => + Effect.sync(() => { + if (threadId === FORK) { + const visibility = legacy.projection.visibleTurnItems.map((row) => row.visibility); + assert.deepStrictEqual(visibility, [ + "inherited", + "inherited", + "inherited", + "synthetic", + "local", + "local", + "local", + "local", + ]); + } else { + const ids = itemIds(legacy.projection); + assert.isTrue(legacy.hasMoreHistory); + // Window rows first, then the retained request outside the window. + assert.strictEqual(ids.at(-1), `turn-item:${LONG}:request`); + assert.isFalse( + legacy.projection.visibleTurnItems.some((row) => row.sourceItemId === ids.at(-1)), + ); + } + }), + ), + ), + ); + + it.effect("WS: bounded snapshots stay unchanged unless the client opts in", () => + Effect.gen(function* () { + const legacy = yield* firstSnapshot({ threadId, acceptBoundedSnapshot: true }); + const compact = yield* firstSnapshot({ + threadId, + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + }); + // Full snapshots ignore the compact opt-in entirely. + const full = yield* firstSnapshot({ threadId, acceptCompactTurnItems: true }); + + assert.isUndefined(legacy.decoded.turnItemsOmitLocalVisible); + assert.isUndefined(full.decoded.turnItemsOmitLocalVisible); + assert.isUndefined(full.decoded.historyCursor); + assert.isTrue(compact.decoded.turnItemsOmitLocalVisible); + assert.isBelow( + compact.decoded.projection.turnItems.length, + legacy.decoded.projection.turnItems.length, + ); + assert.deepStrictEqual( + boundedSnapshotProjection(compact.decoded), + legacy.decoded.projection, + ); + assert.deepStrictEqual( + { + ...compact.decoded, + projection: legacy.decoded.projection, + turnItemsOmitLocalVisible: undefined, + }, + { ...legacy.decoded, turnItemsOmitLocalVisible: undefined }, + ); + }), + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index cd433d3621ad..e6b769d98c40 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -32,6 +32,7 @@ import { import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; import { buildActiveShellSnapshot } from "./ShellStream.ts"; +import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; function isThreadNotFound(error: unknown): boolean { @@ -211,11 +212,10 @@ export const layer = HttpApiBuilder.group( }); return { snapshotSequence: snapshot.snapshotSequence, - projection: bounded.projection, - historyCursor: bounded.historyCursor, - hasMoreHistory: bounded.hasMoreHistory, - latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal, - payloadBudgetExceeded: bounded.payloadBudgetExceeded, + ...boundedSnapshotResponseFields({ + bounded, + compactTurnItems: args.query.compactTurnItems === "1", + }), }; }), ) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55d5ec85b5a5..53bf27800583 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -748,6 +748,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptCompactTurnItems?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -841,7 +842,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ); const { snapshotSequence } = snapshot; const snapshotItem = useBoundedSnapshot - ? buildBoundedThreadStreamSnapshot(snapshot) + ? buildBoundedThreadStreamSnapshot({ + ...snapshot, + compactTurnItems: input.acceptCompactTurnItems === true, + }) : { kind: "snapshot" as const, snapshotSequence, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 48d26b31f70c..906f06fb95aa 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -76,7 +76,84 @@ const FULL_SNAPSHOT_BODY = { }, }; +const AT = "2026-06-20T00:00:00.000Z"; +const command = (id: string, ordinal: number) => ({ + id, + type: "command_execution", + threadId: String(THREAD_ID), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + input: id, + exitCode: 0, + startedAt: AT, + completedAt: AT, + updatedAt: AT, +}); +const LOCAL_ITEMS = [command("local-1", 1), command("local-2", 2)]; +const DEPENDENCY = command("older-dependency", 0); +const VISIBLE = LOCAL_ITEMS.map((item, position) => ({ + position, + visibility: "local", + sourceThreadId: String(THREAD_ID), + sourceItemId: item.id, + item, +})); +const boundedBody = (compact: boolean) => ({ + snapshotSequence: 12, + projection: { + ...FULL_SNAPSHOT_BODY.projection, + turnItems: compact ? [DEPENDENCY] : [...LOCAL_ITEMS, DEPENDENCY], + visibleTurnItems: VISIBLE, + }, + historyCursor: "bounded-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + payloadBudgetExceeded: false, + ...(compact ? { turnItemsOmitLocalVisible: true } : {}), +}); + describe("boundedThreadSnapshotLoader", () => { + it.effect.each([ + ["an older server that ignores the opt-in", false], + ["a server that sends compact turnItems", true], + ] as const)("requests compact turnItems and loads %s", ([, compact]) => { + const urls: string[] = []; + const fetchFn = ((input: RequestInfo | URL) => { + urls.push(String(input)); + return Promise.resolve(Response.json(boundedBody(compact))); + }) satisfies typeof fetch; + + return Effect.gen(function* () { + const loader = yield* ThreadSnapshotLoader.ThreadSnapshotLoader; + const result = yield* loader.load(PREPARED, THREAD_ID); + expect(new URL(urls[0]!).searchParams.get("compactTurnItems")).toBe("1"); + expect(result._tag).toBe("present"); + if (result._tag !== "present") return; + expect(result.snapshot.projection.turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "older-dependency", + ]); + expect("turnItemsOmitLocalVisible" in result.snapshot).toBe(false); + expect(result.history).toEqual({ + historyCursor: "bounded-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + }); + }).pipe( + Effect.provide( + Layer.provide(BoundedThreadSnapshotHttp.layer, RpcHttp.layerRemoteHttpClient(fetchFn)), + ), + ); + }); + it.effect("falls back to full HTTP snapshot when bounded returns a plain route 404", () => { const fetchFn = ((input: RequestInfo | URL) => { const url = String(input); diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index ffad41874c94..a049a6b14863 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -1,4 +1,5 @@ import type { ThreadId } from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,7 +19,11 @@ import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; // Same cold-open budget as the full snapshot path; bounded payloads should fit. const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; -/** Load a bounded recent-window thread snapshot over HTTP. */ +/** + * Load a bounded recent-window thread snapshot over HTTP. Opts into compact + * turnItems and restores them, so callers always see the full bounded shape. + * Older servers ignore the query and send the full shape. + */ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot", )(function* (input: { @@ -40,9 +45,16 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( request: ({ client, headers }) => client.threadBoundedSnapshot({ params: { threadId: input.threadId }, + query: { compactTurnItems: "1" }, headers: withOrchestrationProtocolHeader(headers), }), - }); + }).pipe( + // Drop the marker with the restore so nothing can restore twice. + Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({ + ...snapshot, + projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }), + })), + ); }); /** diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index a832d46b6732..d6cbbf6a7f42 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -122,6 +122,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const lastSubscribeAfterSequence = yield* Ref.make(undefined); const lastRequestCompletionMarker = yield* Ref.make(false); const lastAcceptBoundedSnapshot = yield* Ref.make(undefined); + const lastAcceptCompactTurnItems = yield* Ref.make(undefined); const wakeups = yield* Queue.unbounded(); const savedThreads = yield* Ref.make>([]); const removedThreads = yield* Ref.make>([]); @@ -139,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly afterSequence?: number; readonly requestCompletionMarker?: true; readonly acceptBoundedSnapshot?: true; + readonly acceptCompactTurnItems?: true; }) => Stream.unwrap( Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( @@ -147,6 +149,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Ref.set(lastRequestCompletionMarker, input.requestCompletionMarker === true), ), Effect.andThen(Ref.set(lastAcceptBoundedSnapshot, input.acceptBoundedSnapshot)), + Effect.andThen(Ref.set(lastAcceptCompactTurnItems, input.acceptCompactTurnItems)), Effect.as(streamFrom(inputs)), ), ), @@ -261,6 +264,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o lastSubscribeAfterSequence, lastRequestCompletionMarker, lastAcceptBoundedSnapshot, + lastAcceptCompactTurnItems, supervisorState, supervisorSession, savedThreads, @@ -842,6 +846,113 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("restores compact socket snapshot turnItems before reducing and caching", () => + Effect.gen(function* () { + const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const command = (id: string, ordinal: number, threadId = THREAD_ID) => + ({ + id: TurnItemId.make(id), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: "command_execution" as const, + input: id, + output: id, + exitCode: 0, + }) satisfies OrchestrationV2TurnItem; + const parentThread = ThreadId.make("thread:compact-parent"); + const inherited = command("parent-item", 1, parentThread); + const local = [command("local-1", 1), command("local-2", 2)]; + const retainedRequest = { + ...command("retained-request", 0), + type: "run_interrupt_request" as const, + message: "Stop", + } as unknown as OrchestrationV2TurnItem; + const visibleTurnItems = [ + { + position: 0, + visibility: "inherited" as const, + sourceThreadId: parentThread, + sourceItemId: inherited.id, + item: inherited, + }, + ...local.map((item, index) => ({ + position: index + 1, + visibility: "local" as const, + sourceThreadId: THREAD_ID, + sourceItemId: item.id, + item, + })), + ]; + const harness = yield* makeHarness(); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshotSequence: 5, + // Only the dependency that is not a visible local row is on the wire. + projection: { ...BASE_PROJECTION, turnItems: [retainedRequest], visibleTurnItems }, + historyCursor: "compact-cursor", + hasMoreHistory: true, + latestLocalTurnOrdinal: 2, + payloadBudgetExceeded: false, + turnItemsOmitLocalVisible: true, + }); + + const seeded = yield* awaitThreadState( + harness.observed, + (value) => value.history.historyCursor === "compact-cursor", + ); + expect(Option.getOrThrow(seeded.data).turnItems).toEqual([...local, retainedRequest]); + expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); + expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true); + + // Live updates reduce against the restored list, not the compact one. + const updated = { ...local[1]!, output: "updated", ordinal: 2 }; + yield* Queue.offer(harness.inputs, { + kind: "event", + sequence: 6, + event: { + id: EventId.make("event-compact-update"), + type: "turn-item.updated", + threadId: THREAD_ID, + occurredAt: at, + payload: updated, + }, + }); + const live = yield* awaitThreadState(harness.observed, (value) => + Option.exists(value.data, (data) => + data.turnItems.some( + (item) => item.type === "command_execution" && item.output === "updated", + ), + ), + ); + expect(Option.getOrThrow(live.data).turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "retained-request", + ]); + + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + const saved = (yield* Ref.get(harness.savedThreads)).at(-1); + expect(saved?.projection.turnItems.map((item) => String(item.id))).toEqual([ + "local-1", + "local-2", + "retained-request", + ]); + expect(saved && "turnItemsOmitLocalVisible" in saved).toBe(false); + }), + ); + it.effect("seeds the thread from the HTTP snapshot and resumes live events", () => Effect.gen(function* () { const httpProjection: OrchestrationV2ThreadProjection = { diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 62df7cd30dfb..5c64c41b1d01 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -6,6 +6,7 @@ import { type OrchestrationV2ThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; +import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -589,7 +590,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // Bounded socket fallbacks carry their cursor. Legacy-compatible full // snapshots omit these fields and still replace progressive state. yield* setThread( - item.projection, + boundedSnapshotProjection(item), hasProgressiveHistory ? { history: { @@ -919,7 +920,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - ...(acceptBoundedSnapshot ? { acceptBoundedSnapshot: true as const } : {}), + ...(acceptBoundedSnapshot + ? { acceptBoundedSnapshot: true as const, acceptCompactTurnItems: true as const } + : {}), }; }), { diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a722ba064bd3..b74affb86216 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -584,6 +584,14 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +/** + * `compactTurnItems=1` opts into `turnItemsOmitLocalVisible`. Other values are + * ignored, and older servers ignore the whole query. + */ +const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ + compactTurnItems: Schema.optionalKey(Schema.String), +}); + const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, }); @@ -615,6 +623,7 @@ class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") HttpApiEndpoint.get("threadBoundedSnapshot", "/api/orchestration/threads/:threadId/bounded", { headers: OrchestrationProtocolHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + query: EnvironmentOrchestrationThreadBoundedSnapshotQuery, success: OrchestrationV2ThreadBoundedSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index e8fd216de980..cd185f908d5c 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -45,6 +45,48 @@ import { } from "./orchestrationV2.ts"; const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); +function emptyThreadProjection() { + return { + thread: { + createdBy: "user", + creationSource: "web", + id: "thread-1", + projectId: "project-1", + title: "Thread", + providerInstanceId: "codex", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + }, + runs: [], + attempts: [], + nodes: [], + subagents: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + runtimeRequests: [], + messages: [], + plans: [], + turnItems: [], + checkpointScopes: [], + checkpoints: [], + contextHandoffs: [], + contextTransfers: [], + visibleTurnItems: [], + updatedAt: now, + }; +} + const LegacyShellStreamItem = Schema.Union([ Schema.Struct({ kind: Schema.Literal("synchronized") }), Schema.Struct({ @@ -326,6 +368,47 @@ describe("orchestration V2 contracts", () => { expect("acceptBoundedSnapshot" in legacyDecoded).toBe(false); }); + it("negotiates compact bounded turnItems without disturbing older peers", () => { + // Older servers strip the unknown opt-in, so they keep sending full turnItems. + const legacyDecoded = decodeLegacySubscribeThreadInput({ + threadId: "thread-1", + acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + }); + expect("acceptCompactTurnItems" in legacyDecoded).toBe(false); + expect( + decodeOrchestrationV2SubscribeThreadInput({ + threadId: "thread-1", + acceptCompactTurnItems: true, + }).acceptCompactTurnItems, + ).toBe(true); + + const decodeStreamItem = Schema.decodeUnknownSync(OrchestrationV2ThreadStreamItem); + const projection = decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + }); + // Older servers never send the marker; newer clients treat absence as full turnItems. + expect(projection.kind === "snapshot" && projection.turnItemsOmitLocalVisible).toBe(undefined); + const marked = decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + turnItemsOmitLocalVisible: true, + }); + expect(marked.kind === "snapshot" && marked.turnItemsOmitLocalVisible).toBe(true); + // The marker only means "omitted"; any other value is a protocol error. + expect(() => + decodeStreamItem({ + kind: "snapshot", + snapshotSequence: 1, + projection: emptyThreadProjection(), + turnItemsOmitLocalVisible: false, + }), + ).toThrow(); + }); + it("decodes persisted capability snapshots that predate runtimePolicy", () => { // Events written before the field existed must replay; absent decodes to // the weaker client-boundary guarantee so history never overclaims. diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..abc226e356df 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3244,6 +3244,11 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Allows snapshot fallbacks to contain a bounded, pageable history window. */ acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), + /** + * Allows bounded snapshot fallbacks to omit `projection.turnItems` entries + * that repeat local visible rows. See `turnItemsOmitLocalVisible`. + */ + acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; @@ -3283,6 +3288,12 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt), /** True when complete turns or required live control state exceed the usual byte budget. */ payloadBudgetExceeded: Schema.optional(Schema.Boolean), + /** + * Set only for clients that opted in: `projection.turnItems` omits the items + * of local visible rows, which lead the full list. Clients must restore them + * with `boundedSnapshotProjection` before using the projection. + */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; @@ -3363,6 +3374,8 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([ hasMoreHistory: Schema.optionalKey(Schema.Boolean), latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean), + /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ + turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }), Schema.Struct({ kind: Schema.Literal("event"), diff --git a/packages/shared/package.json b/packages/shared/package.json index 50cc08129d8e..4025994af076 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -359,6 +359,10 @@ "types": "./src/nodeSqliteClient.ts", "import": "./src/nodeSqliteClient.ts" }, + "./orchestrationV2BoundedSnapshot": { + "types": "./src/orchestrationV2BoundedSnapshot.ts", + "import": "./src/orchestrationV2BoundedSnapshot.ts" + }, "./orchestrationV2Timeline": { "types": "./src/orchestrationV2Timeline.ts", "import": "./src/orchestrationV2Timeline.ts" diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts new file mode 100644 index 000000000000..cf231c0617ef --- /dev/null +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.test.ts @@ -0,0 +1,147 @@ +import { + RunId, + ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + boundedSnapshotProjection, + omitLocalVisibleTurnItems, + restoreLocalVisibleTurnItems, +} from "./orchestrationV2BoundedSnapshot.ts"; + +const PARENT = ThreadId.make("thread:compact-parent"); +const CHILD = ThreadId.make("thread:compact-child"); +const RUN = RunId.make("run:compact"); + +function item( + threadId: ThreadId, + id: string, + ordinal: number, + type: "command_execution" | "run_interrupt_request" | "fork" = "command_execution", +): OrchestrationV2TurnItem { + return { + id: TurnItemId.make(id), + type, + threadId, + runId: type === "command_execution" ? null : RUN, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: id, + input: id, + exitCode: 0, + startedAt: null, + completedAt: null, + updatedAt: "2026-10-01T00:00:00.000Z", + } as unknown as OrchestrationV2TurnItem; +} + +function row( + visibility: OrchestrationV2ProjectedTurnItem["visibility"], + sourceThreadId: ThreadId, + value: OrchestrationV2TurnItem, +): Omit { + return { visibility, sourceThreadId, sourceItemId: value.id, item: value }; +} + +function projection(input: { + readonly visible: ReadonlyArray>; + readonly turnItems: ReadonlyArray; +}): OrchestrationV2ThreadProjection { + return { + thread: { id: CHILD }, + turnItems: [...input.turnItems], + visibleTurnItems: input.visible.map((value, position) => ({ ...value, position })), + } as unknown as OrchestrationV2ThreadProjection; +} + +/** Fork child: inherited parent rows, synthetic marker, local rows, retained interrupt request. */ +function forkWindow() { + const inherited = [item(PARENT, "parent-1", 1), item(PARENT, "parent-2", 2)]; + const marker = item(PARENT, "fork-marker", 3, "fork"); + const local = [item(CHILD, "child-1", 1), item(CHILD, "child-2", 2), item(CHILD, "child-3", 3)]; + const retainedRequest = item(CHILD, "interrupt-request", 0, "run_interrupt_request"); + return { + local, + retainedRequest, + projection: projection({ + visible: [ + ...inherited.map((value) => row("inherited", PARENT, value)), + row("synthetic", PARENT, marker), + ...local.map((value) => row("local", CHILD, value)), + ], + turnItems: [...local, retainedRequest], + }), + }; +} + +describe("compact bounded snapshot turnItems", () => { + it("omits only local visible items and restores the exact list and order", () => { + const { projection: full, local, retainedRequest } = forkWindow(); + const compact = omitLocalVisibleTurnItems(full); + + expect(compact).not.toBeNull(); + // Inherited and synthetic rows never had turnItems; the retained request stays. + expect(compact!.turnItems).toEqual([retainedRequest]); + expect(compact!.visibleTurnItems).toBe(full.visibleTurnItems); + + const restored = restoreLocalVisibleTurnItems(compact!); + expect(restored.turnItems).toEqual([...local, retainedRequest]); + expect(restored).toEqual(full); + }); + + it("round-trips through JSON as a separately decoded client would see it", () => { + const { projection: full } = forkWindow(); + const wire = JSON.parse(JSON.stringify(omitLocalVisibleTurnItems(full))); + expect( + boundedSnapshotProjection({ projection: wire, turnItemsOmitLocalVisible: true }), + ).toEqual(JSON.parse(JSON.stringify(full))); + }); + + it("treats rows sourced from the thread itself as local", () => { + const own = item(CHILD, "own", 1); + const full = projection({ visible: [row("inherited", CHILD, own)], turnItems: [own] }); + const compact = omitLocalVisibleTurnItems(full); + expect(compact?.turnItems).toEqual([]); + expect(restoreLocalVisibleTurnItems(compact!)).toEqual(full); + }); + + it("refuses shapes it cannot restore exactly", () => { + const [first, second] = [item(CHILD, "a", 1), item(CHILD, "b", 2)]; + const visible = [row("local", CHILD, first), row("local", CHILD, second)]; + // Different order than the visible rows. + expect(omitLocalVisibleTurnItems(projection({ visible, turnItems: [second, first] }))).toBe( + null, + ); + // Equal content but a different object: restoring would not be provably exact. + expect( + omitLocalVisibleTurnItems(projection({ visible, turnItems: [{ ...first }, second] })), + ).toBe(null); + // A local row without its turn item. + expect(omitLocalVisibleTurnItems(projection({ visible, turnItems: [first] }))).toBe(null); + }); + + it("sends nothing compact when no local rows are visible", () => { + const inherited = item(PARENT, "parent", 1); + const request = item(CHILD, "interrupt-request", 0, "run_interrupt_request"); + expect( + omitLocalVisibleTurnItems( + projection({ visible: [row("inherited", PARENT, inherited)], turnItems: [request] }), + ), + ).toBe(null); + }); + + it("leaves unmarked snapshots untouched", () => { + const { projection: full } = forkWindow(); + expect(boundedSnapshotProjection({ projection: full })).toBe(full); + }); +}); diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts new file mode 100644 index 000000000000..117306819cd2 --- /dev/null +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -0,0 +1,58 @@ +import type { + OrchestrationV2ProjectedTurnItem, + OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; + +/** Local timeline rows also carry their item in `projection.turnItems`. */ +function isLocalTimelineRow( + projection: Pick, + row: OrchestrationV2ProjectedTurnItem, +): boolean { + return row.visibility === "local" || row.sourceThreadId === projection.thread.id; +} + +/** + * Server half of the opt-in compact bounded snapshot. Bounded `turnItems` start + * with the items of the window's local visible rows, so those entries can be + * dropped and rebuilt by `restoreLocalVisibleTurnItems`. Returns null when the + * projection does not have that exact shape, or when nothing would be omitted; + * callers then send the unchanged projection without a marker. + */ +export function omitLocalVisibleTurnItems( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection | null { + let omitted = 0; + for (const row of projection.visibleTurnItems) { + if (!isLocalTimelineRow(projection, row)) continue; + // Same object, not merely the same id: restoring from the row must + // reproduce the original entry exactly. + if (projection.turnItems[omitted] !== row.item) return null; + omitted += 1; + } + if (omitted === 0) return null; + return { ...projection, turnItems: projection.turnItems.slice(omitted) }; +} + +/** + * Client half: call on a decoded snapshot that carries the + * `turnItemsOmitLocalVisible` marker, before reducers or caches see it. + */ +export function restoreLocalVisibleTurnItems( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const local = projection.visibleTurnItems + .filter((row) => isLocalTimelineRow(projection, row)) + .map((row) => row.item); + if (local.length === 0) return projection; + return { ...projection, turnItems: [...local, ...projection.turnItems] }; +} + +/** The usable projection of a decoded bounded snapshot, compact or not. */ +export function boundedSnapshotProjection(snapshot: { + readonly projection: OrchestrationV2ThreadProjection; + readonly turnItemsOmitLocalVisible?: true | undefined; +}): OrchestrationV2ThreadProjection { + return snapshot.turnItemsOmitLocalVisible === true + ? restoreLocalVisibleTurnItems(snapshot.projection) + : snapshot.projection; +} From eb17e0db2bd075e71b4015dc57588e7f838cbcdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:48 -0700 Subject: [PATCH 052/530] perf(mobile): pause elapsed-time timers on hidden thread screens (#15397) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/mobile/package.json | 1 + .../features/threads/ProviderSubagentBar.tsx | 9 +- .../threads/floating-working-control.tsx | 7 +- .../threads/use-visible-second-clock.test.tsx | 199 ++++++++++++++++++ .../threads/use-visible-second-clock.ts | 34 +++ pnpm-lock.yaml | 3 + 6 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.test.tsx create mode 100644 apps/mobile/src/features/threads/use-visible-second-clock.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ca295e389fe2..b8d8dea0d058 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -135,6 +135,7 @@ "@types/react": "~19.3.0", "@types/react-dom": "~19.2.3", "babel-preset-expo": "~58.0.8", + "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx index 813b64148901..fc93df0cd7a9 100644 --- a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -3,12 +3,12 @@ import { type ProviderSubagentStatus, } from "@t3tools/client-runtime/state/thread-execution"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; -import { useEffect, useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { RequestActionButton } from "./RequestActionButton"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; /** * Replaces the composer on a provider-native subagent thread. The provider @@ -26,12 +26,7 @@ export function ProviderSubagentBar(props: { readonly onOpenParent: (() => void) | null; }) { const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - if (!live) return; - const id = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(id); - }, [live]); + const nowMs = useVisibleSecondClock(live); const statusLabel = formatProviderSubagentStatus(props.status, nowMs); const modelDescription = props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 2e032eb8ba5e..f21a83f09a17 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -30,6 +30,7 @@ import { BrowserPreviewButton } from "../browser/browser-preview-button"; import { DevicePreviewButton } from "../devices/device-preview-button"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ShimmeringWorkContent } from "./thread-work-log"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so @@ -510,11 +511,7 @@ function WorkingDuration(props: { } export function WorkingTimer(props: { readonly startedAt: string }) { - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, []); + const nowMs = useVisibleSecondClock(true); return ( { + const listeners = new Set<() => void>(); + const appStateListeners = new Set<(state: string) => void>(); + const state = { focused: true, appState: "active" }; + return { + state, + appStateListeners, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setFocused(focused: boolean) { + state.focused = focused; + for (const listener of listeners) listener(); + }, + setAppState(next: string) { + state.appState = next; + for (const listener of appStateListeners) listener(next); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused), +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return harness.state.appState; + }, + addEventListener: (_event: "change", listener: (state: string) => void) => { + harness.appStateListeners.add(listener); + return { remove: () => harness.appStateListeners.delete(listener) }; + }, + }, +})); + +import { useVisibleSecondClock } from "./use-visible-second-clock"; + +const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0); +let root: Root | null = null; +// Clock value of every committed render, the work a tick costs on device. +let renders: number[] = []; + +function Probe(props: { readonly enabled: boolean }) { + const nowMs = useVisibleSecondClock(props.enabled); + useEffect(() => { + renders.push(nowMs); + }); + return null; +} + +function mount(enabled: boolean) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => root!.render()); +} + +// One act per second, so each tick commits its own render as it would on device. +function advance(ms: number) { + for (let elapsed = 0; elapsed < ms; elapsed += 1_000) { + act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed))); + } +} + +function latest() { + return renders.at(-1); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + harness.state.focused = true; + harness.state.appState = "active"; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.useRealTimers(); +}); + +describe("useVisibleSecondClock", () => { + it("ticks once a second while enabled, focused and active", () => { + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(60); + expect(latest()).toBe(START_MS + 60_000); + }); + + it("does not wake while the app is in the background", () => { + harness.state.appState = "background"; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake while the screen is unfocused", () => { + harness.state.focused = false; + mount(true); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wake when disabled, even when visible", () => { + mount(false); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + }); + + it("notices an AppState change between first render and subscribing", () => { + // Layout effects run before the hook's passive subscription, so this change has no listener. + function BackgroundBeforeSubscribe() { + useLayoutEffect(() => harness.setAppState("background"), []); + return null; + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + <> + + + , + ), + ); + const before = renders.length; + advance(60_000); + expect(renders.length - before).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => { + mount(true); + advance(5_000); + act(() => harness.setFocused(false)); + const paused = renders.length; + advance(120_000); + expect(renders.length).toBe(paused); + + act(() => harness.setFocused(true)); + expect(latest()).toBe(START_MS + 125_000); + advance(1_000); + expect(latest()).toBe(START_MS + 126_000); + }); + + it("pauses while backgrounded and catches up on the first active render", () => { + mount(true); + act(() => harness.setAppState("background")); + const paused = renders.length; + advance(600_000); + expect(renders.length).toBe(paused); + expect(vi.getTimerCount()).toBe(0); + + act(() => harness.setAppState("inactive")); + advance(10_000); + expect(renders.length).toBe(paused); + + act(() => harness.setAppState("active")); + expect(latest()).toBe(START_MS + 610_000); + const resumed = renders.length; + advance(60_000); + expect(renders.length - resumed).toBe(60); + }); + + it("stops when the work it measures settles and refreshes when new work starts", () => { + mount(true); + advance(3_000); + act(() => root!.render()); + const settled = renders.length; + advance(60_000); + expect(renders.length).toBe(settled); + + act(() => root!.render()); + expect(latest()).toBe(START_MS + 63_000); + }); + + it("clears its interval and AppState subscription on unmount", () => { + mount(true); + expect(vi.getTimerCount()).toBe(1); + expect(harness.appStateListeners.size).toBe(1); + act(() => root!.unmount()); + root = null; + expect(vi.getTimerCount()).toBe(0); + expect(harness.appStateListeners.size).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.ts b/apps/mobile/src/features/threads/use-visible-second-clock.ts new file mode 100644 index 000000000000..ff98092b231b --- /dev/null +++ b/apps/mobile/src/features/threads/use-visible-second-clock.ts @@ -0,0 +1,34 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { AppState } from "react-native"; + +/** + * Wall clock for elapsed-time labels, advanced once a second only while + * `enabled`, the screen is focused, and the app is active. Retained routes stay + * mounted when hidden, so an ungated interval would keep re-rendering them. + * The clock re-reads the time before paint when ticking resumes, so a label + * never shows the moment it paused. + */ +export function useVisibleSecondClock(enabled: boolean): number { + const focused = useIsFocused(); + const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setAppActive(state === "active"), + ); + // A change between the first render and this subscription has no event to replay. + // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. + setAppActive(AppState.currentState === "active"); + return () => subscription.remove(); + }, []); + const ticking = enabled && focused && appActive; + useLayoutEffect(() => { + if (!ticking) return; + // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. + setNowMs(Date.now()); + const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [ticking]); + return nowMs; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a54dc0d1fa2..bf0256cee1cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,9 @@ importers: babel-preset-expo: specifier: ~58.0.8 version: 58.0.8(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@58.0.11)(expo@58.0.2)(react-refresh@0.14.2) + jsdom: + specifier: ^30.0.1 + version: 30.1.0(@noble/hashes@1.8.0) tailwindcss: specifier: 4.3.3 version: 4.3.3 From 65404eb75de9233c9d1068ddfd09bacfc3b0465b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:30:48 -0700 Subject: [PATCH 053/530] fix(mobile): pause hidden home thread list updates (#15705) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/home/HomeRouteScreen.tsx | 12 +- .../home/home-route-visibility.test.tsx | 143 ++++++++++++++++++ .../features/home/home-route-visibility.ts | 57 +++++++ 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/home/home-route-visibility.test.tsx create mode 100644 apps/mobile/src/features/home/home-route-visibility.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 0f213ca59b07..cd8af281c9f5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -9,7 +9,8 @@ import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useProjects, useNavigationThreadShells } from "../../state/entities"; +import { useProjects } from "../../state/entities"; +import { environmentThreadShells } from "../../state/threads"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -22,6 +23,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; +import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; import { useHomeThreadSelection } from "./home-thread-navigation"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; @@ -37,7 +39,13 @@ export function HomeRouteScreen() { const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; const { layout, panes } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); - const threads = useNavigationThreadShells(); + // Streaming turns rewrite thread shells many times a second. While a Thread + // covers Home, rebuilding this list is invisible work. + const visible = useHomeRouteVisible(); + const threads = useAtomValueWhileVisible( + environmentThreadShells.navigationThreadShellsAtom, + visible, + ); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx new file mode 100644 index 000000000000..f1c670119ce1 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type Listener = (event: { readonly data?: { readonly closing: boolean } }) => void; + +const harness = vi.hoisted(() => { + const listeners = new Map>(); + const state = { focused: true }; + return { + state, + listeners, + navigation: { + isFocused: () => state.focused, + addListener(type: string, listener: Listener) { + let set = listeners.get(type); + if (set === undefined) { + set = new Set(); + listeners.set(type, set); + } + set.add(listener); + return () => set.delete(listener); + }, + }, + emit(type: string, data?: { readonly closing: boolean }) { + for (const listener of listeners.get(type) ?? []) listener({ data }); + }, + }; +}); + +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => harness.navigation, +})); + +import { useAtomValueWhileVisible, useHomeRouteVisible } from "./home-route-visibility"; + +const shellsAtom = Atom.make(0).pipe(Atom.keepAlive); +let registry: AtomRegistry.AtomRegistry; +let root: Root | null = null; +// Value of every committed render, the work a shell update costs on device. +let renders: Array<{ readonly visible: boolean; readonly value: number }> = []; + +function Probe() { + const visible = useHomeRouteVisible(); + const value = useAtomValueWhileVisible(shellsAtom, visible); + useEffect(() => { + renders.push({ visible, value }); + }); + return null; +} + +function mount() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.createElement("div")); + act(() => + root!.render( + + + , + ), + ); +} + +function stream(count: number) { + for (let index = 0; index < count; index += 1) { + act(() => registry.update(shellsAtom, (value) => value + 1)); + } +} + +function pushThread() { + act(() => harness.emit("transitionStart", { closing: true })); + act(() => harness.emit("transitionEnd", { closing: true })); +} + +beforeEach(() => { + registry = AtomRegistry.make(); + harness.listeners.clear(); + harness.state.focused = true; + renders = []; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + registry.dispose(); +}); + +describe("Home route visibility", () => { + it("re-renders for every shell update while Home is visible", () => { + mount(); + stream(20); + expect(renders.at(-1)).toEqual({ visible: true, value: 20 }); + expect(renders).toHaveLength(21); + }); + + it("stays live until the covering Thread finishes its push animation", () => { + mount(); + act(() => harness.emit("transitionStart", { closing: true })); + stream(3); + expect(renders.at(-1)).toEqual({ visible: true, value: 3 }); + }); + + it("does no work for shell updates while a Thread covers Home", () => { + mount(); + stream(2); + pushThread(); + const before = renders.length; + stream(600); + expect(renders.length - before).toBe(0); + expect(renders.at(-1)).toEqual({ visible: false, value: 2 }); + }); + + it("shows the freshest list as soon as a back swipe starts revealing Home", () => { + mount(); + pushThread(); + stream(50); + act(() => harness.emit("transitionStart", { closing: false })); + expect(renders.at(-1)).toEqual({ visible: true, value: 50 }); + stream(1); + expect(renders.at(-1)).toEqual({ visible: true, value: 51 }); + }); + + it("refreshes on focus when no appear event arrives", () => { + mount(); + pushThread(); + stream(5); + act(() => harness.emit("focus")); + expect(renders.at(-1)).toEqual({ visible: true, value: 5 }); + }); + + it("starts hidden when a deep link mounts Home under its Thread", () => { + harness.state.focused = false; + act(() => registry.set(shellsAtom, 7)); + mount(); + stream(10); + expect(renders).toEqual([{ visible: false, value: 7 }]); + act(() => harness.emit("transitionStart", { closing: false })); + expect(renders.at(-1)).toEqual({ visible: true, value: 17 }); + }); +}); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts new file mode 100644 index 000000000000..1d5f5c7aab99 --- /dev/null +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -0,0 +1,57 @@ +import { RegistryContext } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { Atom } from "effect/reactivity"; + +/** + * Whether the Home route can be seen. Home stays mounted under a pushed + * Thread, and native-stack never freezes the route directly below the focused + * one on Fabric, so Home has to stop its own work. + * + * Driven by the native appear/disappear events rather than focus. A form sheet + * over Home blurs it but leaves it on screen, and the presenting screen gets no + * disappear event for it. Home becomes visible as soon as a back swipe or pop + * starts revealing it, so the list is fresh during the transition. + */ +export function useHomeRouteVisible(): boolean { + const navigation = + useNavigation>(); + // A cold deep link mounts Home under its Thread without ever showing it. + const [visible, setVisible] = useState(() => navigation.isFocused()); + useEffect(() => { + const show = () => setVisible(true); + const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { + if (!data.closing) show(); + }); + const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { + if (data.closing) setVisible(false); + }); + // Popping a sheet or a deep-linked route above Home has no appear event. + const removeFocus = navigation.addListener("focus", show); + return () => { + removeTransitionStart(); + removeTransitionEnd(); + removeFocus(); + }; + }, [navigation]); + return visible; +} + +/** + * Like `useAtomValue`, but unsubscribed while `visible` is false: the last + * visible value is held and atom changes do not re-render. Resubscribing reads + * the current value in the same render that turns visibility back on. + */ +export function useAtomValueWhileVisible(atom: Atom.Atom, visible: boolean): A { + const registry = useContext(RegistryContext); + const held = useRef<{ readonly value: A } | null>(null); + const subscribe = useCallback( + (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), + [registry, atom, visible], + ); + return useSyncExternalStore(subscribe, () => { + if (visible || held.current === null) held.current = { value: registry.get(atom) }; + return held.current.value; + }); +} From e803242d936af3e052757d59ab32bc7c4fcc2390 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 22:33:55 -0700 Subject: [PATCH 054/530] fix(mobile): restore file viewer insets and glass header (#17073) --- .../ios/T3ReviewDiffModule.swift | 8 +++++ .../t3-review-diff/ios/T3ReviewDiffView.swift | 36 ++++++++++++++----- apps/mobile/src/Stack.tsx | 4 +-- .../src/components/ComposerContextSheet.tsx | 1 + .../features/diffs/nativeReviewDiffSurface.ts | 2 ++ .../src/features/files/FileTreeBrowser.tsx | 24 ++++--------- .../src/features/files/SourceFileSurface.tsx | 29 +++++++++++---- .../features/files/ThreadFilesRouteScreen.tsx | 9 +++-- .../files/thread-file-navigator-pane.tsx | 1 - 9 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 96a1785f9fc6..5564dce34af3 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -49,6 +49,14 @@ public class T3ReviewDiffModule: Module { view.setContentWidth(CGFloat(contentWidth)) } + Prop("contentInsetTop") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetTop(CGFloat(inset)) + } + + Prop("contentInsetBottom") { (view: T3ReviewDiffView, inset: Double) in + view.setContentInsetBottom(CGFloat(inset)) + } + Prop("initialRowIndex") { (view: T3ReviewDiffView, initialRowIndex: Double) in view.setInitialRowIndex(initialRowIndex) } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 928e3b5669d1..dfddaf7016c3 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -563,6 +563,24 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } + // Source files fill the screen so UIKit can sample content below the glass bar. + // Keep the same visible row when header or safe-area reservations change. + func setContentInsetTop(_ inset: CGFloat) { + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + let inset = max(0, inset) + let delta = inset - scrollView.contentInset.top + guard delta != 0 else { return } + scrollView.contentInset.top = inset + scrollView.verticalScrollIndicatorInsets.top = inset + scrollView.contentOffset.y -= delta + updateViewportFrame() + } + + func setContentInsetBottom(_ inset: CGFloat) { + scrollView.contentInset.bottom = max(0, inset) + scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset) + } + func setContentResetKey(_ contentResetKey: String) { guard contentResetKey != self.contentResetKey else { return @@ -577,7 +595,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { hasAppliedInitialRowIndex = false pendingScrollFileId = nil isProgrammaticScrollActive = false - scrollView.setContentOffset(.zero, animated: false) + scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) updateContentMetrics() updateViewportFrame() lastVisibleFileId = nil @@ -870,8 +888,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { } private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let clampedOffset = min(max(targetOffset, 0), maxOffset) + let topInset = scrollView.contentInset.top + let maxOffset = max( + scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, + -topInset + ) + let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset) let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 isProgrammaticScrollActive = shouldAnimate contentView.isVerticalScrollActive = shouldAnimate @@ -889,12 +911,10 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { return } - let targetScreenY = max(0, (bounds.height - rowFrame.height) * 0.3) - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let targetOffset = min(max(rowFrame.minY - targetScreenY, 0), maxOffset) + let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom + let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3) hasAppliedInitialRowIndex = true - scrollView.setContentOffset(CGPoint(x: 0, y: targetOffset), animated: false) - updateViewportFrame() + setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false) } } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b215ba338070..569bfc11ada9 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -159,7 +159,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { }; // SOLID: opaque sheet-colored header for surfaces whose content scrolls internally -// (file viewer, terminal, review) โ€” there is nothing for glass to sample there. +// (terminal, review) โ€” there is nothing for glass to sample there. const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -750,7 +750,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ThreadFile: createNativeStackScreen({ screen: ThreadFileScreen, linking: `${THREAD_LINKING_PREFIX}/files/:path*`, - options: SOLID_HEADER_OPTIONS, + options: GLASS_HEADER_OPTIONS, }), ThreadMcpApp: createNativeStackScreen({ screen: McpAppFullscreenScreen, diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 208c15268ec1..4fbc02ab1e49 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -47,6 +47,7 @@ function ContextField(props: { label: string; value: string | null | undefined; style={{ height: Math.min(260, Math.max(100, props.value.split("\n").length * 22 + 36)) }} > diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index fda734106687..16208a7d5a2d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -121,6 +121,8 @@ export interface NativeReviewDiffViewProps extends ViewProps { readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; + readonly contentInsetTop?: number; + readonly contentInsetBottom?: number; readonly initialRowIndex?: number; readonly refreshing?: boolean; readonly nativeViewRef?: Ref; diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index ed42f5345abe..4d313b47b49f 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -8,7 +8,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; -import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { buildFileTree, flattenFileTree, @@ -42,7 +42,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; - readonly loaded: boolean; readonly loading: boolean; readonly onPressDirectory: (path: string, expand: boolean) => void; readonly onPreviewFile?: (path: string) => void; @@ -98,10 +97,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { {node.kind === "directory" && props.expanded && props.loading ? ( - ) : node.kind === "directory" && props.loaded ? ( - - {node.children.length} - ) : null} ); @@ -115,7 +110,6 @@ export function FileTreeBrowser(props: { readonly searchQuery: string; readonly searchTruncated: boolean; readonly selectedPath: string | null; - readonly loadedDirectories: ReadonlySet; readonly loadingDirectories: ReadonlySet; readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; @@ -128,14 +122,12 @@ export function FileTreeBrowser(props: { readonly selectedPathAtPress: string | null; } | null>(null); const insets = useSafeAreaInsets(); - // Native transparent-header height โ‰ˆ safe-area top + nav bar (~44). Matches the - // observed adjustedContentInset bottom (~102) seen in the native trace. - const headerInset = Platform.OS === "ios" ? insets.top + IOS_NAV_BAR_HEIGHT : 0; + const columnMetrics = useNativeColumnLayoutMetrics(); + const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const { onLoadDirectory, onPreviewFile, onSelectFile, - loadedDirectories, loadingDirectories, selectedPath: controlledSelectedPath, } = props; @@ -226,7 +218,6 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} - loaded={loadedDirectories.has(item.node.path)} loading={loadingDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} @@ -237,7 +228,6 @@ export function FileTreeBrowser(props: { expandedPaths, handleSelectFile, onPreviewFile, - loadedDirectories, loadingDirectories, selectedPath, toggleDirectory, @@ -245,8 +235,8 @@ export function FileTreeBrowser(props: { ); const extraData = useMemo( - () => ({ expandedPaths, loadedDirectories, loadingDirectories, selectedPath }), - [expandedPaths, loadedDirectories, loadingDirectories, selectedPath], + () => ({ expandedPaths, loadingDirectories, selectedPath }), + [expandedPaths, loadingDirectories, selectedPath], ); // UIKit owns the header inset on every supported iOS version. Keep the @@ -259,9 +249,7 @@ export function FileTreeBrowser(props: { data={visibleNodes} keyExtractor={(item) => item.node.path} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - scrollIndicatorInsets={ - Platform.OS === "ios" ? { top: headerInset, left: 0, right: 0, bottom: 0 } : undefined - } + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" estimatedItemSize={42} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 42b7dbf282d4..928da8b0f543 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -11,9 +11,10 @@ import { useWindowDimensions, View, } from "react-native"; - +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView, @@ -39,7 +40,11 @@ import { sourceHighlightAtom } from "./sourceHighlightingState"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; + /** The enclosing sheet or card already reserves its own chrome and safe area. */ + readonly embedded?: boolean; readonly initialLine?: number | null; + /** Override when a fixed notice already reserves the native header. */ + readonly headerInsetTop?: number; /** Keep the entire document in one native text-selection scope. */ readonly selectable?: boolean; /** Enables native pull-to-refresh on the source surface. */ @@ -184,6 +189,9 @@ function NativeSourceFileSurface( }, ) { const { NativeView, onRefresh } = props; + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const safeArea = columnMetrics?.safeArea ?? insets; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); @@ -214,6 +222,8 @@ function NativeSourceFileSurface( appearanceScheme={themeAppearance} contentResetKey={props.path} contentWidth={contentWidth} + contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)} + contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8} initialRowIndex={targetIndex ?? -1} rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight} rowsJson={rowsJson} @@ -233,6 +243,7 @@ function NativeSourceFileSurface( } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { + const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded; const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); @@ -323,6 +334,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const list = ( String(index)} @@ -354,6 +367,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { - ) : ( - - ); + const surface = + NativeView && !codeWordBreak && !props.selectable ? ( + + ) : ( + + ); + return surface; } diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 6c8cac046bb2..ca9adeaea165 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -48,6 +48,7 @@ import { FileTreeBrowser } from "./FileTreeBrowser"; import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { ScreenHeader } from "../../components/ScreenHeader"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; @@ -229,6 +230,10 @@ function FileContent(props: { }) { // Reopening a mutable host file must not reuse a poster from an earlier visit. const thumbnailInstanceId = useId(); + const insets = useSafeAreaInsets(); + const columnMetrics = useNativeColumnLayoutMetrics(); + const reservedHeaderInset = + Platform.OS === "ios" && props.truncated ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const isMarkdown = isMarkdownPreviewFile(props.relativePath); const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); @@ -300,7 +305,7 @@ function FileContent(props: { } return ( - + {props.truncated ? ( Preview limited to the first 1 MB of a truncated file. @@ -320,6 +325,7 @@ function FileContent(props: { contents={props.fileContents} path={props.relativePath} initialLine={props.initialLine} + headerInsetTop={props.truncated ? 0 : undefined} onRefresh={props.onRefresh} /> )} @@ -561,7 +567,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { Date: Wed, 7 Oct 2026 22:45:07 -0700 Subject: [PATCH 055/530] fix(web): PR code toolbar no longer overlaps in narrow panels (#15561) --- .../pullRequest/PullRequestCodeTab.tsx | 178 +++++++++++------- 1 file changed, 115 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d531259e5781..7c631825dc45 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { MessageSquareOffIcon, PilcrowIcon, Rows3Icon, + Settings2Icon, TextWrapIcon, TriangleAlertIcon, } from "lucide-react"; @@ -69,10 +70,12 @@ import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; @@ -1075,8 +1078,13 @@ function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; + const changeIgnoreWhitespace = (next: boolean) => { + setIgnoreWhitespace(next); + setDraft(null); + setSelectedLines(null); + }; const toolbar = ( -
    +
    {/* A host that reports no commits has nothing to scope by, and a dropdown whose only entry is the scope already showing is a control that does nothing. */} @@ -1133,7 +1141,7 @@ function PullRequestCodeTab({ ) : null} {/* One count, and the caveats as icons that carry their own words. Spelled out they competed for a strip this narrow and every one of them truncated to nothing. */} - + {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} @@ -1224,30 +1232,113 @@ function PullRequestCodeTab({
    - - + + } + className="@lg:hidden" + aria-label="Diff options" + /> + } + > + + + Diff options + + + changeIgnoreWhitespace(checked)} + > + Hide whitespace changes + + setWordWrap(checked)} + > + Wrap lines + + + { + if (value === "stacked" || value === "split") { + updateClientSettings({ diffLayout: value }); } - variant="ghost" - size="sm" - pressed={ignoreWhitespace} - onPressedChange={(pressed) => { - setIgnoreWhitespace(Boolean(pressed)); - setDraft(null); - setSelectedLines(null); - }} - /> - } + }} + > + + Stacked + + + Split + + + + +
    + + changeIgnoreWhitespace(Boolean(pressed))} + /> + } + > + + + + {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + + + { + const next = value[0]; + if (next === "stacked" || next === "split") { + updateClientSettings({ diffLayout: next }); + } + }} > - - - - {ignoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - - + + + + + + + + + { + setWordWrap(Boolean(pressed)); + }} + /> + } + > + + + + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + +
    {fileKeys.length > 0 ? ( ) : null} - { - const next = value[0]; - if (next === "stacked" || next === "split") { - updateClientSettings({ diffLayout: next }); - } - }} - > - - - - - - - - - { - setWordWrap(Boolean(pressed)); - }} - /> - } - > - - - - {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} - - {fileKeys.length > 0 ? ( Date: Wed, 7 Oct 2026 22:45:24 -0700 Subject: [PATCH 056/530] fix(web): PR commit menu no longer stretches across the window (#15560) --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 7c631825dc45..1339b46196b8 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1098,7 +1098,7 @@ function PullRequestCodeTab({ {scopeLabel} - + onSelectedCommitChange(value === "all" ? null : value)} From 647d8affa78c848c2a55c3e00ed14af9a9eca58e Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:26 -0700 Subject: [PATCH 057/530] fix(web): command palette scrollbar no longer clipped at the top (#17035) --- apps/web/src/components/CommandPaletteResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b909a0c97e44..8a00d8f435c2 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -147,7 +147,7 @@ export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualize onScroll={updateScrollFade} contentContainerClassName="px-2" className={cn( - "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2", + "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2 [&::-webkit-scrollbar-track]:my-2", getVirtualizedScrollFadeClassName(scrollFade), )} /> From 85a41391e9625d760f5bb2294834c38f42bbf5b8 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:46:39 -0700 Subject: [PATCH 058/530] fix(web): Usage breadcrumb stays centered on small viewports (#15552) --- apps/web/src/components/usage/UsagePage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 0a525054915f..a7a1be01188d 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -328,8 +328,8 @@ export function UsagePage() { ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; const topbarContent = ( -
    - +
    +

    Usage

    @@ -398,7 +398,7 @@ export function UsagePage() {
    -
    +
    { element?.focus(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8a5db463884e..d668fa7871b2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1763,7 +1763,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Unpin thread" onClick={handleUnpinClick} - className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className="group/unpin inline-flex cursor-pointer items-center rounded-sm text-muted-foreground/65 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" /> } > @@ -1876,7 +1876,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { type="button" aria-label="Dismiss Woke notification" onClick={handleAcknowledgeWokeClick} - className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring" + className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-warning-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" > Woke @@ -2040,7 +2040,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { aria-label="Dismiss Woke notification" onClick={handleAcknowledgeWokeClick} className={cn( - "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", + "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", topStatus.className, )} > diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index 12d620a0c7e5..b3ad9cd10526 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -311,9 +311,9 @@ function AccessOption({ value={access} className={cn( "flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]", - "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", selected - ? "border-primary bg-background ring-2 ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary bg-background ring-2 ring-inset ring-primary/25 dark:border-transparent dark:bg-primary/10 dark:ring-1 dark:ring-primary/30" : "border-border bg-background hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent", )} > diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a1cb0dac252a..779e4bc13422 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -178,7 +178,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { type="button" data-scroll-anchor-ignore aria-expanded={isExpanded} - className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" + className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" style={{ paddingLeft: `${leftPadding}px` }} onClick={() => toggleDirectory(node.path)} > @@ -213,7 +213,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22ce..3e95f86de902 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1817,7 +1817,7 @@ function TimelineMinimap({ /> diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 9bf65102bf70..dd8cd0dab03f 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -203,7 +203,7 @@ export function MediaVideoPlayer({ type="button" aria-label={label ? `Play ${label}` : "Play video"} onClick={onOpen} - className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" > diff --git a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx index 39af63a90616..afba72c805bd 100644 --- a/apps/web/src/components/preview/PreviewRecentUrlCard.tsx +++ b/apps/web/src/components/preview/PreviewRecentUrlCard.tsx @@ -44,7 +44,7 @@ export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Pro type="button" aria-label={`Remove ${label} from history`} onClick={onRemove} - className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100" + className="absolute right-3 rounded p-1 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring group-hover:opacity-100" > diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 19a246cf4be4..6db7e296337b 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -369,7 +369,7 @@ export function ProjectScriptEditorDialog({ type="button" className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs dark:border-transparent ${ isSelected - ? "border-primary/70 bg-primary/10 dark:ring-1 dark:ring-primary/30" + ? "border-primary/70 bg-primary/10 ring-inset dark:ring-1 dark:ring-primary/30" : "border-border/70 hover:bg-accent/60 dark:bg-white/[0.035]" }`} onClick={() => { diff --git a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx index bcfb3b180113..48f71d139675 100644 --- a/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCopyableCode.tsx @@ -32,7 +32,7 @@ export function PullRequestCopyableCode({ diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index cb6f09af1001..cf4e91fcf534 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -46,7 +46,7 @@ export function FoldedSettingsSection({ className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" >

    - + {title} }>
    - + @@ -818,7 +818,7 @@ function KeybindingKeyControl({ onClick={() => setDraft({ isRecording: true })} aria-label={`Edit shortcut for ${commandLabel(row.command)}: ${formatShortcutLabel(row.binding.shortcut)}`} className={cn( - "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/24", + "inline-flex h-8 cursor-pointer items-center rounded-md border border-transparent px-1.5 sm:h-7 outline-none transition-colors hover:border-border/70 hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/24", pillClassName, )} > diff --git a/apps/web/src/components/settings/PanelAnimationsPreview.tsx b/apps/web/src/components/settings/PanelAnimationsPreview.tsx index 34178f9b54c7..9bc2478923b1 100644 --- a/apps/web/src/components/settings/PanelAnimationsPreview.tsx +++ b/apps/web/src/components/settings/PanelAnimationsPreview.tsx @@ -11,7 +11,7 @@ export function PanelAnimationsPreview({ durationMs }: { durationMs: number }) { + } + /> + + {summaries.some(({ report }) => report !== null) && ( + + report + ? [ + `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, + ] + : [], + ) + .join(" ยท ")} + > + {summaries.map( + ({ environment, report }) => + report && ( + 1 ? environment.label : undefined + } + /> + ), + )} + + )} + + ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ) && ( + { + if ( + next === "any-local-files" || + next === "uncommitted-changes" || + next === "tracked-changes" + ) + updateWorktree({ worktreeKeepWhen: next }); + }} + > + + + {ruleStatus("worktreeKeepWhen") + ? "Mixed" + : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + + + + Any local files + Uncommitted changes + Edited tracked files + + + } + /> + )} )} @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { /> )} + {!isProjectScope && } ); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 800ff48360a9..a47a4bf00241 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, { worktreeAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, ]); }); @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..aff52adeb155 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "storage-worktree-keep-when", + targetId: "storage-worktrees", + title: "Keep worktrees with local changes", + to: "/settings/storage", + scope: "project-defaults", + searchTerms: [ + "any local files uncommitted changes edited tracked files ignored env build output cleanup", + ], + }, + { + id: "storage-delete-now", + title: "Delete now", + to: "/settings/storage", + scope: "environment-defaults", + searchTerms: ["storage cleanup run now results report removed kept failed"], + }, { id: "storage-worktrees", title: "Worktree cleanup", diff --git a/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 000000000000..306302db6761 --- /dev/null +++ b/apps/web/src/lib/formatBytes.ts @@ -0,0 +1,11 @@ +export function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e..c92226a9ee4f 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after merging, or when they have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` -prevent removal. Branches and thread history stay; starting another turn recreates the checkout. +sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored +files such as `.env` and build output. **Any local files** also protects ignored files other than +`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and +ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires a merged pull request whose commits are included in the remote default branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default branch and the worktree is still at the pull request's last commit. @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele server waits for sessions and terminals to stop and retries skipped worktrees after restart. Existing prompts for deleting a worktree manually remain available when this policy is off. +Choose **Delete now** to run the enabled rules immediately. The latest results show which +worktrees were removed or kept and why, plus any failures. + Browser captures and rotated logs have separate retention periods. Expired capture links stop working. Current logs, message attachments, and browser profiles are kept. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 35a957e9d0f2..c11a49897b88 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b9d1ebf6b738..b2c6933fabb7 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination settings grant to run storage cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(cleanup.permissionAtom(env))).toBe(false); + expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(cleanup.permissionAtom(env))).toBe(true); + yield* cleanup.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b1..1d49fd71f70c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + runStorageCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-storage-cleanup", + tag: WS_METHODS.serverRunStorageCleanup, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:storage-cleanup-report", + tag: WS_METHODS.serverGetStorageCleanupReport, + idleTtlMs: 0, + }), updateStateAtom, settingsValueAtom, providersValueAtom, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7..c84169e6e33f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..2a61480a907b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), + storageCleanupRun: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ed14e2e16b7c..36afee2f9efa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -317,7 +317,12 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + StorageCleanupReport, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -468,6 +473,8 @@ export const WS_METHODS = { serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverRunStorageCleanup: "server.runStorageCleanup", + serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { + payload: Schema.Struct({}), + success: StorageCleanupReport, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); +const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { + payload: Schema.Struct({}), + success: Schema.NullOr(StorageCleanupReport), + stream: true, + error: EnvironmentAuthorizationError, +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerRunStorageCleanupRpc, + WsServerGetStorageCleanupReportRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 58e8fa5d9658..b5d39323a23d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: null, }); @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { ).toThrow(); }); + it("decodes older custom rules with the default local-file policy", () => { + const settings = decodeServerSettings({ + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: 8, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }); + expect(settings.worktreeCleanup).toMatchObject({ + rules: { worktreeKeepWhen: "uncommitted-changes" }, + }); + expect( + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), + ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), + ).toThrow(); + }); + it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { expect(() => decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e8027b9f5fd..0038ede86c2d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,7 @@ import { ForwardCompatibleOptional, OmittedWhenNull, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +export const WorktreeKeepWhen = Schema.Literals([ + "any-local-files", + "uncommitted-changes", + "tracked-changes", +]); +export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; + export const WorktreeCleanupRules = Schema.Struct({ + worktreeKeepWhen: WorktreeKeepWhen.pipe( + Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), + ), worktreeAfterDays: StorageRetentionDays, worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect( resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69d..af9a7112c3ea 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( if (policy?.mode === "custom") return policy.rules; if (policy?.mode === "off") return { + worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f7305812e74f..dbc2704171f9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: 30, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 99b0f2c336c1..909eeb0a35ce 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( ? { mode: "custom" as const, rules: { + worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, From e858ddb65668d54be5de3996f30c0bd5c3149657 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Sat, 10 Oct 2026 03:09:23 +0100 Subject: [PATCH 221/530] test(usage): usage service tests keep their state directory until cache writes land (#17636) --- apps/server/src/usage/UsageService.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 393437fcc37b..e28e926d3f89 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -132,7 +132,9 @@ const layerService = (input: { Layer.provideMerge(ProviderHostLive.layer), Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), NodePath.join(input.home, input.prefix)), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), @@ -1203,8 +1205,7 @@ describe("UsageService", () => { return text; }); - const restarted = yield* UsageService.make; - const summary = yield* restarted.readSummary(WINDOW); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); // The live rollout re-parses at the ultrafast rate (10 x 6); the // deleted one keeps its saved v4 usage at the standard rate (20 x 1). assert.strictEqual(totalOutputTokens(summary), 30); @@ -1217,9 +1218,6 @@ describe("UsageService", () => { yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, ); - // The migrated cache is written in the background; let it land before - // the layer removes the state directory under it. - yield* restarted.awaitPersisted; }).pipe( Effect.provide( layerService({ From f3a69c70c6a5bc624e1b5eb80901d8b7c901ad6f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 9 Oct 2026 19:09:46 -0700 Subject: [PATCH 222/530] fix(checkpoint): pulls and rebases no longer flood a turn's changed files (#17161) Co-authored-by: Dylan Zahn Co-authored-by: Claude Opus 5.5 --- .../checkpointing/CheckpointDiffQuery.test.ts | 30 ++++ .../src/checkpointing/CheckpointDiffQuery.ts | 68 +++++++-- .../src/checkpointing/CheckpointStore.test.ts | 132 +++++++++++++++++- .../src/checkpointing/CheckpointStore.ts | 29 ++++ apps/server/src/checkpointing/Diffs.test.ts | 10 +- apps/server/src/checkpointing/Diffs.ts | 20 +++ .../CheckpointCaptureService.test.ts | 1 + .../CheckpointScopeOwnership.test.ts | 1 + .../src/orchestration-v2/CheckpointService.ts | 54 ++++--- apps/server/src/vcs/GitVcsDriver.ts | 64 ++++++++- apps/server/src/vcs/VcsDriver.ts | 11 ++ docs/user/source-control.md | 7 + 12 files changed, 390 insertions(+), 37 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index b7cf965f57ca..bdb3e02785d8 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext { function layerFor(input: { readonly projection: Effect.Effect; readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; + readonly authoredPaths?: ReadonlySet; }) { return CheckpointDiffQuery.layer.pipe( Layer.provide( @@ -57,6 +58,7 @@ function layerFor(input: { }), Layer.mock(CheckpointStore.CheckpointStore)({ diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")), + listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null), }), ), ), @@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => { }).pipe(Effect.provide(layer)); }); +it.effect("leaves Git imports out of large turn diffs", () => { + const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`); + const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => + Effect.succeed( + input.format === "numstat" + ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("") + : (input.filePaths ?? ["all"]).join("\n"), + ), + ); + const layer = layerFor({ + projection: Effect.succeed(makeProjection()), + diffCheckpoints, + authoredPaths: new Set(ownPaths), + }); + + return Effect.gen(function* () { + const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; + yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); + + const patchPaths = diffCheckpoints.mock.calls + .map(([call]) => call) + .filter((call) => call.format !== "numstat") + .map((call) => call.filePaths ?? ["all"]); + assert.isAbove(patchPaths.length, 1); + assert.sameMembers(patchPaths.flat(), ownPaths); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the typed missing-thread error contract", () => { const layer = layerFor({ projection: Effect.fail(new OrchestratorProjectionError({ threadId })), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 37414f35bfb6..37ba07ca12c3 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -31,6 +31,31 @@ import { type CheckpointServiceError, } from "./Errors.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } from "./Diffs.ts"; + +// Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. +const MAX_PATHSPEC_CHARS = 24_000; + +/** Splits files into pathspec lists that each fit on one git command line. Renames keep both paths together. */ +function batchFilePaths(files: ReadonlyArray) { + const batches: string[][] = []; + let batch: string[] = []; + let length = 0; + for (const file of files) { + const paths = file.previousPath === undefined ? [file.path] : [file.previousPath, file.path]; + // Each pathspec also carries a `:(top,literal)` prefix and a separator. + const size = paths.reduce((total, path) => total + path.length + 16, 0); + if (batch.length > 0 && length + size > MAX_PATHSPEC_CHARS) { + batches.push(batch); + batch = []; + length = 0; + } + batch.push(...paths); + length += size; + } + if (batch.length > 0) batches.push(batch); + return batches; +} /** Service tag for checkpoint diff queries. */ export class CheckpointDiffQuery extends Context.Service< @@ -182,15 +207,40 @@ export const make = Effect.gen(function* () { }); } - const diff = yield* checkpointStore - .diffCheckpoints({ - cwd: toScope.cwd, - fromCheckpointRef, - toCheckpointRef: toCheckpoint.ref, - fallbackFromToHead: false, - ignoreWhitespace, - }) - .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")); + const comparison = { + cwd: toScope.cwd, + fromCheckpointRef, + toCheckpointRef: toCheckpoint.ref, + fallbackFromToHead: false, + ignoreWhitespace, + }; + // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary. + // Filtering is optional: when it cannot run, the complete diff still loads. + const authoredPaths = yield* checkpointStore + .listAuthoredPaths(comparison) + .pipe(Effect.orElseSucceed(() => null)); + const files = + authoredPaths === null + ? [] + : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe( + Effect.map(parseTurnDiffFilesFromNumstat), + Effect.orElseSucceed(() => []), + ); + const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths)); + // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit. + const diff = + retainedFiles.length === files.length + ? yield* checkpointStore + .diffCheckpoints(comparison) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")) + : (yield* Effect.forEach( + batchFilePaths(retainedFiles), + (filePaths) => + checkpointStore + .diffCheckpoints({ ...comparison, filePaths }) + .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")), + { concurrency: 4 }, + )).join(""); const turnDiff = buildTurnDiffResult(input, diff); if (!isTurnDiffResult(turnDiff)) { diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 2846b86e1b88..b345535fade7 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -57,15 +57,22 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, + options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, ): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const result = yield* process.run({ + const vcsProcess = yield* VcsProcess.VcsProcess; + const result = yield* vcsProcess.run({ operation: "CheckpointStore.test.git", command: "git", cwd, args, timeoutMs: 10_000, + ...(options.committedAt === undefined + ? {} + : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }), + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), }); return result.stdout.trim(); }); @@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); const expectedFiles = [ { path: "binary.bin", additions: 0, deletions: 0 }, - { path: "copied.txt", additions: 0, deletions: 0 }, + { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 }, { path: "copy-source.txt", additions: 1, deletions: 0 }, { path: "deleted.txt", additions: 0, deletions: 1 }, { path: "empty.txt", additions: 0, deletions: 0 }, { path: addedPath, additions: 2, deletions: 0 }, - { path: renamedPath, additions: 1, deletions: 1 }, + { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 }, ].toSorted((left, right) => left.path.localeCompare(right.path)); expect(firstSummary).toEqual(expectedFiles); @@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ]); }), ); + it.effect("limits a diff to repository paths from a subdirectory workspace", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = NodePath.join(tmp, "app"); + yield* fileSystem.makeDirectory(cwd); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-file-paths-subdirectory"); + const refs = { + cwd, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef }); + yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n"); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n"); + yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef }); + + const patch = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + filePaths: ["app/kept.txt"], + }); + expect(patch).toContain("+kept"); + expect(patch).not.toContain("hidden"); + }), + ); + }); + + describe("listAuthoredPaths", () => { + it.effect("leaves out paths that only a merged upstream changed", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string, contents: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), contents); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt", "upstream\n"); + yield* commitBeforeTurn("shared.txt", "upstream\n"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("shared.txt", "ours\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull(); + + // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file. + yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true }); + yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n"); + yield* git(tmp, ["commit", "-am", "merge upstream"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n"); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + const numstat = yield* checkpointStore.diffCheckpoints({ + ...refs, + ignoreWhitespace: false, + format: "numstat", + }); + expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([ + "README.md", + "shared.txt", + "upstream.txt", + ]); + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual( + new Set(["README.md", "shared.txt"]), + ); + }), + ); + + it.effect("leaves out paths that only a rebase onto upstream brought in", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); + const refs = { + cwd: tmp, + fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), + toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), + }; + const commitBeforeTurn = (file: string) => + Effect.gen(function* () { + yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); + yield* git(tmp, ["add", "."]); + yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); + }); + + yield* git(tmp, ["checkout", "-b", "upstream"]); + yield* commitBeforeTurn("upstream.txt"); + yield* git(tmp, ["checkout", "-"]); + yield* commitBeforeTurn("feature.txt"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: refs.fromCheckpointRef, + }); + yield* git(tmp, ["rebase", "upstream"]); + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); + + expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); + }), + ); }); }); diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index 0c9e60d76a65..78844ec70dfe 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -40,6 +40,14 @@ export interface DiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface ListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface DeleteCheckpointRefsInput { @@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service< input: DiffCheckpointsInput, ) => Effect.Effect; + /** + * List paths changed by work done after the "from" checkpoint: uncommitted + * edits at either checkpoint, commits made after "from", and commits that + * left HEAD. Commits a pull, merge, or rebase brought in are older than + * "from", so their paths are not listed. + * + * Returns null when HEAD did not move or a checkpoint does not record HEAD. + * Then every changed path belongs to the turn. + */ + readonly listAuthoredPaths: ( + input: ListAuthoredPathsInput, + ) => Effect.Effect | null, CheckpointStoreError>; + /** * Delete the provided checkpoint refs. * @@ -150,6 +171,13 @@ export const make = Effect.gen(function* () { return yield* checkpoints.diffCheckpoints(input); }); + const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn( + "listAuthoredPaths", + )(function* (input) { + const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd); + return yield* checkpoints.listAuthoredPaths(input); + }); + const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { @@ -166,6 +194,7 @@ export const make = Effect.gen(function* () { hasCheckpointRef, restoreCheckpoint, diffCheckpoints, + listAuthoredPaths, deleteCheckpointRefs, }); }); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index 6b7f1a875690..441643fbda63 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => { ]); }); - it("uses destination paths for renames and copies", () => { + it("preserves both paths for renames and copies", () => { const numstat = [ "0\t0\t", "src/old.ts", @@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => { expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "other.ts", additions: 1, deletions: 0 }, - { path: "src/copied.ts", additions: 2, deletions: 1 }, - { path: "src/new.ts", additions: 0, deletions: 0 }, + { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 }, + { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 }, ]); }); @@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => { const path = " cafรฉ\tline\r\nname.txt "; const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; - expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]); + expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ + { path, previousPath: "old\tname\n.txt", additions: 3, deletions: 2 }, + ]); expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ { path, additions: 1, deletions: 0 }, ]); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index b7951009118e..24aa3b6879e9 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,5 +1,6 @@ export interface TurnDiffFileSummary { readonly path: string; + readonly previousPath?: string; readonly additions: number; readonly deletions: number; } @@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray left.path.localeCompare(right.path)); } + +/** + * True when Git brought this file in and no work in the range touched it. + * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported. + * A rename stays visible when either side was authored, because the summary shows it as one file. + */ +export function isGitImport( + file: TurnDiffFileSummary, + authoredPaths: ReadonlySet | null, +): boolean { + return ( + authoredPaths !== null && + !authoredPaths.has(file.path) && + (file.previousPath === undefined || !authoredPaths.has(file.previousPath)) + ); +} diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index bb7619f6a8ab..9b24161b54e3 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { IdAllocator.layer, NodeCrypto.layer, Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), isGitRepository: () => Effect.succeed(true), captureCheckpoint: () => Effect.void, hasCheckpointRef: () => diff --git a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts index b29eb33b69a2..33a7490a0245 100644 --- a/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts @@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o getCheckpointContext: () => Effect.succeed(context), }), Layer.mock(CheckpointStore.CheckpointStore)({ + listAuthoredPaths: () => Effect.succeed(null), diffCheckpoints: (input) => { assert.equal(input.cwd, "/prepared-repo"); assert.equal(input.fromCheckpointRef, checkpointRefs[0]); diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 913e53658e2c..ee3316311eac 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; +import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -440,33 +440,49 @@ export const layer: Layer.Layer< }).pipe(Effect.as(false)), ), ); + const refs = { + cwd: input.scope.cwd, + fromCheckpointRef: previousCheckpointRef, + toCheckpointRef: checkpointRef, + }; + // A pull or rebase can change thousands of files the turn did not write. + // Keep only the files the turn's own work touched. const files = previousExists - ? yield* checkpointStore - .diffCheckpoints({ - cwd: input.scope.cwd, - fromCheckpointRef: previousCheckpointRef, - toCheckpointRef: checkpointRef, + ? yield* Effect.all([ + checkpointStore.diffCheckpoints({ + ...refs, fallbackFromToHead: false, ignoreWhitespace: false, format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified", - additions: file.additions, - deletions: file.deletions, - })), - ), + }), + checkpointStore.listAuthoredPaths(refs).pipe( Effect.catch((cause) => - Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + Effect.logWarning("orchestration V2 checkpoint authored paths failed", { scopeId: input.scope.id, checkpointRef, cause: String(cause), - }).pipe(Effect.as([])), + }).pipe(Effect.as(null)), ), - ) + ), + ]).pipe( + Effect.map(([diff, authoredPaths]) => + parseTurnDiffFilesFromNumstat(diff) + .filter((file) => !isGitImport(file, authoredPaths)) + .map((file) => ({ + path: file.path, + kind: "modified", + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.catch((cause) => + Effect.logWarning("orchestration V2 checkpoint diff summary failed", { + scopeId: input.scope.id, + checkpointRef, + cause: String(cause), + }).pipe(Effect.as([])), + ), + ) : []; return makeCheckpoint({ diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6513d037c8dc..599269791edf 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } const message = `t3 checkpoint ref=${input.checkpointRef}`; + // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. const commitTreeResult = yield* execute({ operation, cwd: input.cwd, - args: [...durableWrite, "commit-tree", treeOid, "-m", message], + args: [ + ...durableWrite, + "commit-tree", + treeOid, + ...(headExists ? ["-p", "HEAD"] : []), + "-m", + message, + ], env: commitEnv, }); const commitOid = commitTreeResult.stdout.trim(); @@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; + if (input.filePaths?.length === 0) return ""; yield* Effect.annotateCurrentSpan({ "checkpoint.cwd": input.cwd, "checkpoint.from_ref": input.fromCheckpointRef, @@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, + // Paths are repository-relative, while cwd can be a subdirectory. + ...(input.filePaths + ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)] + : []), ], allowNonZeroExit: true, maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, @@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return result.stdout; }), + listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) { + const operation = "GitVcsDriver.checkpoints.listAuthoredPaths"; + const heads = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "log", + "--no-walk=unsorted", + "--format=%P %ct", + `${input.fromCheckpointRef}^{commit}`, + `${input.toCheckpointRef}^{commit}`, + ], + }); + // Each line is " ". The parent is the HEAD at capture. + const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n"); + const [startHead = "", capturedAt = ""] = fromLine.split(" "); + const [endHead = ""] = toLine.split(" "); + if (startHead === "" || endHead === "" || startHead === endHead) { + return null; + } + + const listPaths = (args: ReadonlyArray) => + execute({ + operation, + cwd: input.cwd, + args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"], + maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => result.stdout.split("\0"))); + // Remerge diff lists a merge's paths only where the result differs from Git's + // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + const pathLists = yield* Effect.all( + [ + listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), + listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]), + listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]), + listPaths([ + "log", + "--format=", + "--diff-merges=remerge", + `--since=@${capturedAt}`, + `${startHead}..${endHead}`, + ]), + ], + { concurrency: "unbounded" }, + ); + return new Set(pathLists.flat().filter((path) => path.length > 0)); + }), + deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( function* (input) { yield* Effect.forEach( diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index a8680c9a54be..c124aa90b963 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput { readonly fallbackFromToHead?: boolean; readonly ignoreWhitespace: boolean; readonly format?: "patch" | "numstat"; + /** Limits the diff to these exact paths. An empty list yields an empty diff. */ + readonly filePaths?: ReadonlyArray; +} + +export interface VcsListAuthoredPathsInput { + readonly cwd: string; + readonly fromCheckpointRef: CheckpointRef; + readonly toCheckpointRef: CheckpointRef; } export interface VcsDeleteCheckpointRefsInput { @@ -48,6 +56,9 @@ export interface VcsCheckpointOps { input: VcsRestoreCheckpointInput, ) => Effect.Effect; readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect; + readonly listAuthoredPaths: ( + input: VcsListAuthoredPathsInput, + ) => Effect.Effect | null, VcsError>; readonly deleteCheckpointRefs: ( input: VcsDeleteCheckpointRefsInput, ) => Effect.Effect; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2e6785db07b9..3730eb6a18d4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -3,6 +3,13 @@ T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. +## Review turn changes + +A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or +rebases, the files Git brought in are left out. A file stays in the list when the turn edited it, +committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed +against your base branch. Restore still returns the complete saved workspace. + ## Connect an account Install Git and configure authentication on the machine running your T3 Code server. For a remote From 07b80800124982bce94879a97767a5365987e8f1 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 10 Oct 2026 07:45:23 +0530 Subject: [PATCH 223/530] fix(web): place notification icons after titles (#12209) --- apps/web/src/components/ui/toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 27ff2a2733a9..9448bfff99ea 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -340,6 +340,7 @@ function ToastBodyContent({ )} >
    + {leadingIcon ? (
    ) : null} -
    Date: Fri, 9 Oct 2026 22:17:46 -0400 Subject: [PATCH 224/530] fix(web): attachments on an open question are visible again (#15537) --- apps/web/src/components/chat/ChatComposer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9ef3b1d81cf4..0d55c1c83e06 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7094,7 +7094,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && - pendingUserInputs.length === 0 && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || expandedComposerImages.length > 0) && ( From f34adad34f21510e867b47fe018c7975779a0ffc Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:18:58 +0500 Subject: [PATCH 225/530] fix(web): scale Files tree with interface font size (#8011) --- apps/web/src/pierre-tree-theme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 906b34ba1c51..3f191d255789 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -8,7 +8,7 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; + --trees-font-size-override: 0.75rem; } button[data-type='item'] { border-radius: 5px; } svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } From 318201caa520525517ef2bee8f4bbeedb2fec1d7 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:29 -0300 Subject: [PATCH 226/530] fix(server): probe only owned preview listeners (#16687) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/preview/PortScanner.test.ts | 163 +++++++++----------- apps/server/src/preview/PortScanner.ts | 79 ++++------ 2 files changed, 110 insertions(+), 132 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 8a6bf87d4699..8aae21d8cf71 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -7,7 +7,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -44,17 +43,7 @@ const layerTestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: processProbeFailure, }); -let integrationListeningPort: number | null = null; - -const layerTestIntegrationNet = Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), - hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), -}); - -/** A host without `/proc`, so a missing `lsof` falls back to common ports. */ +/** A host without `/proc`, so a missing `lsof` leaves only configured URLs. */ const layerNoProc = FileSystem.layerNoop({}); const layerProbeFailure = ( @@ -67,13 +56,6 @@ const layerProbeFailure = ( Layer.mergeAll( fileSystem, Layer.succeed(ProcessRunner.ProcessRunner, { run }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))), ), @@ -85,7 +67,6 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( Layer.mergeAll( layerNoProc, layerTestProcessRunner, - layerTestIntegrationNet, Layer.succeed(HostProcess.Platform, "win32"), FetchHttpClient.layer, ), @@ -94,9 +75,20 @@ const layerTestPortDiscovery = PortScanner.layer.pipe( const LSOF_TEST_PORT = 43_123; +/** The scanner with `processIds` registered to a T3 terminal, so their listeners get probes. */ +const ownedScanner = (processIds: ReadonlyArray = [1234]) => + Effect.tap(PortScanner.PortDiscovery, (scanner) => + scanner.registerTerminalProcesses({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + processIds, + }), + ); + const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; + readonly stdout?: () => string; }) => PortScanner.layer.pipe( Layer.provide( @@ -105,7 +97,7 @@ const layerLsofScanner = (input: { Layer.succeed(ProcessRunner.ProcessRunner, { run: () => Effect.succeed({ - stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, + stdout: input.stdout?.() ?? `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`, stderr: "", code: null, timedOut: false, @@ -115,13 +107,6 @@ const layerLsofScanner = (input: { stderrInvalidUtf8: false, }), }), - Layer.succeed(Net.NetService, { - canListenOnHost: () => Effect.succeed(true), - isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: () => Effect.succeed(false), - reserveLoopbackPort: () => Effect.succeed(40_000), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), Layer.succeed(HostProcess.Platform, "linux"), FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)), @@ -153,73 +138,45 @@ const closeServer = (server: NodeNet.Server): Effect.Effect => }); const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( - ports: ReadonlyArray, onConnection: (socket: NodeNet.Socket) => void, ) { - for (const port of ports) { - const server = yield* openServer(port, onConnection); - if (server !== null) return { port, server }; + const server = yield* openServer(0, onConnection); + const address = server?.address(); + if (!server || !address || typeof address === "string") { + return yield* Effect.die(new Error("Could not open the preview scanner test listener")); } - return yield* Effect.die( - new Error("No common development port was available for the preview scanner test"), - ); + return { port: address.port, server }; }); const commonDevServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => { + openCommonDevServer((socket) => { socket.once("data", () => { socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello"); }); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); const commonNonHttpServer = Effect.acquireRelease( - openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => { + openCommonDevServer((socket) => { socket.on("error", () => undefined); socket.once("data", () => socket.end("MYSQL\r\n\r\n")); - }).pipe( - Effect.tap(({ port }) => - Effect.sync(() => { - integrationListeningPort = port; - }), - ), - ), - ({ server }) => - closeServer(server).pipe( - Effect.ensuring( - Effect.sync(() => { - integrationListeningPort = null; - }), - ), - ), + }), + ({ server }) => closeServer(server), ); /** * Integration tests against a real TCP listener. We provide the Windows host - * platform so the tests exercise the TCP-probe fallback without depending on - * `lsof` being installed. + * platform with a failing listener probe so the tests exercise configured URLs + * without depending on `lsof` being installed. */ -effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fallback)", (it) => { +effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (configured URLs)", (it) => { it.effect( - "scan() returns an HTTP server we just opened on a curated dev port", + "scan() returns a configured HTTP server we just opened", Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () { const { port } = yield* commonDevServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); @@ -231,7 +188,7 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () { const { port } = yield* commonNonHttpServer; const scanner = yield* PortScanner.PortDiscovery; - const result = yield* scanner.scan(); + const result = yield* scanner.scan([`http://localhost:${port}`]); expect(result.some((server) => server.port === port)).toBe(false); }), ); @@ -242,10 +199,12 @@ effectIt.layer(layerTestPortDiscovery)("PortDiscovery integration (TCP probe fal const { port } = yield* commonDevServer; const received: number[] = []; const scanner = yield* PortScanner.PortDiscovery; - yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) => - Effect.sync(() => { - for (const server of servers) received.push(server.port); - }), + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${port}`], initialSnapshot: [] }, + (servers) => + Effect.sync(() => { + for (const server of servers) received.push(server.port); + }), ); yield* scanner.retain; expect(received).toContain(port); @@ -265,7 +224,7 @@ effectIt.effect("revalidates a successful HTML probe after its cache entry expir const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); @@ -499,7 +458,7 @@ effectIt.effect("shares a configured root probe with discovered-root classificat const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan([rootUrl])).toHaveLength(1); expect(requests).toEqual([rootUrl]); @@ -528,7 +487,7 @@ effectIt.effect("starts fresh cache entries after the probing batch completes", const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); yield* Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(1); expect(yield* scanner.scan()).toHaveLength(1); expect(requests).toHaveLength(1); @@ -548,7 +507,7 @@ effectIt.effect("caches a failed web probe until its bounded cache entry expires const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); expect(yield* scanner.scan()).toHaveLength(0); expect(yield* scanner.scan()).toHaveLength(0); expect(requests).toHaveLength(2); @@ -573,7 +532,7 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const servers = yield* scanner.scan(); expect(servers).toHaveLength(1); expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); @@ -581,6 +540,37 @@ effectIt.effect("falls back to HTTPS and does not follow redirects while probing }).pipe(Effect.provide(layer)); }); +effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; + const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; + let stdout = unowned; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn, stdout: () => stdout }); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan()).toHaveLength(0); + stdout = `${owned}${unowned}`; + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toEqual([]); + + stdout = owned; + expect(yield* scanner.scan()).toHaveLength(1); + expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + + yield* scanner.unregisterTerminal({ + threadId: "scanner-thread", + terminalId: "scanner-terminal", + }); + expect(yield* scanner.scan()).toHaveLength(0); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); +}); + effectIt.effect( "excludes HTTP errors, non-navigation responses, and successful non-documents", () => { @@ -592,7 +582,7 @@ effectIt.effect( const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); expect(yield* scanner.scan()).toHaveLength(0); pid += 1; @@ -650,7 +640,7 @@ effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => { const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const scanFiber = yield* Effect.forkChild(scanner.scan()); yield* TestClock.adjust(Duration.seconds(2)); expect(yield* Fiber.join(scanFiber)).toHaveLength(0); @@ -739,11 +729,10 @@ effectIt.effect( ); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); + // Ports 22 and 3001 have no known owner, so they never receive probes. expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 22, pid: null, processName: null }, - { port: 3001, pid: null, processName: null }, { port: 8765, pid: 4242, processName: "python3" }, ]); yield* scanner.scan(); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 9b1d9e966333..7a59079ea9f4 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -8,8 +8,8 @@ * Linux without lsof: reads listening sockets from `/proc/net/tcp{,6}` and * finds their processes through `/proc//fd`. * - * Windows listener failures, or neither source: checks a curated list of - * common dev ports through the shared Net service. + * Only listeners owned by T3 terminal processes and explicitly configured + * URLs receive probes. Without listener ownership, only configured URLs do. * * Listening ports are published only after a bounded HTTP(S) probe finds a * successful HTML document or a redirect to one. @@ -26,7 +26,6 @@ import { type DiscoveredLocalServer, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Net from "@t3tools/shared/Net"; import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; @@ -71,10 +70,6 @@ export class PortDiscovery extends Context.Service< } >()("t3/preview/PortScanner/PortDiscovery") {} -export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ - 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, -]); - const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; /** File descriptors one `/proc` walk may read before it stops looking for socket owners. */ @@ -218,14 +213,20 @@ const parseLsofOutput = ( if (portMatch == null) continue; const url = `http://localhost:${portMatch}`; const key = `localhost:${portMatch}`; - if (seen.has(key)) continue; + const terminal = pid === null ? null : (terminalByProcessId.get(pid) ?? null); + const existing = seen.get(key); + // A port receives probes only when T3 terminals own all of its listeners. + if (existing) { + if (terminal === null) seen.set(key, { ...existing, terminal: null }); + continue; + } seen.set(key, { host: "localhost", port: portMatch, url, processName, pid, - terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), + terminal, }); } } @@ -261,14 +262,20 @@ const parseWindowsListenerOutput = ( const pid = Number(pidRaw); if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; - if (seen.has(port)) continue; + const terminal = + normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null); + const existing = seen.get(port); + if (existing) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: processNameRaw?.trim() || null, pid: normalizedPid, - terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); @@ -339,7 +346,6 @@ const isCommandNotFound = (error: ProcessRunner.ProcessSpawnError): boolean => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { - const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; @@ -398,7 +404,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { /** * Linux listeners from `/proc/net/tcp{,6}`, for hosts without `lsof`. Null - * when neither file is readable, so discovery falls back to common ports. + * when neither file is readable, so only configured URLs are probed. */ const scanProcListeners = Effect.fn("PortDiscovery.scanProcListeners")(function* ( terminalByProcessId: ReadonlyMap, @@ -433,44 +439,25 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const seen = new Map(); for (const { port, inode } of listeners) { const owner = owners.get(inode) ?? null; + const terminal = owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null); const existing = seen.get(port); - if (existing && (existing.pid !== null || owner === null)) continue; + if (existing && (existing.pid !== null || owner === null)) { + if (terminal === null) seen.set(port, { ...existing, terminal: null }); + continue; + } seen.set(port, { host: "localhost", port, url: `http://localhost:${port}`, processName: owner?.processName ?? null, pid: owner?.pid ?? null, - terminal: owner === null ? null : (terminalByProcessId.get(owner.pid) ?? null), + // A replaced listener had no known owner, so the port stays unowned. + terminal: existing ? null : terminal, }); } return [...seen.values()].toSorted((left, right) => left.port - right.port); }); - const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () { - const results = yield* Effect.forEach( - COMMON_DEV_PORTS, - (port) => - net.isPortAvailableOnLoopback(port).pipe( - Effect.map((available) => ({ - port, - listening: !available, - })), - ), - { concurrency: "unbounded" }, - ); - return results - .filter((result) => result.listening) - .map((result) => ({ - host: "localhost", - port: result.port, - url: `http://localhost:${result.port}`, - processName: null, - pid: null, - terminal: null, - })); - }); - const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) => httpClient.get(url).pipe( Effect.map((response) => { @@ -524,6 +511,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() { } for (const server of servers) { + // A listener alone does not identify its protocol. Sending HTTP or TLS to + // another app's binary RPC listener can crash it before classification. + if (server.terminal === null) continue; groups.push({ server, urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], @@ -616,7 +606,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const recoverProcessProbeFailure = (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) => - Effect.logDebug("preview port process probe failed; falling back to common-port probes", { + Effect.logDebug("preview port process probe failed; probing configured URLs only", { cause: error, probe, platform: hostPlatform, @@ -642,7 +632,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), @@ -655,20 +644,20 @@ export const make = Effect.gen(function* PortDiscoveryMake() { }), ); if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls); - return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls); + return yield* probeWebServers([], configuredUrls); } const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof"); if (yield* Ref.get(lsofMissingRef)) { const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); } const lsofResult = yield* processRunner .run({ command: "lsof", args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], timeout: Duration.millis(LSOF_TIMEOUT_MS), + // Partial output could hide another app's listener on an owned port. maxOutputBytes: 1024 * 1024, - outputMode: "truncate", }) .pipe( Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), @@ -686,7 +675,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ); if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls); const fromProc = yield* scanProcListeners(terminalByProcessId); - return yield* probeWebServers(fromProc ?? (yield* probeCommonPorts()), configuredUrls); + return yield* probeWebServers(fromProc ?? [], configuredUrls); }); const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")( From ed4ea1083d477b67304f50d5691ba5fc2b9496cd Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:31:57 -0300 Subject: [PATCH 227/530] fix(chat): surface pending subagent questions on parents (#16634) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadDetailScreen.tsx | 24 +++++++++- .../threads/floating-working-control.tsx | 24 ++++++++-- .../threads/floating-working-status.ts | 6 +++ apps/mobile/src/state/entities.ts | 4 ++ apps/web/src/components/ChatView.tsx | 47 +++++++++++++++++-- apps/web/src/state/entities.ts | 6 +++ .../client-runtime/src/state/threadShell.ts | 46 ++++++++++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9229c117d8da..a3d7a6f4e0c6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useThreadReportedModelSelection } from "../../state/entities"; +import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; import { useNavigation } from "@react-navigation/native"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -400,6 +400,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const controlInsets = deriveBottomControlInsets(nativeMetrics); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const childThreadInputs = useChildThreadInputs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); const composerError = useAtomValue(threadComposerErrorsAtom)[selectedThreadKey]?.message ?? null; const queuedCount = useThreadQueuedCount({ environmentId: props.environmentId, @@ -507,6 +511,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (threadSyncLabel !== null) { return { kind: "syncing", label: threadSyncLabel }; } + const childInput = childThreadInputs[0]; + if (childInput && contentPresentationKind === "ready") { + return { + kind: "child-input", + label: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + accessibilityLabel: `Open question from ${childInput.title}`, + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("Thread", { + environmentId: props.environmentId, + threadId: childInput.id, + }); + }, + }; + } if (props.isCompacting && contentPresentationKind === "ready") { return { kind: "compacting" }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index f21a83f09a17..1fb87ce1c725 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -160,15 +160,16 @@ export function FloatingWorkingControl(props: { hasAgents || hasQueue || (props.devicePreview !== null && props.browserPreview !== null); - // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + // The queue, agents, questions, and reconnect labels have separate tap targets. + const statusInteractive = + props.status?.kind === "connection" || props.status?.kind === "child-input"; const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -372,6 +373,23 @@ function FloatingStatusLabel(props: { if (props.status.kind === "compacting") { return ; } + if (props.status.kind === "child-input") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "connection") { return ( void; + } // The turn settled while background work it started still runs. `waiting` // is false when only commands remain, such as a dev server: the agent is done. | { diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index 0d3b33d582d5..b8e42b0cb99e 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -98,6 +98,10 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); +} + /** `markdown` with each thread link labeled by the thread's current title in `environmentId`. */ export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a80fc56c57e6..06a6bacaf48c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -300,6 +300,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, + MessageCircleIcon, PaperclipIcon, ChevronDownIcon, DownloadIcon, @@ -427,6 +428,7 @@ import { useThreadStatus, useThreadHistory, useThreadShell, + useChildThreadInputs, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, @@ -7414,6 +7416,42 @@ export default function ChatView(props: ChatViewProps) { }, [environmentId, navigate], ); + const childThreadInputs = useChildThreadInputs(activeThreadRef); + const childInputBannerItem = useMemo(() => { + const first = childThreadInputs[0]; + const parentAwaitingUser = + activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput; + if (!first || parentAwaitingUser) return null; + return { + id: `child-input:${first.id}`, + variant: "info", + priority: "activity", + icon: , + title: + childThreadInputs.length === 1 + ? "Subagent needs input" + : `${childThreadInputs.length} subagents need input`, + description: childThreadInputs.map((child, index) => ( + + {index > 0 ? ", " : null} + onOpenRelatedThread(child.id)}> + {child.title} + + + )), + actions: ( + + ), + }; + }, [ + childThreadInputs, + activePendingApproval, + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThread, + ]); // Commands such as /compact and /goal clear run as their own turn. The draft // and its attachments stay local. @@ -7774,9 +7812,11 @@ export default function ChatView(props: ChatViewProps) { : null; const composerBannerItems = useMemo(() => { const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; - const backgroundWorkItems = [goalBannerItem, backgroundWorkBannerItem].filter( - (item) => item !== null, - ); + const backgroundWorkItems = [ + childInputBannerItem, + goalBannerItem, + backgroundWorkBannerItem, + ].filter((item) => item !== null); // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7847,6 +7887,7 @@ export default function ChatView(props: ChatViewProps) { handleRestoreThreadBranch, isRestoringThreadBranch, backgroundWorkBannerItem, + childInputBannerItem, goalBannerItem, localCheckoutBranchMismatch, projectCloneBannerItem, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index aa9b650fb5b5..ebf62f01be6f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -117,6 +117,12 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +export function useChildThreadInputs(ref: ScopedThreadRef | null) { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref), + ); +} + export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref), diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index bc6d444f471a..3cb54087ac14 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import type { EnvironmentThreadShell } from "./models.ts"; @@ -138,6 +139,50 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); }); + // Child questions are hidden with their sidebar rows. Read their shell summaries, + // without subscribing to every child transcript. + const childThreadInputsAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const children = new Map(); + for (const thread of get(environmentThreadsAtom(ref.environmentId))) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + const siblings = children.get(parent); + if (siblings) siblings.push(thread); + else children.set(parent, [thread]); + } + const seen = new Set([ref.threadId]); + const pending = [ref.threadId]; + const next: EnvironmentThreadShell[] = []; + for (const parent of pending) { + for (const child of children.get(parent) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + pending.push(child.id); + if ( + child.pendingRuntimeRequest?.kind === "user_input" && + !isProviderNativeSubagentThread(child) + ) { + next.push(scopedThread(ref.environmentId, child)); + } + } + } + // Indicators read only ids and titles, so other shell updates keep the old array. + if ( + previous.length === next.length && + previous.every( + (thread, index) => thread.id === next[index]?.id && thread.title === next[index]?.title, + ) + ) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-child-thread-inputs:${key}`)); + }); + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { const projectRefs = parseProjectRefCollectionKey(key); let previous: ReadonlyArray = []; @@ -225,5 +270,6 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + childThreadInputsAtom: (ref: ScopedThreadRef) => childThreadInputsAtomFamily(threadKey(ref)), }; } From c1f21e53b3b138cc591f9df90bd5c8aef6a00ce4 Mon Sep 17 00:00:00 2001 From: Bishal Banerjee <56464152+ZenderGoD@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:09:30 +0530 Subject: [PATCH 228/530] fix(web): section header chevrons point up when collapsed (#14273) Co-authored-by: zendergod Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ui/collapsible-section-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/collapsible-section-header.tsx b/apps/web/src/components/ui/collapsible-section-header.tsx index 5cc52ffe2950..6a685fe03acd 100644 --- a/apps/web/src/components/ui/collapsible-section-header.tsx +++ b/apps/web/src/components/ui/collapsible-section-header.tsx @@ -36,7 +36,7 @@ export function CollapsibleSectionHeader({ {accessory} ); From e08c31e1a1fd5e89e2d4130b9ad18b44d15f92cd Mon Sep 17 00:00:00 2001 From: jonesfionn101-dotcom Date: Sat, 10 Oct 2026 03:39:40 +0100 Subject: [PATCH 229/530] fix(web): timeline divider pill shows a pointer, visible hover and focus ring (#15188) Co-authored-by: Fionn Jones <264670603+jonesfionn101-dotcom@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/TimelineSystemDivider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx index 1c4be795007c..e680cab50c4c 100644 --- a/apps/web/src/components/chat/TimelineSystemDivider.tsx +++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx @@ -48,7 +48,7 @@ export function TimelineSystemDivider(props: { type="button" aria-label={props.actionLabel} onClick={props.onAction} - className="flex min-w-0 items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-muted" + className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70" /> } > From 6e4ca6ce9d04009af290412b77a0acb9be9f8a18 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:48:05 -0300 Subject: [PATCH 230/530] fix(git): allow creating prs from dirty worktrees (#15625) --- apps/server/src/git/GitManager.test.ts | 32 +++++++++++++++++-- apps/server/src/git/GitManager.ts | 8 ----- .../GitActionsControl.logic.test.ts | 12 +++++-- .../src/components/GitActionsControl.logic.ts | 1 - apps/web/src/components/GitActionsControl.tsx | 5 +-- .../client-runtime/src/state/gitActions.ts | 8 ++--- 6 files changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 45f4ae96a133..c9a08607bc08 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3743,7 +3743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + it.effect("create_pr pushes committed changes while preserving a dirty worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -3753,6 +3753,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); yield* runGit(repoDir, ["add", "create-pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; const { manager, ghCalls } = yield* makeManager({ ghScenario: { @@ -3781,6 +3785,10 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.push.setUpstream).toBe(true); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(303); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect( + (yield* runGit(remoteDir, ["rev-parse", "feature/create-pr-only"])).stdout.trim(), + ).toBe(headBefore); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature/create-pr-only"), @@ -6199,7 +6207,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + it.effect("create_pr preserves dirty work on an already pushed branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -6210,8 +6218,22 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["add", "pr-only.txt"]); yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout; + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "staged readme\n"); + yield* runGit(repoDir, ["add", "README.md"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "pr-only.txt"), "unstaged feature work\n"); + NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); + const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; + const stagedBefore = (yield* runGit(repoDir, ["diff", "--cached"])).stdout; + let generatedContent: TextGeneration.PrContentGenerationInput | undefined; const { manager } = yield* makeManager({ + textGeneration: { + generatePrContent: (input) => { + generatedContent = input; + return Effect.succeed({ title: "PR only branch", body: "Committed feature work" }); + }, + }, ghScenario: { prListSequence: [ JSON.stringify([]), @@ -6251,6 +6273,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(result.commit.status).toBe("skipped_not_requested"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); + expect(generatedContent?.diffPatch).toContain("+pr only"); + expect(generatedContent?.diffPatch).not.toContain("staged readme"); + expect(generatedContent?.diffPatch).not.toContain("unstaged feature work"); + expect((yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout).toBe(headBefore); + expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); + expect((yield* runGit(repoDir, ["diff", "--cached"])).stdout).toBe(stagedBefore); expect( events.filter( (event): event is Extract => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a9..bd4ee0da7e9d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2730,14 +2730,6 @@ export const make = Effect.gen(function* () { detail: "Feature-branch checkout is only supported for commit actions.", }); } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* new GitManagerError({ - operation: "runStackedAction", - cwd: input.cwd, - detail: "Commit local changes before creating a PR.", - }); - } - const phases: GitActionProgressPhase[] = [ ...(input.featureBranch ? (["branch"] as const) : []), ...(wantsCommit ? (["commit"] as const) : []), diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 6d33d412ec8c..b29a44be3702 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -365,7 +365,7 @@ describe("when: source control provider uses merge requests", () => { }); describe("when: ref is clean, up to date, and has no open PR", () => { - it("enables create PR when synced with upstream but ahead of default", () => { + it("enables create PR when synced with upstream but ahead of default, even with local changes", () => { const syncedFeature = status({ aheadCount: 0, behindCount: 0, @@ -383,6 +383,12 @@ describe("when: ref is clean, up to date, and has no open PR", () => { const items = buildMenuItems(syncedFeature, false); assert.equal(items.find((item) => item.id === "pr")?.disabled, false); + + const dirtyFeature = { ...syncedFeature, hasWorkingTreeChanges: true }; + assert.equal( + buildMenuItems(dirtyFeature, false).find((item) => item.id === "pr")?.disabled, + false, + ); }); it("resolveQuickAction returns disabled no-action state", () => { @@ -550,7 +556,7 @@ describe("when: working tree has local changes", () => { ]); }); - it("buildMenuItems enables push for ahead commits while local changes remain uncommitted", () => { + it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => { const items = buildMenuItems( status({ refName: "feature/test", @@ -584,7 +590,7 @@ describe("when: working tree has local changes", () => { { id: "pr", label: "Create PR", - disabled: true, + disabled: false, icon: "pr", kind: "open_dialog", dialogAction: "create_pr", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8d3b7bc30578..5422628825a6 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -184,7 +184,6 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && hasDefaultBranchDelta && !isBehind && diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 5615c64ee450..cfe45ff24cc4 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -338,13 +338,10 @@ function getMenuActionDisabledReason({ if (!hasBranch) { return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return `Add an "origin" remote before creating a ${terminology.singular}.`; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index d3571e65d2e6..f708e9ca9b98 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -65,9 +65,8 @@ export function buildMenuItems( const canCreatePr = !isBusy && hasBranch && - !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0 && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -292,13 +291,10 @@ export function getGitActionDisabledReason(input: { if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; } - if (hasChanges) { - return "Commit local changes before creating a PR."; - } if (!gitStatus.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } - if (!isAhead) { + if ((gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) <= 0) { return "No local commits to include in a PR."; } if (isBehind) { From b822420e5074d750e7258ea48568666720cf0415 Mon Sep 17 00:00:00 2001 From: Ege <68082325+ege-arhan@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:57:13 +0300 Subject: [PATCH 231/530] docs(install): polish binary install destination phrasing (#15732) --- docs/user/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/install.md b/docs/user/install.md index 948e5d367607..67df94217ac3 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -20,7 +20,7 @@ On Windows, in PowerShell: irm https://t3.codes/install.ps1 | iex ``` -This puts `t3` in `~/.local/bin`. If your shell reports `command not found` +This installs the `t3` binary to `~/.local/bin`. If your shell reports `command not found` afterwards, that directory is not on your `PATH` yet; the installer prints the line to add. Set `T3CODE_CHANNEL=nightly` to install the nightly train, or `T3CODE_VERSION` to pin an exact version. From 0981e6b9d421ec46010d9b9edebd0beb241ef472 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:57:34 +0200 Subject: [PATCH 232/530] perf(server): keep passive terminal output flowing (#17178) --- apps/server/src/terminal/OutputProtocol.test.ts | 2 ++ apps/server/src/terminal/OutputProtocol.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index 548a09c08686..a12f9d6ecbf8 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -13,8 +13,10 @@ import { withTerminalOutputWindow } from "./OutputProtocol.ts"; describe("terminal output window", () => { it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, + { tag: WS_METHODS.terminalObserve, size: 1, limit: 8 }, { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, + { tag: WS_METHODS.terminalObserve, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, ])("limits $tag with $size-byte values to $limit pending chunks", ({ tag, size, limit }) => Effect.gen(function* () { diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 441961a7952f..402ed32c022a 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -22,6 +22,7 @@ export function withTerminalOutputWindow( if ( message._tag === "Request" && (message.tag === WS_METHODS.terminalAttach || + message.tag === WS_METHODS.terminalObserve || message.tag === WS_METHODS.subscribeTerminalEvents) ) { const key = `${clientId}:${message.id}`; From b6aa268b12e8eb6d5aab52e5e09c12e255fca905 Mon Sep 17 00:00:00 2001 From: Anneshu Nag <132702983+NK-Works@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:28:01 +0530 Subject: [PATCH 233/530] fix(web): stop button icon no longer shifts on hover (#16012) --- apps/web/src/components/chat/ComposerPrimaryActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ac8a70e66553..85d8504ce256 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -138,7 +138,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ } /> - {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} + {pendingAction === "enable-auto-merge" + ? "Enabling..." + : inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : pendingAutoMergeLabel} ) : primaryAction === "auto-merge-armed" ? ( @@ -1970,21 +1962,44 @@ export function PullRequestDetailPanel({ size="xs" variant="default" disabled={actionPending} - onClick={() => setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} aria-label={ - pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel + pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel } > - - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + + + {selectedMergeMethodLabel} + + + Confirm merge? + + + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} + } /> - {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} ) : (primaryAction === "merged" || primaryAction === "closed") && @@ -2101,11 +2116,18 @@ export function PullRequestDetailPanel({ ) : null} {showsMergeNow ? ( setConfirmation({ open: true, action: "merge" })} + {...inlineConfirm.props( + "merge", + () => void perform("merge", selectedMergeMethod), + )} > - Merge now + {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} ) : null} {/* The same merge, left with the host to carry out once its requirements @@ -2121,13 +2143,22 @@ export function PullRequestDetailPanel({ ) : showsAutoMerge ? ( - setConfirmation({ open: true, action: "enable-auto-merge" }) + closeOnClick={inlineConfirm.isArmed("enable-auto-merge")} + aria-label={ + inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge" } + disabled={actionPending} + {...inlineConfirm.props( + "enable-auto-merge", + () => void perform("enable-auto-merge", selectedMergeMethod), + )} > - Enable auto-merge + {inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge"} ) : null} {/* A preference for the merge action rather than a second action, so it @@ -2200,12 +2231,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "close" })} + {...inlineConfirm.props("close", () => void perform("close"))} > - Close pull request + {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} ) : detail.state === "closed" && can("reopen") ? ( @@ -2220,11 +2255,16 @@ export function PullRequestDetailPanel({ <> setConfirmation({ open: true, action: "revert" })} + {...inlineConfirm.props("revert", () => void perform("revert"))} > - Revert changes + {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} ) : null} @@ -2553,20 +2593,33 @@ export function PullRequestDetailPanel({ size="xs" variant="warning-outline" disabled={actionPending} - onClick={() => - setConfirmation({ open: true, action: "approve-workflows" }) - } + {...inlineConfirm.props( + "approve-workflows", + () => void perform("approve-workflows"), + )} aria-label={ pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run" + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run" } > - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} + + + Approve workflows to run + + + Confirm approve? + + + {pendingAction === "approve-workflows" + ? "Approving..." + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} + @@ -2575,7 +2628,9 @@ export function PullRequestDetailPanel({ {pendingAction === "approve-workflows" ? "Approving..." - : "Approve workflows to run"} + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} ) : ( @@ -2802,74 +2857,6 @@ export function PullRequestDetailPanel({ />
    ) : null} - - setConfirmation((current) => ({ ...current, open }))} - onOpenChangeComplete={(open) => { - if (!open) setConfirmation({ open: false, action: "merge" }); - }} - > - - - - {confirmAction === "merge" - ? "Merge pull request?" - : confirmAction === "enable-auto-merge" - ? "Enable auto-merge?" - : confirmAction === "revert" - ? "Revert these changes?" - : confirmAction === "approve-workflows" - ? "Approve workflows to run?" - : "Close pull request?"} - - - {confirmAction === "merge" - ? `This merges #${reference.number} using ${selectedMergeMethod}.` - : confirmAction === "enable-auto-merge" - ? // The host merges this as soon as it considers the pull request ready, which - // may be immediately โ€” there is no telling from here whether anything is - // still outstanding. - `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` - : confirmAction === "revert" - ? `This opens a new pull request that reverses the changes merged by #${reference.number}.` - : confirmAction === "approve-workflows" - ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.` - : `This closes #${reference.number} without merging it.`} - - - - }> - Cancel - - - - -

    ); } diff --git a/apps/web/src/components/pullRequest/useInlineActionConfirm.ts b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts new file mode 100644 index 000000000000..5b4f7fc57212 --- /dev/null +++ b/apps/web/src/components/pullRequest/useInlineActionConfirm.ts @@ -0,0 +1,55 @@ +import { useEffect, useState, type MouseEvent } from "react"; + +/** Arms one control at a time, including controls rendered in a menu portal. */ +export function useInlineActionConfirm(scopeKey: string) { + const [armed, setArmed] = useState<{ + scopeKey: string; + action: string; + element: HTMLElement; + } | null>(null); + const active = armed?.scopeKey === scopeKey ? armed : null; + + useEffect(() => { + if (!armed) return; + const disarm = () => setArmed(null); + if (armed.scopeKey !== scopeKey) { + disarm(); + return; + } + const onPointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !armed.element.contains(event.target)) disarm(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + disarm(); + } + }; + const timeout = window.setTimeout(disarm, 3000); + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("blur", disarm); + return () => { + window.clearTimeout(timeout); + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("blur", disarm); + }; + }, [armed, scopeKey]); + + return { + isArmed: (action: string) => active?.action === action, + props: (action: string, onConfirm: () => void) => ({ + onClick: (event: MouseEvent) => { + if (active?.action === action && active.element === event.currentTarget) { + setArmed(null); + onConfirm(); + } else { + setArmed({ scopeKey, action, element: event.currentTarget }); + } + }, + onBlur: () => setArmed(null), + }), + }; +} From 4107930420cffe14776ad80783f7060236b5868b Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:38:39 -0700 Subject: [PATCH 264/530] feat(web): reorder right panel tabs by dragging (#17730) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 9 + apps/web/src/components/RightPanelTabs.tsx | 330 ++++++++++++-------- apps/web/src/rightPanelStore.test.ts | 23 ++ apps/web/src/rightPanelStore.ts | 28 +- apps/web/src/routes/_chat.pull-requests.tsx | 4 + 5 files changed, 260 insertions(+), 134 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 06a6bacaf48c..2aeca85e6aa2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6233,6 +6233,13 @@ export default function ChatView(props: ChatViewProps) { finishRightPanelSurfaceClose, rightPanelState.surfaces, ]); + const moveRightPanelSurface = useCallback( + (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }, + [activeThreadRef], + ); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -11991,6 +11998,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} @@ -12049,6 +12057,7 @@ export default function ChatView(props: ChatViewProps) { onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} + onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} onAddBrowser={() => createBrowserSurface()} onAddBrowserInProfile={createBrowserSurface} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 6c1206929b4c..5c8323a7ae34 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -14,6 +14,17 @@ import type { ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Smartphone, ChevronDown, @@ -27,6 +38,7 @@ import { } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -115,6 +127,8 @@ interface RightPanelTabsProps { onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; + /** Tabs are draggable only when the owner can persist the new order. */ + onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** @@ -199,6 +213,7 @@ type TabContextMenuAction = | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; +const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; @@ -795,6 +810,61 @@ function PullRequestSurfaceIcon({ return ; } +/** + * Lets its tabs be dragged into a new order. Neighbours slide aside while dragging, + * and the order commits on drop. + */ +function SortableTabList(props: { + surfaces: readonly RightPanelSurface[]; + onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + children: ReactNode; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const surfaceIds = useMemo(() => props.surfaces.map((surface) => surface.id), [props.surfaces]); + const { onMove, surfaces } = props; + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) return; + const toIndex = surfaces.findIndex((surface) => surface.id === over.id); + if (toIndex >= 0) onMove?.(String(active.id), toIndex); + }, + [onMove, surfaces], + ); + return ( + + + {props.children} + + + ); +} + +function SortableTab({ + surfaceId, + disabled, + className, + ...props +}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: surfaceId, + disabled, + }); + return ( +
    + ); +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; @@ -1118,135 +1188,143 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
    - {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
    handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} + + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - - - } + props.onCloseSurface(surface)} + > + - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - - {surface.kind === "device" ? ( - - ) : ( - title - )} - - - )} -
    - ); - })} + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + + {surface.kind === "device" ? ( + + ) : ( + title + )} + + + )} + + ); + })} + {props.open !== false ? (
    { ), ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); }); + + it("moves a surface to a new index and keeps it there through browser reconciliation", () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.openBrowser(refA, "tab-a"); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + const surfaceIds = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ); + + store.moveSurface(refA, "browser:tab-a", 0); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff"]); + store.moveSurface(refA, "browser:tab-a", 2); + expect(surfaceIds()).toEqual(["terminal:term-1", "diff", "browser:tab-a"]); + store.moveSurface(refA, "browser:tab-a", 0); + + store.reconcileBrowserSurfaces(refA, ["tab-a", "tab-b"]); + expect(surfaceIds()).toEqual(["browser:tab-a", "terminal:term-1", "diff", "browser:tab-b"]); + // Reordering is not a choice about what the panel shows. + expect(store.getUserActionRevision(refA)).toBe(revision); + }); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 812de8804046..8afdbedfcc87 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -176,6 +176,7 @@ interface RightPanelStoreState { closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void; reconcileBrowserSurfaces: ( ref: ScopedThreadRef, tabIds: readonly string[], @@ -893,22 +894,33 @@ export const useRightPanelStore = create()( : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), ), + // Reordering is not a choice about what the panel shows, so it leaves the + // user-action revision alone and proactive opens still apply. + moveSurface: (ref, surfaceId, toIndex) => + set((state) => + updateThread(state, scopedThreadKey(ref), (current) => { + const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (fromIndex < 0 || fromIndex === toIndex) return current; + const surfaces = [...current.surfaces]; + surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1)); + return { ...current, surfaces }; + }), + ), reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) => set((state) => automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); - const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); - const existingBrowser = current.surfaces.filter( - (surface): surface is Extract => - surface.kind === "preview" && - surface.id !== "browser:new" && - validIds.has(surface.id), + // Filtered in place so browser tabs keep the order the user dragged them into. + const kept = current.surfaces.filter( + (surface) => + surface.kind !== "preview" || + (surface.id !== "browser:new" && validIds.has(surface.id)), ); - const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const knownIds = new Set(kept.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId)) .map((tabId) => browserSurface(tabId)); - const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const surfaces = [...kept, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c6b0591ee257..02e0e9e26d46 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2241,6 +2241,10 @@ function PullRequestsRouteView() { if (surface.kind === "pull-request") closeSurfacesToRight(surface); }} onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} onCopyFilePath={() => undefined} onAddBrowser={() => undefined} onAddBrowserInProfile={() => undefined} From c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:39:17 -0300 Subject: [PATCH 265/530] fix(azure-devops): list pull requests with token sign-in and check out into worktrees (#17725) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 100 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 45 +++++++- .../src/server/AzureDevOpsCli.test.ts | 25 +++++ .../server/AzureDevOpsPullRequestCli.test.ts | 43 +++++++- .../src/server/AzureDevOpsPullRequestCli.ts | 37 ++++++- .../AzureDevOpsSourceControlProvider.test.ts | 1 + .../AzureDevOpsSourceControlProvider.ts | 3 +- .../src/server/azureDevOpsPullRequests.ts | 4 + 8 files changed, 248 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c9a08607bc08..79053fbb65eb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("prepares a worktree from the head branch when the host has no pull ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-pull-ref"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n"); + yield* runGit(repoDir, ["add", "head.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]); + yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]); + const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]); + // The remote keeps its Azure spelling, which is what the pull request is compared with. + const remoteUrl = "https://dev.azure.com/org/project/_git/repo"; + yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]); + yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]); + + // A pull request of another repository in the organization is not this remote's branch. + const { manager: otherRepositoryManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Another repository's PR", + url: "https://dev.azure.com/org/project/_git/other/pullrequest/79", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(otherRepositoryManager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }), + ); + // Nor is the branch of a closed one, which may have moved past the head it closed with. + const { manager: mergedManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Merged PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "merged", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(mergedManager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }), + ); + const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout; + expect(localBranches).not.toContain("feature/no-pull-ref"); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Azure DevOps PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/no-pull-ref"); + const worktreeHead = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "HEAD", + ])).stdout.trim(); + expect(worktreeHead).toBe(headSha); + const upstream = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstream).toBe("origin/feature/no-pull-ref"); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index bd4ee0da7e9d..c5d372f8fb2b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -54,6 +54,7 @@ import { sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { + canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -958,11 +959,45 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }); + yield* gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the + // same repository is a branch on the primary remote, so it is fetched by name instead, + // but only when that remote is the pull request's own repository: Azure finds a pull + // request by number anywhere in the organization. Only while it is open, too: the + // branch of a closed one may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue( + cwd, + `remote.${remoteName}.url`, + ); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + canonicalRepositoryKey(pullRequestKey) !== + canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index e4fca9c5ffae..0a6b4fd6fc57 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", timeoutMs: 30_000, }); + assert.strictEqual(result.isCrossRepository, false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks a pull request from a fork as cross-repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 43, + title: "From a fork", + sourceRefName: "refs/heads/main", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" }); + + assert.strictEqual(result.isCrossRepository, true); }).pipe(Effect.provide(layer)), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index b3afaca03715..8ad71188e187 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("fails when nobody is signed in", () => + it.effect("names a token sign-in by the viewer's own pull request", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("Please run 'az login' to setup account."), + }); + mockedExecute + .mockReturnValueOnce(Effect.fail(notLoggedIn)) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))) + .mockReturnValueOnce(Effect.succeed(output("[]"))); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev"); + expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"])); + // Someone who has opened nothing has no name to compare rows against. const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); - assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); }), ); + it.effect("fails when no sign-in can read pull requests", () => + Effect.gen(function* () { + const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("signed out"), + }); + mockedExecute.mockReturnValue(Effect.fail(signedOut)); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError"); + }), + ); + it.effect("completes a pull request to merge it, squashing only when asked", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 70fe78b34bf9..b7d719725fe3 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError error._tag !== "AzureDevOpsCliUnavailableError", + () => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--creator", + "me", + "--status", + "all", + "--top", + "1", + ], + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodePullRequestListJson(result.stdout.trim() || "[]"); + const login = Result.isSuccess(decoded) + ? decoded.success.items[0]?.author?.login + : undefined; + return login === undefined + ? Effect.fail( + new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }), + ) + : Effect.succeed(login); + }), + ), + ), ), listPullRequests: (input) => 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 8e55d453b224..f0362851596d 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" baseRefName: "main", headRefName: "feature/source-control", state: "closed", + isCrossRepository: false, closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index dcb07e0632f3..f935fb808709 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; @@ -77,7 +78,7 @@ function toChangeRequest(summary: { closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.isCrossRepository, }; } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts index 6179a61aa65d..7ffc398490cf 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; @@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ targetRefName: TrimmedNonEmptyString, status: Schema.String, isDraft: Schema.optional(Schema.Boolean), + /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */ + forkSource: Schema.optional(Schema.Unknown), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord( headRefName: normalizeRefName(raw.sourceRefName), state, ...(raw.isDraft === true ? { isDraft: true } : {}), + isCrossRepository: raw.forkSource != null, closedAt: state === "closed" ? terminalAt : null, mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( From 0be20c86c2962b7b581dfc79df9ab34b4fbdab14 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:47:53 -0300 Subject: [PATCH 266/530] fix(usage): keep one email in two workspaces as two accounts (#17711) --- apps/server/src/provider/ClaudeProvider.ts | 6 + apps/server/src/provider/CodexProvider.ts | 12 +- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../server/src/provider/claudeResetCredits.ts | 7 + apps/server/src/usage/cliproxyApi.ts | 3 + packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/server.ts | 6 + .../src/server/managedProvider.test.ts | 16 +- .../src/server/managedProvider.ts | 27 ++- packages/shared/src/usageLimits.test.ts | 190 ++++++++++++++++++ packages/shared/src/usageLimits.ts | 165 ++++++++++----- 11 files changed, 382 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fef..8bc961834f27 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { type ClaudeCapabilitiesProbe = { readonly email: string | undefined; + /** + * The organization UUID from the login's `.claude.json`. The driver reads it + * in the same cached probe, so it describes the same login as the rest. + */ + readonly workspaceId?: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; /** Where the CLI found an API key, when it authenticates with one. */ @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "authenticated", ...(capabilities.email ? { email: capabilities.email } : {}), + ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index cb1a5513cd01..5109bb840f56 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = | null | undefined; readonly resetCredits: CodexResetCreditsSummary | null | undefined; + /** The ChatGPT workspace the usage belongs to, when the backend names it. */ + readonly accountId?: string | null | undefined; } | { readonly failure: string }; @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, })), Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), Effect.map( @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { +function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); const authEmail = codexAccountEmail(account.account); + // One email can hold several workspaces, each with its own quota; the usage + // read names the one this login draws on. + const workspaceId = + rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined; const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), ...(authEmail ? { email: authEmail } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ServerProvider["auth"]; if (account.account) { @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot.account); + : accountProbeStatus(snapshot); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index be7872a7df28..0169eb87ba76 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.all([ + probeClaudeCapabilities(effectiveConfig, processEnv, cwd), + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath), + ]).pipe( + Effect.map(([capabilities, workspaceId]) => + capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities, + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index e381fa7cabe2..3c4a54742082 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ +/** The organization the login is signed in to; one email can belong to several. */ +export const readClaudeOrganizationId = (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( configDir: string | undefined, ) { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index c50ebb57f40e..b80ebe59261e 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c692a9b7d519..0108bbb669eb 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ plan: Schema.optional(TrimmedNonEmptyString), + /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ + workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5855477855d1..a726f05a311c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ canLogout: Schema.optional(Schema.Boolean), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), + /** + * The workspace or organization whose quota the login draws on, such as a + * ChatGPT workspace or Claude organization id. One email can belong to + * several, each with its own quota. + */ + workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 239508cec41c..1d73e112b815 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( Effect.map((count) => count === 1 - ? { ...refreshedSnapshot, usageLimits: probedLimits } + ? { + ...refreshedSnapshot, + auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, + usageLimits: probedLimits, + } : { ...refreshedSnapshotSecond, + ...(count === 3 + ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } + : {}), usageLimits: { checkedAt: "2026-04-10T00:00:03.000Z", windows: [], @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); + // ...and the workspace they were read for. + assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); + + // A failed read for another workspace does not inherit those windows. + const switched = yield* provider.refresh; + assert.strictEqual(switched.auth.workspaceId, "ws-b"); + assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed"); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index b74788a38bad..a11734a1661b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 : state.enrichmentGeneration; + // A failed read keeps the last good limits only for the same account, + // and they keep the workspace they were read for. + const previous = state.snapshot.auth; + const differs = (a: string | undefined, b: string | undefined) => + a !== undefined && b !== undefined && a !== b; + // Limits read without a workspace cannot be attributed to a new one. + const switchedAccount = + differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || + (probedSnapshot.auth.workspaceId !== undefined && + probedSnapshot.auth.workspaceId !== previous.workspaceId); + const usageLimits = switchedAccount + ? probedSnapshot.usageLimits + : resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }); + const workspaceId = + usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; const snapshot = withUsageLimits( - probedSnapshot, - resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, - }), + workspaceId && !probedSnapshot.auth.workspaceId + ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } + : probedSnapshot, + usageLimits, ); return [ { snapshot, generation }, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b2bef55cb108..668103302417 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -496,6 +496,128 @@ describe("pools", () => { expect(account?.redeem?.environmentId).toBe("env-2"); }); + it("keeps one email in two workspaces apart while merging each workspace's duplicates", () => { + const login = (instanceId: string, workspaceId: string, usedPercent: number) => + provider({ + instanceId: ProviderInstanceId.make(instanceId), + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent }] }, + }); + const hubAccount = (id: string, workspaceId?: string) => ({ + id, + driver: ProviderDriverKind.make("codex"), + email: "same@example.com", + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)], + usageLimitSources: [ + // A hub that names the workspace joins it; one that does not + // cannot say which of the two it read, so it keeps its own row. + { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] }, + ], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [login("personal", "ws-plus", 26)] }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual([ + "env-a:business", + "env-a:personal", + "hub:x.json", + ]); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8); + // One hub file name in two workspaces stays two accounts, even without an + // email to tell them apart. + const withoutEmail = (id: string, workspaceId: string) => { + const { email: _email, ...account } = hubAccount(id, workspaceId); + return account; + }; + const sameFile = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-1")] }], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-2")] }], + }, + }, + ], + ]); + expect(collectLimitAccounts(sameFile).map((account) => account.key)).toEqual([ + "hub:a.json:ws-1", + "hub:a.json:ws-2", + ]); + expect(accounts[1]?.environments.map((environment) => environment.label)).toEqual([ + "Laptop", + "Desktop", + ]); + }); + + it("joins a report without a workspace to the only workspace signed in with its email", () => { + const native = provider({ + driver: claude, + auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hubAccount = { + id: "claude.json", + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [window] }, + }; + const withOther = (other: ServerProvider) => + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native, other], + usageLimitSources: [{ ...source, accounts: [hubAccount] }], + }, + }, + ], + ]); + const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") }); + expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ + "env-a:codex", + ]); + // A second org whose limits could not be read still makes the hub ambiguous. + const unreadable = provider({ + ...native, + instanceId: ProviderInstanceId.make("team"), + auth: { ...native.auth, workspaceId: "org-b" }, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([ + "env-a:codex", + "hub:claude.json", + ]); + }); + it("names an environment once however many of its instances share the account", () => { const shared = provider({ auth: { status: "authenticated", email: "same@example.com" }, @@ -536,6 +658,45 @@ describe("pools", () => { expect(accounts[0]?.displayName).toBe("claude-team-seat"); }); + it("pools Codex windows by kind, whichever slot reported them", () => { + const codexAccount = (id: string, slots: readonly string[]) => ({ + id, + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt, + windows: slots.map((slot) => ({ ...weekly, id: slot, usedPercent: 50 })), + }, + }); + const pooled = (accounts: ReturnType[]) => + collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { ...laptop, serverConfig: { usageLimitSources: [{ ...source, accounts }] } }, + ], + ]), + ), + now, + )[0]?.windows ?? []; + // A weekly-only plan reports weekly as `primary`. The pool keeps one id + // whichever account sorts first. + for (const order of [ + [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])], + [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])], + ]) { + const windows = pooled(order); + expect(windows.map((window) => window.id)).toEqual(["weekly"]); + expect(windows[0]?.members).toHaveLength(2); + } + // A second window of the same kind on one account stays apart, with its own id. + expect( + pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map( + (window) => window.id, + ), + ).toEqual(["weekly", "weekly:secondary"]); + }); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { }); }); + it("gives a hub credit to no native row when its email is signed in to two workspaces", () => { + const workspaces = ["ws-a", "ws-b"].map((workspaceId) => + provider({ + instanceId: ProviderInstanceId.make(workspaceId), + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + }), + ); + const hub = [ + { + ...sources[0]!, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "same@example.com", + usageLimits: { ...limits, resetCredits: { availableCount: 2, nextCreditId: "c" } }, + }, + ], + }, + ]; + const report = collectProviderUsageLimits(workspaces[0]!.instanceId, workspaces, hub, now); + expect(report?.accounts.map((account) => account.resetCreditInput)).toEqual([ + { instanceId: "ws-a" }, + { instanceId: "ws-b" }, + { sourceId: "hub", accountId: "duplicate", creditId: "c" }, + ]); + }); + it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { const fresher = provider({ usageLimits: { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b1211bffd54d..85c5e6e39f88 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -125,11 +125,51 @@ function accountKey( : null; } +interface AccountReport { + readonly driver: ServerProvider["driver"]; + readonly email?: string | undefined; + readonly workspaceId?: string | undefined; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const nativeReport = (provider: ServerProvider): AccountReport => ({ + driver: provider.driver, + email: provider.auth.email, + workspaceId: provider.auth.workspaceId, + usageLimits: provider.usageLimits, +}); + +/** + * Keys each report by the account it draws on. One email can belong to + * several workspaces, each with its own quota, so a report that names its + * workspace is keyed by it too. One that does not (a hub, an older server) + * joins the only workspace known for its email, and gets no key when there + * are several, since it cannot say which one it read. Pass every enabled + * login, read or not, so an unreadable workspace still counts as one. + */ +function accountKeyResolver(reports: Iterable) { + const workspaces = new Map>(); + for (const report of reports) { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (key && report.workspaceId) { + workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); + } + } + return (report: AccountReport): string | null => { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (!key) return null; + if (report.workspaceId) return `${key}:${report.workspaceId}`; + const known = workspaces.get(key); + if (!known) return key; + return known.size === 1 ? `${key}:${[...known][0]}` : null; + }; +} + /** * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name - * one account. Its quota is one bucket, so counting it twice would misstate - * what is left. + * reported. Matching emails or credentials across environments name one + * account, unless they name different workspaces. Its quota is one bucket, so + * counting it twice would misstate what is left. */ export interface LimitAccount { readonly key: string; @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }, }); }; + const resolveKey = accountKeyResolver( + [...presentations.values()].flatMap((presentation) => [ + ...(presentation.serverConfig?.providers ?? []) + .filter((provider) => provider.enabled) + .map(nativeReport), + ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ]), + ); for (const [environmentId, presentation] of presentations) { const label = presentation.entry.target.label; for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; - merge( - accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? - `${environmentId}:${provider.instanceId}`, - { - key: `${environmentId}:${provider.instanceId}`, - driver: provider.driver, - displayName: provider.displayName?.trim() || null, - email: provider.auth.email, - plan: provider.auth.label, - accentColor: provider.accentColor, - environments: [{ environmentId, label }], - sourceLabel: null, - redeem: { environmentId, input: { instanceId: provider.instanceId } }, - limits: provider.usageLimits, - }, - ); + merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, input: { instanceId: provider.instanceId } }, + limits: provider.usageLimits, + }); } } // Every hub account, including those a native instance also knows: the hub @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge( - accountKey(account.driver, account.email, account.usageLimits) ?? - `${source.id}:${account.id}`, - { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }, - ); + const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; + merge(resolveKey(account) ?? key, { + key, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }); } } } @@ -401,7 +442,7 @@ export function collectLimitPools( .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; const orderReset = (account: LimitAccount) => { const window = account.limits.windows.find( - (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow), ); return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; }; @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +/** + * Codex ids are slots, not allowances: a weekly-only plan reports its weekly + * window as `primary`, other plans as `secondary`. Its windows pool by kind; + * other drivers' ids name distinct allowances. + */ +function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { + return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; +} + function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { const byKey = new Map(); for (const account of accounts) { for (const window of account.limits.windows) { - const key = `${window.kind}:${window.id}`; + const pooled = poolKey(account.driver, window); + // An account with two windows of one kind keeps the second apart. + const key = byKey.get(pooled)?.some((member) => member.account === account) + ? `${window.kind}:${window.id}` + : pooled; const list = byKey.get(key); if (list) list.push({ account, window }); else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const pools = [...byKey].map(([key, members]): LimitPoolWindow => { const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; + // Pooled Codex slots take the pool's own key, so the id stays the same + // whichever account sorts first. + const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the // members that have a clock; a window with no reset would otherwise @@ -458,7 +515,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L }) .sort((left, right) => left.at - right.at); return { - id: first.id, + id, kind: first.kind, label: first.label, members, @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( const native = providersWithLimits(providers).filter( (provider) => provider.driver === selected.driver, ); + const resolveKey = accountKeyResolver([ + ...providers.filter((provider) => provider.enabled).map(nativeReport), + ...sources.flatMap((source) => source.accounts), + ]); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email, account.usageLimits) === key && + resolveKey(account) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email, account.usageLimits); + const key = resolveKey(account); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, From c3243f05088c3c20e9439f285fc6d7b0bfe1747d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:05:38 -0700 Subject: [PATCH 267/530] feat(pull-requests): hosts can report edit and resolve permissions per item (#17667) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestCodeTab.tsx | 10 ++- .../pullRequestEditing.logic.test.ts | 66 +++++++++++++++++++ .../pullRequest/pullRequestEditing.logic.ts | 30 +++++++-- packages/contracts/src/pullRequest.ts | 23 +++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 1339b46196b8..d5f054dadab0 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -33,7 +33,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings" import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; -import { canEditPullRequestComment } from "./pullRequestEditing.logic"; +import { canEditPullRequestComment, canResolvePullRequestThread } from "./pullRequestEditing.logic"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -934,7 +934,7 @@ function PullRequestCodeTab({ thread={thread} workspaceRoot={detail.workspaceRoot} canReply={review.reply} - canResolve={review.resolve} + canResolve={review.resolve && canResolvePullRequestThread(detail, thread)} canReact={detail.capabilities.reactions === true} environmentId={environmentId} reference={reference} @@ -966,7 +966,11 @@ function PullRequestCodeTab({ } // A conversation on a line is made of review comments, whatever the host filed them as. canEditComment={(comment) => - canEditPullRequestComment(detail, { author: comment.author, kind: "review-comment" }) + canEditPullRequestComment(detail, { + author: comment.author, + kind: "review-comment", + canEdit: comment.canEdit, + }) } onEditComment={(commentId, body) => runThreadCommand("The comment could not be saved", () => diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts index 1b43ff209b23..06ecec5d3295 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { canEditPullRequestChangeRequest, canEditPullRequestComment, + canResolvePullRequestThread, } from "./pullRequestEditing.logic"; type Subject = Pick; @@ -89,6 +90,33 @@ describe("canEditPullRequestChangeRequest", () => { ).toBe(true); }); + it("follows the host's own answer about the reader over authorship and merge access", () => { + expect( + canEditPullRequestChangeRequest( + subject({ + author: actor("someone-else"), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(true); + expect( + canEditPullRequestChangeRequest( + subject({ + viewerPermissions: permissions({ actions: ["merge"], editChangeRequest: false }), + }), + ), + ).toBe(false); + // The host can't rewrite change requests at all, so the reader's answer can't widen that. + expect( + canEditPullRequestChangeRequest( + subject({ + capabilities: capabilities({ changeRequest: false, comment: true }), + viewerPermissions: permissions({ editChangeRequest: true }), + }), + ), + ).toBe(false); + }); + it("refuses a reader who neither wrote it nor may merge it", () => { expect( canEditPullRequestChangeRequest( @@ -146,6 +174,23 @@ describe("canEditPullRequestComment", () => { ); }); + it("follows the host's own answer about a remark over authorship", () => { + expect(canEditPullRequestComment(subject(), comment({ canEdit: false }))).toBe(false); + expect( + canEditPullRequestComment( + subject(), + comment({ author: actor("someone-else"), canEdit: true }), + ), + ).toBe(true); + // The host can't rewrite remarks at all, so its per-remark answer can't widen that. + expect( + canEditPullRequestComment( + subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), + comment({ canEdit: true }), + ), + ).toBe(false); + }); + it("refuses a remark the host attributes to nobody", () => { expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false); }); @@ -170,3 +215,24 @@ describe("canEditPullRequestComment", () => { expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false); }); }); + +describe("canResolvePullRequestThread", () => { + it("uses the reader's repository-wide permission where the host says nothing per thread", () => { + expect(canResolvePullRequestThread(subject(), {})).toBe(true); + expect( + canResolvePullRequestThread( + subject({ viewerPermissions: permissions({ resolve: false }) }), + {}, + ), + ).toBe(false); + }); + + it("only narrows the reader's permission with the host's answer for one thread", () => { + expect(canResolvePullRequestThread(subject(), { canResolve: false })).toBe(false); + expect( + canResolvePullRequestThread(subject({ viewerPermissions: permissions({ resolve: false }) }), { + canResolve: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts index a7e49f3f635f..520ff7a7ef96 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.ts @@ -1,4 +1,8 @@ -import type { PullRequestComment, PullRequestDetail } from "@t3tools/contracts"; +import type { + PullRequestComment, + PullRequestDetail, + PullRequestReviewThread, +} from "@t3tools/contracts"; /** Only the parts of a detail either answer reads, so a caller can pass a whole detail view. */ type EditingSubject = Pick< @@ -14,12 +18,15 @@ function sameLogin(one: string | null | undefined, other: string | null | undefi /** * Whether the title and description can be rewritten from here. Beyond the host being able to at - * all, either the reader wrote the change request or they may merge it โ€” merging is the one action - * every host here grants with write access and withholds without it, so it stands in for the - * permission none of them publishes by name. + * all, a host that reports `editChangeRequest` answers directly. Otherwise either the reader wrote + * the change request or they may merge it: merging is the one action every host here grants with + * write access and withholds without it, so it stands in for the permission they don't publish. */ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean { if (detail.capabilities.edit?.changeRequest !== true) return false; + if (detail.viewerPermissions.editChangeRequest !== undefined) { + return detail.viewerPermissions.editChangeRequest; + } return ( sameLogin(detail.viewer, detail.author?.login) || detail.viewerPermissions.actions.includes("merge") @@ -33,9 +40,22 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean */ export function canEditPullRequestComment( detail: EditingSubject, - comment: Pick, + comment: Pick, ): boolean { if (detail.capabilities.edit?.comment !== true) return false; if (comment.kind !== "issue-comment" && comment.kind !== "review-comment") return false; + if (comment.canEdit !== undefined) return comment.canEdit; return sameLogin(detail.viewer, comment.author?.login); } + +/** + * Whether the reader may resolve or reopen one review thread. The host's answer for this thread + * can only narrow the reader's repository-wide `resolve` permission: a thread can't grant what the + * repository withholds. + */ +export function canResolvePullRequestThread( + detail: Pick, + thread: Pick, +): boolean { + return detail.viewerPermissions.resolve && thread.canResolve !== false; +} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2cd0e45cc8e0..1efbc1808d14 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -209,6 +209,12 @@ export const PullRequestComment = Schema.Struct({ reviewState: Schema.NullOr(Schema.String), /** Absent from a host with no reactions at all, which is a different thing from none on this. */ reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestComment = typeof PullRequestComment.Type; @@ -232,6 +238,12 @@ export const PullRequestThreadComment = Schema.Struct({ editedAt: Schema.optional(Schema.NullOr(IsoDateTime)), url: Schema.NullOr(Schema.String), reactions: Schema.optional(Schema.Array(PullRequestReaction)), + /** + * The host's own answer to whether this reader may rewrite this remark. Absent where the host + * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both + * here and in its review thread; a host that sets this sets it on both copies alike. + */ + canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; @@ -257,6 +269,12 @@ export const PullRequestReviewThread = Schema.Struct({ commentCount: Schema.optional(NonNegativeInt), /** Opaque cursor for the next comment page. Absent once this thread is whole. */ nextCommentsCursor: Schema.optional(TrimmedNonEmptyString), + /** + * The host's own answer to whether this reader may resolve this thread. It can only narrow the + * reader's repository-wide `resolve` permission, never widen it. Absent where the host + * doesn't say per thread. + */ + canResolve: Schema.optional(Schema.Boolean), }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; @@ -481,6 +499,11 @@ export const PullRequestViewerPermissions = Schema.Struct({ * changed on this host at all. */ labels: Schema.optional(Schema.Boolean), + /** + * This viewer may rewrite the change request's title and description. Absent where the host + * doesn't say, which leaves the page to guess from authorship and merge access. + */ + editChangeRequest: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; From 9cc57680cb5f762f553acd1fa5f3efcf17d8fc67 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:08:22 -0400 Subject: [PATCH 268/530] perf(server): run Git for Windows' real git.exe, not its launcher (#17707) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.ts | 10 ++- packages/shared/src/shell.test.ts | 89 +++++++++++++++++++++++++ packages/shared/src/shell.ts | 48 ++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 51c1f7b29468..53e7f6418058 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...input.env, ...trace2Monitor.env, }; + const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; + const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); + // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit + // messages at the first newline; leave that case to Node's lookup. + const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner .spawn( - ChildProcess.make("git", commandInput.args, { + ChildProcess.make(executable, commandInput.args, { cwd: commandInput.cwd, - env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, + env: spawnEnv, }), ) .pipe( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 428d0d5951f5..5f493c8044d3 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -15,6 +15,7 @@ import { listLoginShellCandidates, mergePathEntries, mergePathValues, + preferGitForWindowsBinary, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { ); }); +describe("preferGitForWindowsBinary", () => { + const files = + (...paths: Array) => + (filePath: string) => + paths.includes(filePath); + + it("runs the git.exe Git for Windows' launcher would start", () => { + // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.EXE", + {}, + files( + "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe", + "C:\\Program Files\\Git\\mingw64\\bin\\git.exe", + ), + ), + ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe"); + // Before 2.56, and the portable build's bin launcher. + expect( + preferGitForWindowsBinary( + "D:\\PortableGit\\bin\\git.exe", + {}, + files("D:\\PortableGit\\mingw64\\bin\\git.exe"), + ), + ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe"); + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + {}, + files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"); + }); + + it("keeps the launcher when MSYSTEM is set", () => { + // The real git.exe only adds its own folders to PATH when MSYSTEM is unset; + // without them a `#!/bin/sh` hook cannot start. + expect( + preferGitForWindowsBinary( + "C:\\Program Files\\Git\\cmd\\git.exe", + { MSYSTEM: "MINGW64" }, + files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"), + ), + ).toBe("C:\\Program Files\\Git\\cmd\\git.exe"); + }); + + it("keeps the launcher when no git.exe sits beside it", () => { + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe( + "C:\\Program Files\\Git\\cmd\\git.exe", + ); + }); + + it("leaves other gits and other launchers alone", () => { + const everything = () => true; + expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe( + "C:\\Users\\me\\scoop\\shims\\git.exe", + ); + expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe( + "C:\\Program Files\\Git\\cmd\\gitk.exe", + ); + }); +}); + effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { it.effect("runs Windows executables directly without a shell", () => Effect.gen(function* () { @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }).pipe(Effect.provideService(CommandResolutionCache, new Map())), ); + it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () => + Effect.gen(function* () { + let scans = 0; + const scan: SpawnExecutableResolver = () => { + scans++; + return "C:\\Git\\cmd\\git.exe"; + }; + const resolve = (env: NodeJS.ProcessEnv) => + resolveSpawnCommand("git", [], { + env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env }, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, scan), + ); + + yield* resolve({}); + yield* resolve({}); + expect(scans).toBe(1); + // A swap made without MSYSTEM must not reach a child that has it set. + yield* resolve({ MSYSTEM: "MINGW64" }); + expect(scans).toBe(2); + }).pipe(Effect.provideService(CommandResolutionCache, new Map())), + ); + it.effect("does not fall back to a shell for unresolved Windows commands", () => Effect.gen(function* () { const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index b24c998b1785..4d29841bfa8b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); +// Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds +// live in clangarm64 and 32-bit ones in mingw32. +const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; + +function isFileSync(filePath: string): boolean { + try { + return NodeFS.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Swaps Git for Windows' launcher (`\cmd\git.exe`, the only git its + * installer puts on PATH, or the portable build's `\bin\git.exe`) for the + * git.exe it starts. The launcher costs a second process on every git command, + * and each launch leaks a kernel token reference that slows process creation + * machine-wide until reboot. The real binary sets HOME itself, but adds its own + * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is + * unset, so the launcher stays when MSYSTEM is set. Anything else is returned + * as is. + */ +export function preferGitForWindowsBinary( + executable: string, + env: NodeJS.ProcessEnv, + isFile: (filePath: string) => boolean = isFileSync, +): string { + if (env.MSYSTEM) return executable; + const path = NodePath.win32; + if (path.basename(executable).toLowerCase() !== "git.exe") return executable; + const launcherDirectory = path.dirname(executable); + const launcherFolder = path.basename(launcherDirectory).toLowerCase(); + if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; + const installRoot = path.dirname(launcherDirectory); + for (const build of GIT_FOR_WINDOWS_BUILDS) { + const candidate = path.join(installRoot, build, "bin", "git.exe"); + if (isFile(candidate)) return candidate; + } + return executable; +} + // Untraced because it runs before most spawns and returns at once off Windows. export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( platform, resolvePathEnvironmentVariable(env), resolveWindowsPathExtensions(env).join(";"), + env.MSYSTEM ? "msystem" : "", command, ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); const nowNanos = yield* Clock.currentTimeNanos; @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { - resolvedExecutable = resolveExecutable(command, platform, env) ?? null; + // Cached with the scan: its file checks would otherwise run before every + // git launch. A Git upgrade that moves the real binary can fail git for up + // to the cache lifetime. + const found = resolveExecutable(command, platform, env); + resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); if (!explicitPath && resolvedExecutable !== null) { cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); } From a7223f3969428170cc13bd7859aa59098b2f94b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:09:20 -0700 Subject: [PATCH 269/530] fix(clients): restart continuations show as a T3 Code notice, not another agent's message (#17723) Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 8 +++- apps/mobile/src/lib/threadActivity.test.ts | 20 +++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++- .../src/orchestration-v2/Orchestrator.ts | 8 +++- .../RestartContinuation.test.ts | 26 ++++++++-- .../orchestration-v2/RestartContinuation.ts | 22 ++++++--- .../src/orchestration-v2/runtimeLayer.test.ts | 17 +++++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++++- .../src/state/threadExecution.ts | 1 + .../client-runtime/src/userMessage.test.ts | 31 ++++++++++-- packages/client-runtime/src/userMessage.ts | 48 +++++++++++++++---- packages/contracts/src/orchestrationV2.ts | 2 + 12 files changed, 171 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a210ffcb1dc6..ee0c4711b03b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1719,11 +1719,15 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.isAutomation ? ( + {presentation.attribution === "automation" ? ( Sent by automation - ) : message.createdBy === "agent" ? ( + ) : presentation.attribution === "t3code" ? ( + + Sent by T3 Code + + ) : presentation.attribution === "agent" ? ( { expect(withoutPath?.canExpand).toBe(false); }); +it("expands a T3 Code notice to the prompt the agent got", () => { + const item = { + ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), + type: "notification", + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + } as OrchestrationV2TurnItem; + const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); + expect(activity?.canExpand).toBe(true); + expect(activity?.getFullDetail()).toBe("Continue where you left off."); +}); + it("labels file searches with the adapter title and its search target", () => { const item: OrchestrationV2TurnItem = { ...base("file-search", "2026-06-20T00:00:03.000Z", 2), @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { expect(messageEntry).toBeDefined(); expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ text: "Run checks", - isAutomation: true, + attribution: "automation", }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11..edaaf2b0fab8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "monitor": return "eye"; case "background_task": + case "system": return "zap"; default: source satisfies never; @@ -814,7 +815,11 @@ function toFeedActivity( ? collectToolFilePaths(item) : null; const getFullDetail = memoizeValue(() => - readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + readPaths + ? readPaths.join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 475c6bea0a94..3f17a6faa55d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode = { type: "start_immediately" }; } } + // A restart continuation resumes the interrupted turn, so it starts + // immediately rather than queueing like other wakes. if ( command.notification !== undefined && (command.createdBy !== "agent" || (command.creationSource !== "server" && command.creationSource !== "provider") || - dispatchMode.type !== "queue_after_active") + (dispatchMode.type !== "queue_after_active" && + !( + command.restartContinuationOfRunId !== undefined && + dispatchMode.type === "start_immediately" + ))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index dcbf4aa63728..34b3d8352c68 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -10,6 +10,7 @@ import { RunAttemptId, RunId, ThreadId, + type OrchestrationV2Command, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationTexts = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => Effect.gen(function* () { - const texts: Array = []; + const dispatches: Array> = []; yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( Effect.provide( Layer.merge( @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => getThreadRecords: () => Effect.succeed(projection), recoverDelegatedTask: () => Effect.void, dispatch: (command) => { - if (command.type === "message.dispatch") texts.push(command.text); + if (command.type === "message.dispatch") dispatches.push(command); return Effect.succeed({} as never); }, }), @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => ), ), ); - return texts; + return dispatches; }); +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + continuationDispatches(projection).pipe( + Effect.map((dispatches) => dispatches.map((command) => command.text)), + ); + const cutMidTurn = (extra: Record = {}) => { const base = makeProjection(); return { @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => }), ); +it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => + Effect.gen(function* () { + const [resumed] = yield* continuationDispatches(cutMidTurn()); + assert.deepEqual(resumed?.notification, { + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }); + }), +); + it.effect( "carries the note forward when its continuation was cut before reaching the provider", () => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc4..b7e905d0f4a9 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ); const noteText = note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); + const text = + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`; yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: - noteText === undefined - ? CONTINUE_PROMPT - : note.settled - ? noteText - : `${noteText}\n\n${CONTINUE_PROMPT}`, + text, + // Shown as a work log row; the prompt the agent got is its detail. + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bb70c880b061..3d4bf4c2a64a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, messageId: MessageId.make("restart-automatic-message"), text: "Continue where you left off.", + notification: { + source: { kind: "system" as const }, + outcome: "updated" as const, + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }, attachments: [], modelSelection, dispatchMode: { type: "start_immediately" as const }, @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const admitted = yield* orchestrator.getThreadProjection(threadId); assert.lengthOf(admitted.runs, 2); assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); + // The timeline shows a work log row; the prompt stays in its detail. + const continuationItems = admitted.turnItems.filter( + (item) => item.runId === admitted.runs[1]?.id, + ); + assert.isFalse(continuationItems.some((item) => item.type === "user_message")); + assert.deepInclude( + continuationItems.flatMap((item) => + item.type === "notification" ? [[item.summary, item.detail]] : [], + ), + ["T3 Code restarted and resumed this turn", "Continue where you left off."], + ); // A differently identified stale delivery still must not create another run. yield* orchestrator.dispatch({ ...command, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a3138c29044b..0518c96afc58 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.isAutomation ? ( + {userMessage.attribution === "automation" ? (

    - ) : row.message.createdBy === "agent" ? ( + ) : userMessage.attribution === "t3code" ? ( +

    + Sent by T3 Code +

    + ) : userMessage.attribution === "agent" ? (

    {senderThreadId ? ( { it("removes attribution from legacy scheduled prompts", () => { expect( resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), - ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined }); + ).toEqual({ + text: "Check for crashes.\n", + attribution: "automation", + scheduledTaskId: undefined, + }); }); it("uses task metadata without changing the prompt", () => { @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { scheduledTaskId: ScheduledTaskId.make("task-1"), text: legacyText, }), - ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" }); + ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" }); }); it("preserves user-written and assistant-quoted schedule headers", () => { @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ text: legacyText, - isAutomation: false, + attribution: null, scheduledTaskId: undefined, }); } @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ text, - isAutomation: false, + attribution: "agent", scheduledTaskId: undefined, }); } }); + it("attributes older server-sent restart continuations to T3 Code, not another agent", () => { + const text = "Note: the T3 server restarted."; + expect( + resolveUserMessagePresentation({ + role: "user", + createdBy: "agent", + creationSource: "server", + text, + }), + ).toMatchObject({ attribution: "t3code" }); + for (const creationSource of ["mcp", "provider"] as const) { + expect( + resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }), + ).toMatchObject({ attribution: "agent" }); + } + }); + it("recovers the triggering automation from older scheduler message ids", () => { for (const trigger of ["scheduled", "manual"]) { expect( @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { }), ).toEqual({ text: "Check for crashes.\n", - isAutomation: true, + attribution: "automation", scheduledTaskId: "task:daily-audit", }); } diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index 5acad0a44802..b292bafe0ca4 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -1,31 +1,59 @@ -import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts"; +import { + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, + ScheduledTaskId, +} from "@t3tools/contracts"; const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; -/** Older scheduled messages stored their attribution in the prompt itself. */ +/** + * Text and sender label for a user-role message. Older scheduled messages + * stored their attribution in the prompt itself. + */ export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; readonly text: string; readonly createdBy?: OrchestrationV2Actor; + readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; -}) { +}): { + readonly text: string; + /** Who sent a user-role message the user did not type. */ + readonly attribution: "automation" | "agent" | "t3code" | null; + readonly scheduledTaskId: ScheduledTaskId | undefined; +} { if (message.role !== "user") { - return { text: message.text, isAutomation: false, scheduledTaskId: undefined }; + return { text: message.text, attribution: null, scheduledTaskId: undefined }; } if (message.scheduledTaskId !== undefined) { - return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId }; + return { + text: message.text, + attribution: "automation", + scheduledTaskId: message.scheduledTaskId, + }; } const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); const legacyTaskId = legacyPrefix ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] : undefined; - const isAutomation = - legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent"); + if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) { + return { + text: message.text.slice(legacyPrefix[0].length), + attribution: "automation", + scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + }; + } return { - text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text, - isAutomation, - scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + text: message.text, + // Restart continuations were sent as the agent before they became notices. + attribution: + message.createdBy !== "agent" + ? null + : message.creationSource === "server" + ? "t3code" + : "agent", + scheduledTaskId: undefined, }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 423f8234178b..7aa8c74fd80a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( CommandNotificationSource, Schema.Struct({ kind: Schema.Literal("monitor") }), Schema.Struct({ kind: Schema.Literal("background_task") }), + /** T3 Code itself, such as a restart continuing an interrupted turn. */ + Schema.Struct({ kind: Schema.Literal("system") }), ], (kind) => Schema.Struct({ kind }), () => ({ kind: "background_task" }), From 57b3780770a829ff81e62d8dc658683bf51d5155 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 23:13:32 -0700 Subject: [PATCH 270/530] fix(mobile): browser picture in picture opens from the header button (#17731) Co-authored-by: Claude Opus 5.5 --- .../browser/preview-stream.browser.ts | 80 ++++++++++++------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/apps/mobile/src/features/browser/preview-stream.browser.ts b/apps/mobile/src/features/browser/preview-stream.browser.ts index 07a9953cc5a2..91fa5d7a7085 100644 --- a/apps/mobile/src/features/browser/preview-stream.browser.ts +++ b/apps/mobile/src/features/browser/preview-stream.browser.ts @@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG = interface Viewer { readonly stop: () => void; readonly command: (input: PreviewStreamInput) => void; - readonly togglePictureInPicture: () => Promise; + readonly togglePictureInPicture: () => void; } let activeViewer: Viewer | null = null; @@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) { } export function pictureInPicture() { - void activeViewer?.togglePictureInPicture(); + activeViewer?.togglePictureInPicture(); } export function start(configuration: PreviewStreamConfiguration) { @@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) { }; const painter = createPreviewFramePainter(canvas, () => { if (!streaming && !stopped) reportStatus("streaming"); + sendPictureInPictureFrame(); }); const send = (message: PreviewStreamInput) => client?.send(message) ?? false; const clearInput = () => { @@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) { const pictureInPictureSupported = interactive && typeof canvas.captureStream === "function" && + typeof CanvasCaptureMediaStreamTrack === "function" && ((document.pictureInPictureEnabled === true && typeof videoPrototype.requestPictureInPicture === "function") || typeof videoPrototype.webkitSetPresentationMode === "function"); + // Since the iOS 17 SDK, WebKit revokes the activation it grants an injected script when + // the script returns, so the native button's request must start before any await. The + // video is ready ahead of the tap: paused, and fed a frame only when it has none or + // floats, so the inline view captures nothing. let video: PresentationVideo | null = null; + let track: CanvasCaptureMediaStreamTrack | null = null; const pictureInPictureActive = () => video !== null && (document.pictureInPictureElement === video || @@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) { active: pictureInPictureActive(), ...(detail ? { detail } : {}), }); - const ensureVideo = () => { - if (video) return video; + // Called after each paint. A frame drawn before the player attached is lost, so retry. + const sendPictureInPictureFrame = () => { + if (video && (video.readyState === HTMLMediaElement.HAVE_NOTHING || pictureInPictureActive())) { + track?.requestFrame(); + } + }; + if (pictureInPictureSupported) { + const stream = canvas.captureStream(0); + const [captured] = stream.getVideoTracks(); const element: PresentationVideo = document.createElement("video"); element.muted = true; element.playsInline = true; - element.autoplay = true; // Under the canvas at full size: WebKit pauses muted video it considers off screen. - element.srcObject = canvas.captureStream(); - // A static page sends no new frames; repaint once so the stream has one. - if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0); + element.srcObject = stream; for (const name of [ "enterpictureinpicture", "leavepictureinpicture", @@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) { }); } container.prepend(element); + // Covers the letterbox, where a stale frame of another shape would show through. + canvas.style.background = configuration.background; video = element; - return element; - }; - const togglePictureInPicture = async () => { - if (!pictureInPictureSupported) return; - try { - if (pictureInPictureActive()) { - if (document.pictureInPictureElement) await document.exitPictureInPicture(); - else video?.webkitSetPresentationMode?.("inline"); - return; - } - const element = ensureVideo(); - await element.play(); - if (document.pictureInPictureEnabled) await element.requestPictureInPicture(); - else element.webkitSetPresentationMode?.("picture-in-picture"); - } catch (error) { + track = captured instanceof CanvasCaptureMediaStreamTrack ? captured : null; + } + const togglePictureInPicture = () => { + const element = video; + if (!element) return; + if (pictureInPictureActive()) { + if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {}); + else element.webkitSetPresentationMode?.("inline"); + return; + } + if (element.readyState === HTMLMediaElement.HAVE_NOTHING) { + reportPictureInPicture("The page has not shown yet. Try again in a moment."); + return; + } + // The page may have changed since the video's last frame. WebKit captures only on a draw. + track?.requestFrame(); + canvas.getContext("2d")?.drawImage(canvas, 0, 0); + const fail = (error: unknown) => { + element.pause(); reportPictureInPicture( error instanceof Error ? error.message : "Picture in picture is unavailable.", ); + }; + if (document.pictureInPictureEnabled) { + const entering = element.requestPictureInPicture(); + void Promise.all([entering, element.play()]).catch(fail); + } else { + element.webkitSetPresentationMode?.("picture-in-picture"); + void element.play().catch(fail); } }; @@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) { painter.stop(); client?.stop(); client = null; - if (video) { - for (const track of video.srcObject instanceof MediaStream - ? video.srcObject.getTracks() - : []) { - track.stop(); - } - video.srcObject = null; - } + track?.stop(); + if (video) video.srcObject = null; }, command: (message) => { send(message); From 65d33177b777e014c56f01851bef446785b3aa28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:18:22 -0700 Subject: [PATCH 271/530] feat(source-control): GitCafe lives in @t3tools/source-control-gitcafe (#17681) Co-authored-by: Claude Opus 5.5 --- apps/server/package.json | 1 + .../SourceControlDiscovery.test.ts | 21 + .../SourceControlProviderRegistry.test.ts | 2 + .../src/sourceControl/builtInDrivers.ts | 5 + apps/web/src/components/CommandPalette.tsx | 20 +- apps/web/src/components/GitActionsControl.tsx | 12 +- apps/web/src/components/Icons.tsx | 8 + .../ThreadStatusIndicators.test.tsx | 22 + .../src/components/ThreadStatusIndicators.tsx | 23 +- .../pullRequest/pullRequestDetail.logic.ts | 2 + .../pullRequest/pullRequestLinkContextMenu.ts | 1 + .../settings/SourceControlSettings.tsx | 2 + apps/web/src/sourceControlPresentation.ts | 7 + apps/web/src/state/sourceControlActions.ts | 2 +- docs/user/source-control.md | 20 +- package.json | 2 +- packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/changeRequestUrl.ts | 7 + packages/shared/src/sourceControl.ts | 31 +- packages/source-control-gitcafe/package.json | 52 ++ .../src/server/GitCafeApi.ts | 176 ++++ .../src/server/GitCafeCredentials.test.ts | 155 ++++ .../src/server/GitCafeCredentials.ts | 166 ++++ .../server/GitCafePullRequestProvider.test.ts | 751 +++++++++++++++ .../src/server/GitCafePullRequestProvider.ts | 867 ++++++++++++++++++ .../GitCafeSourceControlProvider.test.ts | 339 +++++++ .../server/GitCafeSourceControlProvider.ts | 503 ++++++++++ .../src/server/driver.ts | 20 + .../src/server/gitCafeHosts.test.ts | 46 + .../src/server/gitCafeHosts.ts | 49 + .../src/server/gitCafePullRequestJson.test.ts | 547 +++++++++++ .../src/server/gitCafePullRequestJson.ts | 578 ++++++++++++ packages/source-control-gitcafe/tsconfig.json | 7 + pnpm-lock.yaml | 34 + scripts/release-smoke.ts | 1 + 35 files changed, 4463 insertions(+), 17 deletions(-) create mode 100644 packages/source-control-gitcafe/package.json create mode 100644 packages/source-control-gitcafe/src/server/GitCafeApi.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeCredentials.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts create mode 100644 packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts create mode 100644 packages/source-control-gitcafe/src/server/driver.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafeHosts.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts create mode 100644 packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts create mode 100644 packages/source-control-gitcafe/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 3dd02fcdcb5e..1147b5e06233 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -49,6 +49,7 @@ "@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:", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index b4503fb7535f..8fa9201b94ca 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -25,6 +25,7 @@ import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/Git 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 ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; @@ -53,6 +54,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( @@ -203,6 +205,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unknown", account: Option.none(), }, + { + kind: "gitcafe", + status: "missing", + auth: "unknown", + account: Option.none(), + }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); @@ -266,6 +274,13 @@ Logged in to gitlab.com as gitlab-user ) { return Effect.succeed(processOutput("azure-user@example.com\n")); } + if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { + return Effect.succeed( + processOutput( + encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), + ), + ); + } return Effect.fail( new VcsProcessSpawnError({ operation: input.operation, @@ -341,6 +356,12 @@ Logged in to gitlab.com as gitlab-user account: Option.some("forgejo-user"), detail: Option.none(), }, + { + kind: "gitcafe", + auth: "authenticated", + account: Option.some("cafe-user"), + detail: Option.none(), + }, ], ); }).pipe(Effect.provide(layerTest)); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bfed745ca295..1fab84a6acd0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -24,6 +24,7 @@ import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHub 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 SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -126,6 +127,7 @@ function makeRegistry(input: { Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), + Layer.mock(GitCafeApi.GitCafeApi)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 2e830dec803e..1b891ce9259a 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -15,6 +15,9 @@ import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/serv import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; +import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi"; import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; @@ -32,6 +35,7 @@ const drivers = [ AzureDevOpsDriver.driver, BitbucketDriver.driver, ForgejoDriver.driver, + GitCafeDriver.driver, ]; /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ @@ -51,5 +55,6 @@ export const layer = Layer.mergeAll( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, + GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37d80d33b7df..48c4b20a777f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -187,7 +187,14 @@ import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitCafeIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; @@ -252,7 +259,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -278,6 +285,7 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "forgejo", "bitbucket", "azure-devops", + "gitcafe", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", @@ -285,6 +293,7 @@ const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray; case "azure-devops": return ; + case "gitcafe": + return ; case "url": return ; } @@ -392,6 +406,7 @@ function buildAddProjectRemoteSourceReadiness( forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, + gitcafe: unavailable, }; if (!discovery) { @@ -2093,6 +2108,7 @@ function OpenCommandPaletteDialog(props: { "bitbucket", "azure", "devops", + "gitcafe", "url", "environment", ], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cfe45ff24cc4..2b684cd0cd4e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,7 @@ import { GitHubIcon, GitLabIcon, ForgejoIcon, + GitCafeIcon, } from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; @@ -158,7 +159,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe" >; type GitActionToastId = ReturnType; @@ -239,6 +240,14 @@ const PUBLISH_PROVIDER_OPTIONS = [ 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; @@ -565,6 +574,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { forgejo: null, bitbucket: null, "azure-devops": null, + gitcafe: null, }; for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { if (isPublishProviderKind(provider.kind)) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 3e4d53d55b63..909b9c7456f3 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -693,6 +693,14 @@ export const ForgejoIcon: Icon = (props) => ( ); +// GitCafe's own mark, from https://git.cafe/favicon.svg, inset so the full-bleed square does not +// read heavier than the round and cut-out marks beside it. +export const GitCafeIcon: Icon = (props) => ( + +); + // macOS System Settings > Privacy & Security pane marks, so the SnapShot setup // step points at the same icon the user is about to look for. export const MacScreenRecordingIcon: Icon = (props) => { diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2ad3ef64448e..3ae07b403063 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -79,4 +79,26 @@ describe("linked pull request snapshots", () => { sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, }); }); + it.each([ + ["https://git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"], + ["https://codeberg.org/owner/repo/pulls/42", "forgejo"], + ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"], + ["https://github.com/owner/repo/pull/42", "github"], + ])("classifies %s as %s", (url, kind) => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + url, + snapshot: { + state: "open", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result?.sourceControlProvider.kind).toBe(kind); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ad0c9c99a292..8368dbb0eac9 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,6 +6,7 @@ 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, @@ -129,15 +130,19 @@ export function linkedPullRequestSnapshotStatus( ): LinkedThreadPullRequestStatus | null { const snapshot = link.snapshot; if (snapshot === null) return null; - const kind = link.url.includes("/-/merge_requests/") - ? "gitlab" - : link.url.includes("/pullrequest/") - ? "azure-devops" - : link.url.includes("/pull-requests/") - ? "bitbucket" - : link.url.includes("/pulls/") - ? "forgejo" - : "github"; + // 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"; return { pr: { number: link.number, diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 00d3aafd2d2f..083f2c8db22a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -128,6 +128,8 @@ export function pullRequestCheckoutCommand( : null; case "azure-devops": return `az repos pr checkout --id ${number}`; + case "gitcafe": + return `cafe pr checkout ${number}`; case "bitbucket": { if ( !headRepositoryNameWithOwner || diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16c4fa5d3559..6b673ad98325 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -13,6 +13,7 @@ const OPEN_ON_HOST_LABELS: Partial> = { gitlab: "Open on GitLab", forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", + gitcafe: "Open on GitCafe", "azure-devops": "Open on Azure DevOps", }; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 978d7a57105d..68465b06d0fd 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -49,6 +49,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AzureDevOpsIcon, BitbucketIcon, + GitCafeIcon, GitHubIcon, GitIcon, GitLabIcon, @@ -83,6 +84,7 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 7a8d2cab8fe5..61a1befd6099 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -16,6 +16,7 @@ import { AzureDevOpsIcon, BitbucketIcon, ForgejoIcon, + GitCafeIcon, GitHubIcon, GitLabIcon, } from "./components/Icons"; @@ -62,6 +63,12 @@ export function getSourceControlPresentation( 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, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 23b44f7992bb..f760678136dc 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -275,7 +275,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" | "gitcafe"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 3730eb6a18d4..b0452974f1c1 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,7 +1,7 @@ # Source control -T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish -repositories, create pull requests, and review changes. +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, Azure DevOps, and GitCafe to clone +and publish repositories, create pull requests, and review changes. ## Review turn changes @@ -96,6 +96,22 @@ az extension add --name azure-devops az login ``` +### GitCafe + +Install the GitCafe CLI and sign in: + +```bash +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 +`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. +Merging a stack lands it through GitCafe, and updating a branch restacks the layers above it. + ## Start, clone, or publish a project To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or diff --git a/package.json b/package.json index ba161940d2f7..e21d895fd656 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 06934e93c740..c4d48e3ccd03 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -8,6 +8,7 @@ export const SourceControlProviderKind = Schema.Literals([ "forgejo", "azure-devops", "bitbucket", + "gitcafe", "unknown", ]); export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 5be8d355a166..265c31c157fb 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -48,6 +48,11 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); + // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n} + if (host === "git.cafe" || host === "staging.git.cafe") { + const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + return claim(host, match); + } // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); @@ -96,6 +101,8 @@ export function changeRequestUrlFor( switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; + case "gitcafe": + return `https://${host}/${repository}/pulls/${number}`; case "forgejo": { try { const remote = new URL(remoteUrl ?? ""); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index ddfa3bdf9b46..be09c017767e 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,14 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: + | "github" + | "gitlab" + | "forgejo" + | "azure-devops" + | "bitbucket" + | "gitcafe" + | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -79,6 +86,17 @@ const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", }; +const GITCAFE_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitcafe", + providerName: "GitCafe", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitCafe pull request", + checkoutCommandExample: "cafe pr checkout 123", + urlExample: "https://git.cafe/owner/repo/pulls/42", +}; + const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "change-request", providerName: "source control", @@ -104,6 +122,8 @@ export function resolveChangeRequestPresentation( return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "gitcafe": + return GITCAFE_CHANGE_REQUEST_PRESENTATION; case "unknown": return GENERIC_CHANGE_REQUEST_PRESENTATION; } @@ -205,6 +225,11 @@ function isAzureDevOpsHost(host: string): boolean { ); } +/** GitCafe has no self-hosted installs: production and staging are the only hosts. */ +function isGitCafeHost(host: string): boolean { + return host === "git.cafe" || host === "staging.git.cafe"; +} + function isBitbucketHost(host: string): boolean { return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } @@ -218,6 +243,10 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); + if (isGitCafeHost(hostname)) { + return { kind: "gitcafe", name: "GitCafe", baseUrl: toBaseUrl(hostname) }; + } + if ( hostname === "codeberg.org" || hasDnsLabel(hostname, "forgejo") || diff --git a/packages/source-control-gitcafe/package.json b/packages/source-control-gitcafe/package.json new file mode 100644 index 000000000000..eeda68c5e92d --- /dev/null +++ b/packages/source-control-gitcafe/package.json @@ -0,0 +1,52 @@ +{ + "name": "@t3tools/source-control-gitcafe", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/gitCafeHosts": { + "types": "./src/server/gitCafeHosts.ts", + "import": "./src/server/gitCafeHosts.ts" + }, + "./server/GitCafeApi": { + "types": "./src/server/GitCafeApi.ts", + "import": "./src/server/GitCafeApi.ts" + }, + "./server/GitCafeCredentials": { + "types": "./src/server/GitCafeCredentials.ts", + "import": "./src/server/GitCafeCredentials.ts" + }, + "./server/gitCafePullRequestJson": { + "types": "./src/server/gitCafePullRequestJson.ts", + "import": "./src/server/gitCafePullRequestJson.ts" + }, + "./server/GitCafePullRequestProvider": { + "types": "./src/server/GitCafePullRequestProvider.ts", + "import": "./src/server/GitCafePullRequestProvider.ts" + }, + "./server/GitCafeSourceControlProvider": { + "types": "./src/server/GitCafeSourceControlProvider.ts", + "import": "./src/server/GitCafeSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts new file mode 100644 index 000000000000..949ece637faa --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -0,0 +1,176 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +const NO_REDIRECT: RequestInit = { redirect: "manual" }; +const TIMEOUT = Duration.seconds(30); + +/** + * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token + * disables the provider, a limit pauses the host, and the rest belong to the one request. + */ +export const GitCafeApiFailure = Schema.Literals([ + "missing-tool", + "unauthenticated", + "rate-limited", + "not-found", + "failed", +]); + +export class GitCafeApiError extends Schema.TaggedError()("GitCafeApiError", { + host: Schema.String, + operation: Schema.String, + /** GitCafe's HTTP status; null when no answer arrived. */ + status: Schema.NullOr(Schema.Int), + reason: GitCafeApiFailure, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `GitCafe ${this.operation} failed on ${this.host}: ${this.detail}`; + } +} + +export interface GitCafeApiRequest { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */ + readonly path: string; + readonly body?: unknown; +} + +/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */ +export class GitCafeApi extends Context.Service< + GitCafeApi, + { + /** The body of a successful answer. */ + readonly request: (input: GitCafeApiRequest) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeApi") {} + +/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */ +const decodeProblem = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + title: Schema.optional(Schema.String), + detail: Schema.optional(Schema.String), + }), + ), +); + +const reasonForStatus = (status: number): typeof GitCafeApiFailure.Type => + status === 401 + ? "unauthenticated" + : status === 429 + ? "rate-limited" + : status === 404 + ? "not-found" + : "failed"; + +const MAX_PROBLEM_TEXT = 300; +const boundedProblemText = (text: string | undefined) => { + const line = text?.replace(/\s+/gu, " ").trim(); + if (!line) return undefined; + return line.length > MAX_PROBLEM_TEXT ? `${line.slice(0, MAX_PROBLEM_TEXT)}โ€ฆ` : line; +}; + +const credentialFailure = ( + error: GitCafeCredentials.GitCafeCredentialUnavailableError, +): typeof GitCafeApiFailure.Type => + error._tag === "GitCafeCliMissingError" + ? "missing-tool" + : error._tag === "GitCafeNotSignedInError" + ? "unauthenticated" + : "failed"; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + + const request = Effect.fn("GitCafeApi.request")(function* (input: GitCafeApiRequest) { + const fail = ( + fields: Pick & { cause?: unknown }, + ) => + new GitCafeApiError({ + host: input.host, + operation: input.operation, + status: fields.status, + reason: fields.reason, + detail: fields.detail, + ...(fields.cause === undefined ? {} : { cause: fields.cause }), + }); + // The host comes from a remote URL, so a token never leaves for one GitCafe does not own. + const host = GitCafeHosts.gitCafeHost(input.host); + if (host === null) + return yield* fail({ + status: null, + reason: "failed", + detail: `GitCafe lives on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}, not ${input.host}.`, + }); + yield* Effect.annotateCurrentSpan({ + "gitcafe.host": host, + "gitcafe.operation": input.operation, + "http.request.method": input.method ?? "GET", + }); + const credential = yield* credentials + .get(host) + .pipe( + Effect.mapError((cause) => + fail({ status: null, reason: credentialFailure(cause), detail: cause.message, cause }), + ), + ); + const base = HttpClientRequest.make(input.method ?? "GET")( + `https://${host}/api${input.path}`, + ).pipe( + HttpClientRequest.bearerToken(Redacted.value(credential.token)), + HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), + ); + const { status, text } = yield* Effect.gen(function* () { + const response = yield* httpClient + .execute( + input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), + ) + .pipe( + // The client's own span would record the query string, which can carry branch names. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + // fetch would follow a redirect itself and keep the bearer token on a same-origin hop; + // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure. + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + ); + return { status: response.status, text: yield* response.text }; + }).pipe( + Effect.timeout(TIMEOUT), + Effect.mapError((cause) => + fail({ status: null, reason: "failed", detail: "Could not reach GitCafe.", cause }), + ), + ); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": status }); + if (status >= 200 && status < 300) return text; + // The source may hold a newer token than the one that was refused. + if (status === 401) yield* credentials.invalidate(host); + const problem = Option.getOrUndefined(decodeProblem(text)); + return yield* fail({ + status, + reason: reasonForStatus(status), + // GitCafe's own explanation is what the user acts on; it is kept short and single-line. + detail: + boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`, + }); + }); + + return GitCafeApi.of({ request }); +}); + +export const layer = Layer.effect(GitCafeApi, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts new file mode 100644 index 000000000000..f0f8be74711f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -0,0 +1,155 @@ +/** + * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates: + * + * - `class GitCafeCredentials extends Context.Service` keyed + * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with + * `get(host) => Effect` and + * `invalidate(host) => Effect`. + * - `interface GitCafeCredential { host; token: Redacted; source: "env" | "cafe" }`. + * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`, + * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`). + * - `environmentToken(host, env): string | null` โ€” `CAFE_TOKEN` only for the host `CAFE_HOST` + * names (git.cafe when unset). + * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and + * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; + +const processOutput = TestSourceControlHost.processOutput; + +function harness( + run: SourceControlHost.SourceControlHost["Service"]["process"]["run"], + env: NodeJS.ProcessEnv = {}, +) { + const calls: Array = []; + const layer = GitCafeCredentials.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(input); + return run(input); + }, + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, env)), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + ); + return { layer, calls }; +} + +describe("GitCafeCredentials", () => { + it("hands CAFE_TOKEN only to the host cafe itself targets", () => { + const token = { CAFE_TOKEN: "env-token" }; + assert.strictEqual(GitCafeCredentials.environmentToken("git.cafe", token), "env-token"); + assert.isNull(GitCafeCredentials.environmentToken("staging.git.cafe", token)); + for (const CAFE_HOST of ["https://staging.git.cafe/api", "staging.git.cafe"]) { + const staging = { ...token, CAFE_HOST }; + assert.strictEqual( + GitCafeCredentials.environmentToken("staging.git.cafe", staging), + "env-token", + ); + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", staging)); + } + assert.isNull(GitCafeCredentials.environmentToken("git.cafe", { CAFE_TOKEN: " " })); + }); + + it.effect("prefers CAFE_TOKEN without running cafe", () => { + const { layer, calls } = harness(() => Effect.die("cafe must not run"), { + CAFE_TOKEN: "env-token", + }); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const credential = yield* credentials.get("git.cafe"); + assert.strictEqual(Redacted.value(credential.token), "env-token"); + assert.strictEqual(credential.source, "env"); + assert.deepStrictEqual(calls, []); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks cafe's Git credential helper over stdin and reuses the token", () => { + const { layer, calls } = harness(() => + Effect.succeed( + processOutput("protocol=https\nhost=staging.git.cafe\nusername=alice\npassword=gct_1\n"), + ), + ); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + // A token for staging is never taken from an environment that targets production. + const credential = yield* credentials.get("Staging.Git.Cafe"); + assert.strictEqual(Redacted.value(credential.token), "gct_1"); + assert.strictEqual(credential.source, "cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0]?.command, "cafe"); + assert.deepStrictEqual(calls[0]?.args, [ + "auth", + "http", + "credential", + "--host", + "https://staging.git.cafe/api", + "get", + ]); + assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n"); + // A refused token is asked for again rather than reused. + yield* credentials.invalidate("staging.git.cafe"); + yield* credentials.get("staging.git.cafe"); + assert.strictEqual(calls.length, 2); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each([ + [ + "a missing CLI", + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + "GitCafeCliMissingError", + ], + [ + "a helper with no login", + Effect.fail( + new VcsProcessExitError({ + operation: "GitCafeCredentials.get", + command: "cafe", + cwd: "/server", + exitCode: 1, + detail: "not signed in", + }), + ), + "GitCafeNotSignedInError", + ], + [ + "an answer without a password", + Effect.succeed(processOutput("username=alice\n")), + "GitCafeNotSignedInError", + ], + ] as const)("reports %s", ([, result, tag]) => { + const { layer } = harness(() => result); + return Effect.gen(function* () { + const credentials = yield* GitCafeCredentials.GitCafeCredentials; + const error = yield* credentials.get("git.cafe").pipe(Effect.flip); + assert.strictEqual(error._tag, tag); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts new file mode 100644 index 000000000000..c47e9ce1153f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -0,0 +1,166 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +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"; + +/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export interface GitCafeCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: "env" | "cafe"; +} + +/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ +export class GitCafeCliMissingError extends Schema.TaggedError()( + "GitCafeCliMissingError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: set CAFE_TOKEN, or install the GitCafe CLI and run \`cafe auth login\`.`; + } +} + +/** `cafe` is installed but holds no login for the host. */ +export class GitCafeNotSignedInError extends Schema.TaggedError()( + "GitCafeNotSignedInError", + { host: Schema.String }, +) { + override get message(): string { + return `No GitCafe credential for ${this.host}: run \`cafe auth login --host https://${this.host}/api\`.`; + } +} + +/** `cafe` timed out or failed for a reason other than having no login. */ +export class GitCafeCliFailedError extends Schema.TaggedError()( + "GitCafeCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitCafe CLI could not hand over a credential for ${this.host}. Check \`cafe auth status\` on the server.`; + } +} + +export type GitCafeCredentialUnavailableError = + | GitCafeCliMissingError + | GitCafeNotSignedInError + | GitCafeCliFailedError; + +/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */ +export class GitCafeCredentials extends Context.Service< + GitCafeCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} + +const normalizeHost = (host: string) => host.trim().toLowerCase(); + +/** + * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by + * default): a remote URL picks the host, and must not steer a production token to staging. + */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const configured = env.CAFE_HOST?.trim(); + const configuredHost = configured + ? URL.parse(configured.includes("://") ? configured : `https://${configured}`)?.host + : "git.cafe"; + if (configuredHost?.toLowerCase() !== normalizeHost(host)) return null; + return env.CAFE_TOKEN?.trim() || null; +} + +/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */ +function credentialPassword(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + if (line.startsWith("password=")) return line.slice("password=".length).trim() || null; + } + return null; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + const environment = yield* HostProcess.Environment; + const workingDirectory = yield* HostProcess.WorkingDirectory; + + /** + * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand + * the stored login to another program, and answers over stdin/stdout so the token never + * reaches an argument list or a log. + */ + const fromCafe = (host: string) => + process + .run({ + operation: "GitCafeCredentials.get", + command: "cafe", + args: ["auth", "http", "credential", "--host", `https://${host}/api`, "get"], + cwd: workingDirectory, + stdin: `protocol=https\nhost=${host}\n\n`, + env: { CAFE_NO_UPDATE_CHECK: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.mapError((error) => + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitCafeCliMissingError({ host }) + : error._tag === "VcsProcessExitError" + ? new GitCafeNotSignedInError({ host }) + : new GitCafeCliFailedError({ host, cause: error }), + ), + Effect.map((output) => credentialPassword(output.stdout)), + Effect.filterOrFail( + (token): token is string => token !== null, + () => new GitCafeNotSignedInError({ host }), + ), + ); + + const lookup = Effect.fn("GitCafeCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, environment); + const token = fromEnv ?? (yield* fromCafe(host)); + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "cafe", + } satisfies GitCafeCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 4, + // A transient failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitCafeCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, + }); + + return GitCafeCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitCafeCredentials, make); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts new file mode 100644 index 000000000000..0603dfec80ec --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -0,0 +1,751 @@ +/** + * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer + * supports. Pass 2 creates: + * + * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`) + * with `request({ host, operation, method?, path, body? }) => Effect` + * (the response body), sending `https:///api` with the `GitCafeCredentials` bearer + * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`. + * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited. + * - `./GitCafePullRequestProvider.ts`: `make: Effect` + * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment, + * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by + * `expectedStackHeads`. + * + * Every write carries a fresh `requestId`, which GitCafe requires. + * + * Out of scope here (layer 3): review-revision fencing, async outcome polling. + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +interface SentRequest { + readonly method: string; + /** The path below `/api`, without its query. */ + readonly path: string; + readonly query: URLSearchParams; + readonly host: string; + readonly body: unknown; + /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */ + readonly redirect: string | undefined; +} + +/** + * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404 + * problem document. Every request is recorded in order. + */ +function fakeGitCafe( + routes: Record unknown)>, + options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {}, +) { + const sent: Array = []; + const client = HttpClient.make( + (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => { + const url = new URL(request.url); + assert.isTrue(url.pathname.startsWith("/api/"), url.pathname); + assert.strictEqual(request.headers.authorization, "Bearer env-token"); + const entry: SentRequest = { + method: request.method, + path: url.pathname.slice("/api".length), + query: url.searchParams, + host: url.host, + body: + request.body._tag === "Uint8Array" + ? decodeJson(new TextDecoder().decode(request.body.body)) + : undefined, + redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect, + }; + sent.push(entry); + const route = routes[`${entry.method} ${entry.path}`]; + const response = + route === undefined + ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 }) + : new Response(encodeJson(typeof route === "function" ? route(entry) : route), { + status: options.status ?? 200, + }); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }, + ); + const layer = GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provide(TestSourceControlHost.layer()), + // An environment token, so no test runs `cafe`. + Layer.provide( + Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ); + const writes = () => + sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`); + return { layer, sent, writes }; +} + +const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 }; +const timestamp = "2026-09-12T12:00:00Z"; +const headOid = "0123456789abcdef0123456789abcdef01234567"; +const baseOid = "89abcdef0123456789abcdef0123456789abcdef"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid, + observedBaseOid: baseOid, + mergeRoute: "provider", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 4, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const root = { + id: "root", + threadId: "root", + version: 2, + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: headOid, + resolvedAt: null, + capabilities: { + edit: true, + hide: false, + unhide: false, + delete: false, + resolve: true, + unresolve: false, + }, +}; +/** + * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged + * layer's head comes from its own pull. + */ +const stack = ( + members: ReadonlyArray<{ number: number; headOid: string; state?: string }>, +): Record => ({ + "GET /repos/owner/repo/pulls/7/stack": { + stack: { + id: "stack-one", + number: 3, + revision: 5, + landingBase: "main", + members: members.map((member, index) => ({ + pullRequestNumber: member.number, + title: `Layer ${member.number}`, + state: member.state ?? "open", + draft: false, + sourceBranch: `layer-${member.number}`, + position: index + 1, + })), + }, + }, + ...Object.fromEntries( + members.map((member) => [ + `GET /repos/owner/repo/pulls/${member.number}`, + { ...pull, number: member.number, headOid: member.headOid }, + ]), + ), +}); + +/** GitCafe refuses a write without an idempotency key. */ +const assertRequestId = (body: unknown) => + assert.match( + (body as { readonly requestId?: unknown } | undefined)?.requestId as string, + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ); + +describe("GitCafePullRequestProvider", () => { + it.effect("lists a repository's pull requests from its pulls collection", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "all", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual( + page.items.map((item) => item.number), + [7], + ); + assert.deepStrictEqual( + server.sent.map((request) => `${request.method} ${request.path}`), + ["GET /repos/owner/repo/pulls"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps a staging repository's requests and links on staging", () => { + // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7": pull }, + { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const summary = yield* provider.getChangeRequestSummary!({ + ...target, + host: "staging.git.cafe", + }); + assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe")); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .getChangeRequest({ ...target, host: "github.com" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [401, "unauthenticated"], + [429, "rate-limited"], + ] as const) { + const server = fakeGitCafe( + { "GET /repos/owner/repo/pulls/7/stack": { stack: null } }, + { + status, + }, + ); + const error = yield* GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.flip, + Effect.provide(server.layer), + ); + assert.strictEqual(error.reason, reason); + } + }), + ); + + it.effect("never lets fetch follow a redirect with the token", () => { + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } }); + return GitCafePullRequestProvider.make.pipe( + Effect.flatMap((provider) => provider.getChangeRequestStack!(target)), + Effect.tap(() => + Effect.sync(() => + assert.deepStrictEqual( + server.sent.map((r) => r.redirect), + ["manual"], + ), + ), + ), + Effect.provide(server.layer), + ); + }); + + it.effect("reads the stack a pull request belongs to, with each open layer's head", () => { + const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([ + { number: 6, headOid: baseOid, state: "merged" }, + { number: 7, headOid }, + ]); + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getChangeRequestStack!(target); + assert.strictEqual(result?.number, 3); + // A merged layer is not fenced on, so its pull is never read. + assert.deepStrictEqual( + result?.layers.map((layer) => [layer.number, layer.headSha]), + [ + [6, undefined], + [7, headOid], + ], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads activity with the host's edit and resolve permissions", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/commits": { + items: [], + truncated: false, + next: null, + headOid, + }, + "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const activity = yield* provider.getChangeRequestActivity(target); + assert.strictEqual(activity.comments[0]?.canEdit, true); + assert.strictEqual(activity.reviewThreads[0]?.canResolve, true); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the viewer's permission to edit the pull request", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { + ...pull, + capabilities: { ...pull.capabilities, edit: false }, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const permissions = yield* provider.getViewerPermissions(target); + assert.strictEqual(permissions.editChangeRequest, false); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("reads the diff from the pull request's changed files", () => { + const file = { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + }; + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": { + ...snapshot, + items: [ + { + ...file, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const diff = yield* provider.getDiff(target); + assert.include(diff.patch, "-old\n+new\n"); + assert.isNull(diff.nextCursor); + // The batched hunk read is a POST, but it writes nothing. + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("comments and replies to a thread on the pull request's comments", () => { + const written = { id: "c1", threadId: "root", version: 1 }; + const server = fakeGitCafe({ + "POST /repos/owner/repo/pulls/7/comments/": written, + "POST /repos/owner/repo/pulls/7/comments/root/replies": written, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.comment({ ...target, body: "Hello" }); + yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/", + "POST /repos/owner/repo/pulls/7/comments/root/replies", + ]); + assert.deepStrictEqual( + server.sent.map((request) => request.body), + [{ body: "Hello" }, { body: "Reply" }], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("resolves a thread through its root at the root's version", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null }, + "POST /repos/owner/repo/pulls/7/comments/root/resolve": { + id: "root", + threadId: "root", + version: 3, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/7/comments/root/resolve", + ]); + assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("submits a review against the pull request's current head", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.submitReview({ + ...target, + verdict: "request-changes", + body: "Please fix", + comments: [], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + verdict: "request_changes", + body: "Please fix", + commitOid: headOid, + }); + assertRequestId(server.sent.at(-1)?.body); + + // GitCafe rejects a null body; an empty review leaves it out. + yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] }); + assert.notProperty(server.sent.at(-1)?.body as object, "body"); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["completed", true], + ["failed", false], + ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .runAction({ ...target, action: "merge", mergeMethod: "squash" }) + .pipe(Effect.result); + assert.strictEqual(result._tag, succeeds ? "Success" : "Failure"); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + strategy: "squash", + headOid, + expectedVersion: 4, + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "lands a stack through a layer only while its heads are the ones the reader saw", + () => { + const layers = [ + { number: 6, headOid: baseOid }, + { number: 7, headOid }, + ]; + const server = fakeGitCafe({ + ...stack(layers), + "POST /repos/owner/repo/pulls/stacks/3/land-through": { + id: "land-1", + state: "completed", + landedCount: 2, + stepCount: 2, + stopReason: null, + error: null, + steps: [], + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid })); + const stale = yield* provider + .runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }], + }) + .pipe(Effect.result); + assert.strictEqual(stale._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + + yield* provider.runAction({ + ...target, + action: "merge", + stackNumber: 3, + expectedStackHeads: heads, + mergeMethod: "merge", + }); + assert.deepStrictEqual(server.writes(), [ + "POST /repos/owner/repo/pulls/stacks/3/land-through", + ]); + assert.deepInclude(server.sent.at(-1)?.body as object, { + expectedRevision: 5, + throughPullRequestNumber: 7, + strategy: "merge", + }); + assertRequestId(server.sent.at(-1)?.body); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("restacks from a layer on update-branch", () => { + const server = fakeGitCafe({ + ...stack([{ number: 7, headOid }]), + "POST /repos/owner/repo/pulls/stacks/3/restack": { + id: "restack-1", + state: "completed", + stepCount: 1, + completedStepCount: 1, + pauseReason: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ + ...target, + action: "update-branch", + updateMethod: "rebase", + stackNumber: 3, + expectedStackHeads: [{ number: 7, headSha: headOid }], + }); + assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]); + const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown }; + assertRequestId({ requestId }); + assert.deepStrictEqual(body, { expectedRevision: 5 }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + ["authored", "authors"], + ["reviewing", "reviewers"], + ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { + actors: [{ actorId: "act_one", handle: "Alice" }], + }, + "GET /repos/owner/repo/pulls": { items: [pull], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]'); + assert.strictEqual( + page.items[0]?.reviewRequestLogins.includes("alice"), + involvement === "reviewing", + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("lists nothing for a viewer GitCafe has no actor for", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/filter-options": { actors: [] }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement: "authored", + viewer: "alice", + limit: 10, + }); + assert.deepStrictEqual(page.items, []); + assert.deepStrictEqual( + server.sent.map((request) => request.path), + ["/repos/owner/repo/filter-options"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["authored", "reviewing"] as const)( + "lists %s unnarrowed when GitCafe can't say who the viewer is", + (involvement) => { + // No `filter-options` route: the fake answers 404, which is an ordinary failure. + const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const page = yield* provider.listChangeRequests({ + ...target, + state: "open", + involvement, + viewer: "alice", + limit: 10, + }); + const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls"); + assert.isDefined(listing); + assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors")); + // An unnarrowed row says nothing about who was asked to review it. + assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect("reads line counts, open merge strategies and requested reviewers", () => { + const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid }; + const files = (from: number, count: number) => + Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` })); + let changePage = 0; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] }, + "GET /repos/owner/repo/pulls/7/status": { + merge: { + conflicts: "unknown", + fastForward: true, + strategies: ["merge", "squash", "rebase"], + blockers: [{ blockedStrategies: ["squash"] }, {}], + }, + checks: { pending: 0, failing: 0, total: 0 }, + }, + // Checks continue on a cursor too; a failure on a later page must not be dropped. + [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({ + items: [ + { + name: request.query.has("after") ? "late" : "early", + status: "completed", + conclusion: request.query.has("after") ? "failure" : "success", + summary: null, + detailsUrl: null, + }, + ], + next: request.query.has("after") ? null : "checks-1", + }), + // `/changes` counts no lines, and a pull past one page continues on a cursor. + "GET /repos/owner/repo/pulls/7/changes": () => + changePage++ === 0 + ? { ...snapshot, items: files(0, 2), next: "cursor-1" } + : { ...snapshot, items: files(2, 1), next: null }, + "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({ + ...snapshot, + items: (request.body as { paths: ReadonlyArray }).paths.map((path) => ({ + path, + additions: 2, + deletions: 1, + })), + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const detail = yield* provider.getChangeRequest(target); + assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 }); + assert.strictEqual( + server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"), + "cursor-1", + ); + assert.deepStrictEqual(detail.mergeCapabilities, { + merge: true, + squash: false, + rebase: true, + }); + assert.deepStrictEqual( + detail.checks.map((check) => check.name), + ["early", "late"], + ); + // A requested reviewer shows before they have reviewed. + assert.deepStrictEqual( + detail.reviewers.map((reviewer) => reviewer.login), + ["alice"], + ); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("fences a native merge on the target branch's current commit", () => { + const branchOid = "fedcba9876543210fedcba9876543210fedcba98"; + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null }, + "GET /repos/owner/repo/commit": { oid: branchOid }, + "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + yield* provider.runAction({ ...target, action: "merge" }); + const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit"); + assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main"); + assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("refuses to drop a reaction whose id it cannot address", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7/reactions/": { + items: [ + { + subject: { kind: "pull_request_comment", id: "root" }, + emoji: { kind: "unicode", value: "๐Ÿš€" }, + count: 1, + viewerReactionId: "../escape", + reactors: [actor], + }, + ], + next: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider + .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("keeps GitCafe's explanation short and on one line", () => { + const server = fakeGitCafe( + { + "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` }, + }, + { status: 409 }, + ); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getChangeRequest(target).pipe(Effect.flip); + assert.notInclude(error.detail, "\n"); + assert.isAtMost(error.detail.length, 310); + assert.isTrue(error.detail.startsWith("Line x")); + }).pipe(Effect.provide(server.layer)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts new file mode 100644 index 000000000000..7ea6a1e6eb87 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -0,0 +1,867 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; +import { + NonNegativeInt, + TrimmedNonEmptyString, + type PullRequestCapabilities, + type PullRequestStackHead, +} from "@t3tools/contracts"; +import { + PullRequestProviderError, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeHosts from "./gitCafeHosts.ts"; +import * as Json from "./gitCafePullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["ready", "draft", "close", "reopen", "merge", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + // Only through a stack: GitCafe's one branch update restacks every layer above it. + updateMethods: ["rebase"], + search: true, + stacks: true, + stackActions: true, + reactions: true, + labels: false, + // Line comments go through GitCafe's review drafts, which arrive with revision fencing. + review: { + inlineComment: false, + reply: true, + resolve: true, + verdicts: ["approve", "request-changes", "comment"], + }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: true }, +}; + +const PAGE_SIZE = 100; +/** Conversation pages read before the list is reported as truncated. */ +const MAX_PAGES = 10; +/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ +const DIFF_FILES_BATCH = 64; +/** + * Line totals cost one hunk read per batch, so a pull request larger than this reports none + * rather than reading every file. + */ +const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; +/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */ +const MAX_CHANGE_PAGES = 20; + +const chunk = (items: ReadonlyArray, size: number): Array> => + Array.from({ length: Math.ceil(items.length / size) }, (_, index) => + items.slice(index * size, (index + 1) * size), + ); + +/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */ +const detailablePaths = (files: ReadonlyArray) => + files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path); + +/** + * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without + * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows + * GitCafe's own reason. + */ +const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) => + status.merge.permitted !== false && + status.merge.strategies.includes(strategy) && + !(status.merge.blockers ?? []).some((blocker) => + (blocker.blockedStrategies ?? []).includes(strategy), + ); + +const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u)); +const isId = Schema.is(Id); +const Principal = Schema.Struct({ handle: TrimmedNonEmptyString }); +const Written = Schema.Struct({ id: Id }); +const Versioned = Schema.Struct({ version: NonNegativeInt }); +const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt }); +const Status = Schema.Struct({ + merge: Schema.Struct({ + conflicts: Schema.Literals(["unknown", "conflicting"]), + fastForward: Schema.NullOr(Schema.Boolean), + strategies: Schema.Array(Schema.String), + permitted: Schema.optional(Schema.Boolean), + blockers: Schema.optional( + Schema.Array( + Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }), + ), + ), + }), + checks: Schema.Struct({ + pending: NonNegativeInt, + failing: NonNegativeInt, + total: NonNegativeInt, + }), +}); +const FilterOptions = Schema.Struct({ + actors: Schema.Array( + Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }), + ), +}); +const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); +const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const Snapshot = { + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + comparisonBaseOid: TrimmedNonEmptyString, +}; +const Changes = Schema.Struct({ + ...Snapshot, + items: Json.GitCafeDiffFiles, + next: Schema.NullOr(TrimmedNonEmptyString), +}); +const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles }); +const DiffCursor = Schema.Struct({ + version: NonNegativeInt, + headOid: TrimmedNonEmptyString, + baseOid: TrimmedNonEmptyString, + after: TrimmedNonEmptyString, +}); +const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor)); +const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor)); +const MergeOutcome = Schema.Struct({ + id: Schema.String, + state: Schema.Literals(["accepted", "completed", "failed"]), + reason: Schema.optional(Schema.String), +}); +const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String }); +/** Where a stack operation stopped short; anything else is underway or done. */ +const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]); + +type PullRef = ProviderRepositoryRef & { readonly number: number }; +type Paged = { readonly items: ReadonlyArray; readonly next: string | null }; + +/** The heads a reader saw are still every unmerged layer the action moves. */ +function stackHeadsCurrent( + stack: Json.GitCafeStack, + input: { readonly number: number; readonly merge: boolean }, + expected: ReadonlyArray, +): boolean { + const members = stack.members.toSorted((a, b) => a.position - b.position); + const index = members.findIndex((member) => member.pullRequestNumber === input.number); + if (index === -1) return false; + const moved = (input.merge ? members.slice(0, index + 1) : members).filter( + (member) => member.state !== "merged", + ); + return ( + moved.length === expected.length && + moved.every( + (member) => + member.headOid !== undefined && + expected.some( + (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid, + ), + ) + ); +} + +/** Lays the detailed files over the structural page, keeping its order and anything omitted. */ +const withDetails = ( + files: ReadonlyArray, + details: ReadonlyArray, +) => { + const byPath = new Map(details.map((file) => [file.path, file])); + return files.map((file) => byPath.get(file.path) ?? file); +}; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + + const failure = (operation: string, detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + + /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */ + const repositoryPath = ( + operation: string, + input: ProviderRepositoryRef, + ): Effect.Effect => + GitCafeHosts.isGitCafeHost(input.host) && + GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository + ? Effect.succeed(`/repos/${input.repository}`) + : Effect.fail( + failure( + operation, + `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`, + ), + ); + + const send = ( + operation: string, + input: Pick, + request: Omit, + ) => + api.request({ ...request, host: input.host, operation }).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "gitcafe", + operation, + reason: error.reason, + detail: error.detail, + cause: error, + }), + ), + ); + + const read = >( + operation: string, + input: Pick, + request: Omit, + schema: S, + ) => + send(operation, input, request).pipe( + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + failure(operation, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + + const pullPath = (operation: string, input: PullRef) => + repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`)); + + const readPull = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), + ); + + /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */ + const readPages = , unknown, never, never>>( + operation: string, + input: Pick, + path: string, + schema: S, + ) => + Effect.gen(function* () { + const items: Array = []; + let next: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (next !== null) query.set("after", next); + const batch: S["Type"] = yield* read( + operation, + input, + { path: `${path}?${query}` }, + schema, + ); + items.push(...batch.items); + const previous: string | null = next; + next = batch.next; + if (next === null || next === previous || batch.items.length === 0) break; + } + return { items, next }; + }); + + const readComments = (operation: string, input: PullRef) => + pullPath(operation, input).pipe( + Effect.flatMap((path) => + readPages(operation, input, `${path}/comments`, Json.GitCafeComments), + ), + ); + + /** + * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no + * heads, and they are what a land or restack is fenced on, so each open layer's pull is read. + */ + const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* ( + operation: string, + input: PullRef, + ) { + const path = yield* pullPath(operation, input); + const { stack } = yield* read( + operation, + input, + { path: `${path}/stack` }, + Json.GitCafeStackEnvelope, + ); + if (stack === null) return null; + const members = yield* Effect.forEach( + stack.members, + (member) => + member.state === "merged" + ? Effect.succeed(member) + : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe( + Effect.map((pull) => + pull.headOid == null ? member : { ...member, headOid: pull.headOid }, + ), + ), + { concurrency: 4 }, + ); + return { ...stack, members }; + }); + + /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */ + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( + input: PullRef, + revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, + paths: ReadonlyArray, + ) { + if (paths.length === 0) return []; + const path = yield* pullPath("getDiff", input); + const files = yield* read( + "getDiff", + input, + { + method: "POST", + path: `${path}/diff-files`, + body: { paths, expectedVersion: revision.version }, + }, + DiffFiles, + ); + if ( + files.version !== revision.version || + files.headOid !== revision.headOid || + files.comparisonBaseOid !== revision.baseOid + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + return files.items; + }); + + const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* ( + input: Parameters[0] & { readonly stackNumber: number }, + ) { + const land = input.action === "merge"; + const operation = land ? "landStackThrough" : "restackStack"; + if (!land && input.action !== "update-branch") + return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`); + const stack = yield* readStack(operation, input); + if ( + stack === null || + stack.number !== input.stackNumber || + input.expectedStackHeads === undefined || + !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads) + ) + return yield* failure( + operation, + "The stack changed since it was read. Refresh it and try again.", + ); + const base = yield* repositoryPath(operation, input); + const outcome = yield* read( + operation, + input, + { + method: "POST", + path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`, + body: land + ? { + expectedRevision: stack.revision, + requestId: yield* randomUuidV4, + throughPullRequestNumber: input.number, + strategy: input.mergeMethod ?? "merge", + } + : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 }, + }, + StackOutcome, + ); + if (FAILED_STACK_STATES.has(outcome.state)) + return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`); + }); + + /** + * Added and deleted line totals and the file count. `/changes` lists files without counting + * lines, so the counts come from batched hunk reads, and a pull request past the cap reports + * none rather than paying for every file. + */ + const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( + input: PullRef, + version: number, + ) { + const path = yield* pullPath("getChangeRequest", input); + const files: Array = []; + let revision: { version: number; headOid: string; baseOid: string } | undefined; + let after: string | null = null; + for (let page = 0; page < MAX_CHANGE_PAGES; page++) { + const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + if (after !== null) query.set("after", after); + const batch: typeof Changes.Type = yield* read( + "getChangeRequest", + input, + { path: `${path}/changes?${query}` }, + Changes, + ); + revision ??= { + version: batch.version, + headOid: batch.headOid, + baseOid: batch.comparisonBaseOid, + }; + files.push(...batch.items); + const previous: string | null = after; + after = batch.next; + if (after === null || after === previous || batch.items.length === 0) break; + } + // A hunk read that loses a race with a push leaves the counts at zero rather than dropping + // the file count with them. + const counted = + files.some((file) => file.additions !== undefined) || + files.length > LINE_STATS_MAX_FILES || + revision === undefined + ? files + : (yield* Effect.forEach( + chunk(detailablePaths(files), DIFF_FILES_BATCH), + (paths) => readDiffFiles(input, revision, paths), + { concurrency: 4 }, + ).pipe(Effect.orElseSucceed(() => [[]]))).flat(); + return { + changedFiles: files.length, + additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0), + deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0), + }; + }); + + const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* ( + input: Parameters[0], + ) { + const pull = yield* readPull("merge", input); + if (pull.state === "merged") return; + if (pull.mergeRoute === "unsupported") + return yield* failure("merge", "This GitCafe pull request has no supported merge route."); + const base = yield* repositoryPath("merge", input); + // GitCafe observes the base only for merges it routes to the provider; a native merge is + // fenced on the target branch's current commit. + const baseOid = + pull.mergeRoute === "provider" + ? pull.observedBaseOid + : (yield* read( + "merge", + input, + { + path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`, + }, + BranchCommit, + )).oid; + if (pull.headOid === null || baseOid == null) + return yield* failure( + "merge", + "GitCafe has not reported the pull request's head and base yet. Refresh and try again.", + ); + const path = yield* pullPath("merge", input); + const outcome = yield* read( + "merge", + input, + { + method: "POST", + path: `${path}/merge`, + body: { + requestId: yield* randomUuidV4, + expectedVersion: pull.version, + headOid: pull.headOid, + baseOid, + strategy: input.mergeMethod ?? "merge", + }, + }, + MergeOutcome, + ); + // An accepted merge is GitCafe's to finish; the pull request's state reports when it has. + if (outcome.state === "failed") + return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request."); + }); + + const provider: PullRequestProviderApi = { + kind: "gitcafe", + capabilities: CAPABILITIES, + getViewer: (input) => + read( + "getViewer", + { host: input.host ?? "git.cafe" }, + { path: "/auth/principal" }, + Principal, + ).pipe(Effect.map((principal) => principal.handle)), + listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")( + function* (input) { + const base = yield* repositoryPath("listChangeRequests", input); + const involved = input.involvement === "authored" || input.involvement === "reviewing"; + let actorId: string | undefined; + if (involved) { + // A host narrows as far as it can: when the actor list can't be read, the listing comes + // back unnarrowed and the page's own involvement filter does the rest. A refused token + // or a rate limit still fails, since the service acts on those for the whole host. + const options = yield* read( + "listChangeRequests", + input, + { path: `${base}/filter-options` }, + FilterOptions, + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PullRequestProviderError: (error) => + error.reason === "unauthenticated" || error.reason === "rate-limited" + ? Effect.fail(error) + : Effect.succeed(Option.none()), + }), + ); + if (Option.isSome(options)) { + actorId = options.value.actors.find( + (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(), + )?.actorId; + // A viewer GitCafe knows no actor for has authored and been asked to review nothing. + if (actorId === undefined) return { items: [], truncated: false, continues: false }; + } + } + const limit = Math.max(1, input.limit); + const items: Array = []; + let next: string | null = null; + do { + const query = new URLSearchParams({ + limit: String(Math.min(PAGE_SIZE, limit - items.length)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (input.query?.trim()) query.set("q", input.query.trim()); + if (actorId !== undefined) + query.set( + input.involvement === "reviewing" ? "reviewers" : "authors", + encodeActorIds([actorId]), + ); + if (next !== null) query.set("after", next); + const page: typeof Json.GitCafePulls.Type = yield* read( + "listChangeRequests", + input, + { path: `${base}/pulls?${query}` }, + Json.GitCafePulls, + ); + items.push(...page.items); + next = page.items.length === 0 ? null : page.next; + } while (next !== null && items.length < limit); + return { + items: items.slice(0, limit).map((pull) => { + const item = Json.toChangeRequest(pull, input); + // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An + // unnarrowed listing (no actor to filter by) says nothing about who was asked. + return input.involvement === "reviewing" && actorId !== undefined + ? { + ...item, + reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])], + } + : item; + }), + truncated: next !== null, + continues: false, + }; + }, + ), + getChangeRequestSummary: (input) => + readPull("getChangeRequestSummary", input).pipe( + Effect.map((pull) => ({ + ...Json.toChangeRequest(pull, input), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + })), + ), + getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) { + const pull = yield* readPull("getChangeRequest", input); + const path = yield* pullPath("getChangeRequest", input); + const base = yield* repositoryPath("getChangeRequest", input); + const [status, checks, changes] = yield* Effect.all( + [ + read("getChangeRequest", input, { path: `${path}/status` }, Status), + pull.headOid === null + ? Effect.succeed(null) + : readPages( + "getChangeRequest", + input, + `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`, + Json.GitCafeChecks, + ), + pull.headOid === null + ? Effect.succeed(null) + : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)), + ], + { concurrency: 3 }, + ); + const permissions = Json.toViewerPermissions(pull); + return { + ...Json.toChangeRequest(pull, input), + headSha: pull.headOid, + body: pull.description ?? "", + ...(pull.sourceRepo === null + ? {} + : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }), + additions: changes?.additions ?? 0, + deletions: changes?.deletions ?? 0, + changedFiles: changes?.changedFiles ?? 0, + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + reviewers: Json.toReviewers(pull, input.host), + checks: checks === null ? [] : Json.toChecks(checks), + ...(status.checks.total === 0 + ? {} + : { + checksState: + status.checks.failing > 0 + ? ("failing" as const) + : status.checks.pending > 0 + ? ("pending" as const) + : ("passing" as const), + }), + mergeability: + status.merge.conflicts === "conflicting" + ? ("conflicting" as const) + : ("unknown" as const), + baseComparison: + status.merge.fastForward === null + ? ("unknown" as const) + : status.merge.fastForward + ? ("up-to-date" as const) + : ("behind" as const), + mergeCapabilities: { + merge: mergeStrategyOpen(status, "merge"), + squash: mergeStrategyOpen(status, "squash"), + rebase: mergeStrategyOpen(status, "rebase"), + }, + viewerPermissions: + status.merge.permitted === false + ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") } + : permissions, + }; + }), + getChangeRequestStack: (input) => + readStack("getChangeRequestStack", input).pipe( + Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))), + ), + getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")( + function* (input) { + const pull = yield* readPull("getChangeRequestActivity", input); + const path = yield* pullPath("getChangeRequestActivity", input); + const operation = "getChangeRequestActivity"; + const [comments, reviews, commits, reactions] = yield* Effect.all( + [ + readComments(operation, input), + readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews), + pull.headOid === null + ? Effect.succeed({ items: [], next: null }) + : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits), + // Reactions decorate the conversation; a failed read leaves them out, not it. + readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe( + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: 4 }, + ); + return Json.toActivity({ + comments, + reviews, + commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" }, + host: input.host, + ...(pull.headOid === null ? {} : { headOid: pull.headOid }), + ...(reactions === undefined ? {} : { reactions }), + }); + }, + ), + getViewerPermissions: (input) => + readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), + getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { + if (input.commit !== undefined) + return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), + ); + const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; + const path = yield* pullPath("getDiff", input); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(DIFF_FILES_BATCH), + }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (cursor !== null && + (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) + ) + return yield* failure( + "getDiff", + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + return { + ...Json.toDiff({ items: withDetails(page.items, details) }), + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }), + runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { + if (input.stackNumber !== undefined) + return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); + switch (input.action) { + case "merge": + return yield* merge(input); + case "ready": + case "draft": + case "close": + case "reopen": { + const pull = yield* readPull(input.action, input); + const path = yield* pullPath(input.action, input); + yield* send(input.action, input, { + method: "POST", + path: `${path}/${input.action}`, + body: { expectedVersion: pull.version }, + }); + return; + } + default: + return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`); + } + }), + updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")( + function* (input) { + const pull = yield* readPull("updateChangeRequest", input); + const path = yield* pullPath("updateChangeRequest", input); + yield* read( + "updateChangeRequest", + input, + { + method: "PATCH", + path, + body: { + expectedVersion: pull.version, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }, + }, + Versioned, + ); + }, + ), + comment: (input) => + pullPath("comment", input).pipe( + Effect.flatMap((path) => + read( + "comment", + input, + { method: "POST", path: `${path}/comments/`, body: { body: input.body } }, + Written, + ), + ), + Effect.asVoid, + ), + updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) { + if (!isId(input.commentId)) + return yield* failure("updateComment", "GitCafe comment ids are opaque slugs."); + const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`; + const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail); + yield* read( + "updateComment", + input, + { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } }, + Written, + ); + }), + submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) { + if (input.comments.length > 0) + return yield* failure("submitReview", "GitCafe line comments are not supported yet."); + const pull = yield* readPull("submitReview", input); + if (pull.headOid === null) + return yield* failure("submitReview", "GitCafe has not reported the pull request's head."); + const path = yield* pullPath("submitReview", input); + yield* read( + "submitReview", + input, + { + method: "POST", + path: `${path}/reviews`, + body: { + verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict, + ...(input.body.length === 0 ? {} : { body: input.body }), + commitOid: pull.headOid, + expectedVersion: pull.version, + requestId: yield* randomUuidV4, + }, + }, + Written, + ); + }), + listReviewerCandidates: () => + Effect.fail( + failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."), + ), + setReviewerRequest: () => + Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")), + replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) { + if (!isId(input.threadId)) + return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs."); + const path = yield* pullPath("replyToThread", input); + yield* read( + "replyToThread", + input, + { + method: "POST", + path: `${path}/comments/${input.threadId}/replies`, + body: { body: input.body }, + }, + Written, + ); + }), + setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) { + const path = `${yield* pullPath("setReaction", input)}/reactions/`; + const subject = + input.subjectId === undefined + ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id } + : { kind: "pull_request_comment" as const, id: input.subjectId }; + const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions); + const mine = reactions.items.find( + (item) => + item.subject.kind === subject.kind && + item.subject.id === subject.id && + item.emoji.kind === "unicode" && + Json.reactionContent(item.emoji.value) === input.content, + )?.viewerReactionId; + if (input.reacted === (mine != null)) return; + if (input.reacted) { + yield* send("setReaction", input, { + method: "POST", + path, + body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } }, + }); + return; + } + if (mine == null) return; + if (!isId(mine)) + return yield* failure("setReaction", "GitCafe returned an unreadable reaction id."); + yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` }); + }), + setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")( + function* (input) { + const comments = yield* readComments("setThreadResolution", input); + const root = comments.items.find( + (comment) => comment.id === input.threadId && comment.threadId === input.threadId, + ); + if (root === undefined || root.version === undefined || !isId(root.id)) + return yield* failure("setThreadResolution", "GitCafe did not return the thread root."); + if (input.resolved === (root.resolvedAt !== null)) return; + const path = yield* pullPath("setThreadResolution", input); + yield* read( + "setThreadResolution", + input, + { + method: "POST", + path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`, + body: { expectedVersion: root.version }, + }, + Written, + ); + }, + ), + }; + return provider; +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts new file mode 100644 index 000000000000..aaaf5d0e90ff --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -0,0 +1,339 @@ +/** + * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: + * + * - `discovery: SourceControlCliDiscoverySpec` โ€” kind `"gitcafe"`, executable `"cafe"`, + * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a + * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its + * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. + * - `make: Effect` with + * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. + * + * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. + */ +import { assert, describe, it } from "@effect/vitest"; +import { GitCommandError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitCafeApi from "./GitCafeApi.ts"; +import * as GitCafeCredentials from "./GitCafeCredentials.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const authAnswer = (input: { stdout?: unknown; stderr?: unknown; exitCode?: number }) => ({ + stdout: input.stdout === undefined ? "" : encodeJson(input.stdout), + stderr: input.stderr === undefined ? "" : encodeJson(input.stderr), + exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), +}); +const failure = (code: string, status: number | null) => ({ + schemaVersion: 1, + error: { code, status, message: `Failure ${code}` }, +}); + +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 signedIn = parseAuth( + authAnswer({ stdout: { schemaVersion: 1, data: { host: "git.cafe", username: "alice" } } }), + ); + assert.strictEqual(signedIn.status, "authenticated"); + assert.deepStrictEqual(signedIn.account, Option.some("alice")); + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure("AUTHENTICATION_REQUIRED", 401), exitCode: 1 })) + .status, + "unauthenticated", + ); + for (const [code, status] of [ + ["FORBIDDEN", 403], + ["NETWORK_ERROR", null], + ] as const) { + assert.strictEqual( + parseAuth(authAnswer({ stderr: failure(code, status), exitCode: 1 })).status, + "unknown", + ); + } + }); + + it.effect.each([ + ["https://staging.git.cafe/team/project.git", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ] as const)("resolves the repository from the remote %s", ([remoteUrl, host]) => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + const context = { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + assert.strictEqual(provider.kind, "gitcafe"); + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo", context }), "trunk"); + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", context, repository: remoteUrl }), + { + nameWithOwner: "team/project", + url: `https://${host}/team/project`, + sshUrl: `ssh@${host}:team/project.git`, + }, + ); + assert.deepStrictEqual(urls, [ + `https://${host}/api/repos/team/project`, + `https://${host}/api/repos/team/project`, + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge(TestSourceControlHost.layer()), + Layer.provide( + Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token", CAFE_HOST: host }), + ), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect( + "reads a bare owner/name on git.cafe without a remote, and no other host's pulls", + () => { + const urls: Array = []; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson({ name: "project", defaultBranch: "trunk" })), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + // The checkout has no remote at all, as when a repository is about to be created. + assert.deepStrictEqual( + yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), + { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }, + ); + const foreign = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://example.com/team/project/pulls/42", + }) + .pipe(Effect.flip); + assert.strictEqual(foreign._tag, "SourceControlProviderError"); + assert.deepStrictEqual(urls, ["https://git.cafe/api/repos/team/project"]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }, + ); + + it.effect.each([ + ["no context", undefined, "https://git.cafe/fork/project.git"], + ["an HTTPS remote", "https://git.cafe/team/project.git", "https://git.cafe/fork/project.git"], + ["an SSH remote", "ssh@git.cafe:team/project.git", "ssh@git.cafe:fork/project.git"], + ["an ssh:// remote", "ssh://ssh@git.cafe/team/project.git", "ssh@git.cafe:fork/project.git"], + [ + "an HTTPS remote with a user", + "https://alice@git.cafe/team/project.git", + "https://git.cafe/fork/project.git", + ], + ] as const)("checks a fork out over the transport of %s", ([, remoteUrl, expected]) => { + const remotes: Array = []; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + number: 7, + title: "Change", + state: "open", + draft: false, + sourceBranch: "feature", + targetBranch: "main", + headOid: null, + updatedAt: null, + sourceRepo: { owner: "fork", name: "project" }, + closedAt: null, + mergedAt: null, + }), + ), + ), + ), + ); + const context = + remoteUrl === undefined + ? undefined + : { + provider: { kind: "gitcafe" as const, name: "GitCafe", baseUrl: "https://git.cafe" }, + remoteName: "origin", + remoteUrl, + }; + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + reference: "https://git.cafe/team/project/pulls/7", + ...(context === undefined ? {} : { context }), + }); + assert.deepStrictEqual(remotes, [expected]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + git: { + ensureRemote: (input) => { + remotes.push(input.url); + return Effect.succeed("gitcafe"); + }, + listLocalBranchNames: () => Effect.succeed([]), + fetchRemoteBranch: () => Effect.void, + setBranchUpstream: () => Effect.void, + switchRef: () => Effect.succeed({ refName: null }), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); + + it.effect("waits for a created repository's admission before handing out its URLs", () => { + const urls: Array = []; + const states = ["running", "complete"]; + const client = HttpClient.make((request) => { + urls.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson({ + repoId: "repo_1", + state: states.shift(), + owner: "team", + name: "project", + }), + ), + ), + ); + }); + return Effect.gen(function* () { + const provider = yield* GitCafeSourceControlProvider.make; + const created = yield* provider + .createRepository({ cwd: "/repo", repository: "team/project", visibility: "private" }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(created), { + nameWithOwner: "team/project", + url: "https://git.cafe/team/project", + sshUrl: "ssh@git.cafe:team/project.git", + }); + assert.deepStrictEqual(urls, [ + "https://git.cafe/api/orgs/team/admissions/repo_1", + "https://git.cafe/api/orgs/team/admissions/repo_1", + ]); + }).pipe( + Effect.provide( + GitCafeApi.layer.pipe( + Layer.provide(GitCafeCredentials.layer), + Layer.provideMerge( + TestSourceControlHost.layer({ + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput( + encodeJson({ + schemaVersion: 1, + data: { + resource: { + repoId: "repo_1", + state: "running", + owner: "team", + name: "project", + }, + }, + }), + ), + ), + }, + git: { + resolvePrimaryRemoteName: () => + Effect.fail( + new GitCommandError({ + operation: "resolvePrimaryRemoteName", + command: "git remote", + cwd: "/repo", + detail: "No git remote is configured.", + }), + ), + }, + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { CAFE_TOKEN: "env-token" })), + Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts new file mode 100644 index 000000000000..5d55bd72bac3 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -0,0 +1,503 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import { + PositiveInt, + SourceControlProviderError, + TrimmedNonEmptyString, + type ChangeRequest, +} from "@t3tools/contracts"; +import { + providerAuth, + type SourceControlCliDiscoverySpec, +} 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 GitCafeHosts from "./gitCafeHosts.ts"; + +/** Discovery reports the production account; repository operations pick their own host. */ +const AUTH_HOST = "git.cafe"; +/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */ +const ADMISSION_TIMEOUT = "30 seconds"; +const ADMISSION_POLL_INTERVAL = "500 millis"; +const cliArgs = (host: string) => [ + "--host", + `https://${host}/api`, + "--no-input", + "--no-update-check", +]; + +const decodeAuth = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + data: Schema.Struct({ host: Schema.String, username: TrimmedNonEmptyString }), + }), + ), +); +/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */ +const decodeFailure = Schema.decodeUnknownResult( + Schema.fromJsonString( + Schema.Struct({ + schemaVersion: Schema.Literal(1), + error: Schema.Struct({ + code: Schema.String, + message: Schema.String, + status: Schema.NullOr(Schema.Finite), + }), + }), + ), +); +const cliFailure = (output: string) => + [output.trim(), ...output.split(/\r?\n/u).toReversed()] + .map((line) => decodeFailure(line.trim())) + .find(Result.isSuccess)?.success.error; + +export const discovery = { + type: "cli", + kind: "gitcafe", + label: "GitCafe", + executable: "cafe", + versionArgs: ["--version"], + authArgs: [...cliArgs(AUTH_HOST), "auth", "status", "--json"], + parseAuth: (input) => { + const signedIn = decodeAuth(input.stdout.trim()); + if (input.exitCode === 0 && Result.isSuccess(signedIn)) + return providerAuth({ + status: "authenticated", + host: AUTH_HOST, + account: signedIn.success.data.username, + }); + const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout); + if (failure) + return providerAuth({ + // Only a refusal says the login is gone; a forbidden or offline answer says nothing. + status: + failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401 + ? "unauthenticated" + : "unknown", + host: AUTH_HOST, + detail: failure.message, + }); + return providerAuth({ + status: "unknown", + host: AUTH_HOST, + 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.`, +} satisfies SourceControlCliDiscoverySpec; + +const Repository = Schema.Struct({ + name: TrimmedNonEmptyString, + defaultBranch: Schema.NullOr(TrimmedNonEmptyString), +}); +const Pull = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.Literals(["open", "closed", "merged"]), + draft: Schema.Boolean, + sourceBranch: TrimmedNonEmptyString, + targetBranch: TrimmedNonEmptyString, + headOid: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtcFromString), +}); +const PullDetail = Schema.Struct({ + ...Pull.fields, + sourceRepo: Schema.NullOr( + Schema.Struct({ owner: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + ), + closedAt: Schema.NullOr(Schema.String), + mergedAt: Schema.NullOr(Schema.String), +}); +const Pulls = Schema.Struct({ items: Schema.Array(Pull), next: Schema.NullOr(Schema.String) }); +const RepositoryAdmission = Schema.Struct({ + repoId: TrimmedNonEmptyString, + state: Schema.String, + owner: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +const CreatedRepository = Schema.Struct({ + data: Schema.Struct({ resource: RepositoryAdmission }), +}); +const decodeCreatedRepository = Schema.decodeEffect(Schema.fromJsonString(CreatedRepository)); +const encodeBranches = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +interface Target { + readonly host: GitCafeHosts.GitCafeHost; + readonly repository: string; +} + +const cloneUrls = (target: Target) => ({ + nameWithOwner: target.repository, + url: `https://${target.host}/${target.repository}`, + sshUrl: `ssh@${target.host}:${target.repository}.git`, +}); + +/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */ +const isSshRemote = (remoteUrl: string | undefined) => + remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); + +const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u; + +export const make = Effect.gen(function* () { + const api = yield* GitCafeApi.GitCafeApi; + const host = yield* SourceControlHost.SourceControlHost; + + const error = (operation: string, cwd: string, detail: string, cause?: unknown) => + new SourceControlProviderError({ + provider: "gitcafe", + operation, + cwd, + detail, + ...(cause === undefined ? {} : { cause }), + }); + const read = >( + operation: string, + cwd: string, + request: Omit, + schema: S, + ) => + api.request({ ...request, operation }).pipe( + Effect.mapError((cause) => error(operation, cwd, cause.detail, cause)), + Effect.flatMap((raw) => + Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError((cause) => + error(operation, cwd, "GitCafe returned an unreadable response.", cause), + ), + ), + ), + ); + 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, + }) + .pipe( + Effect.mapError((cause) => + error( + operation, + cwd, + (cause._tag === "VcsProcessExitError" && cliFailure(cause.detail)?.message) || + "GitCafe CLI command failed.", + cause, + ), + ), + ); + + /** The repository a call names, falling back to the checkout's own GitCafe remote. */ + const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( + operation: string, + input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }, + ) { + const named = + input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository); + if (named) return named; + // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no + // remote (a repository being created) still resolves, on git.cafe. + const readRemote = host.git + .resolvePrimaryRemoteName(input.cwd) + .pipe( + Effect.flatMap((remote) => host.git.readConfigValue(input.cwd, `remote.${remote}.url`)), + ); + const remoteUrl = + input.context?.remoteUrl ?? + (input.repository === undefined + ? yield* readRemote.pipe( + Effect.mapError((cause) => + error(operation, input.cwd, "Could not read the GitCafe remote.", cause), + ), + ) + : yield* readRemote.pipe(Effect.orElseSucceed(() => null))); + const remote = remoteUrl ? GitCafeHosts.parseGitCafeRemote(remoteUrl) : null; + if (input.repository === undefined) { + if (remote) return remote; + return yield* error(operation, input.cwd, "No GitCafe repository remote was found."); + } + // A bare owner/name lives on the checkout's own GitCafe host. + const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository); + if (repository !== null) + return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target; + return yield* error( + operation, + input.cwd, + "A GitCafe repository is named owner/name or by its git.cafe URL.", + ); + }); + + const getPull = Effect.fn("GitCafeSourceControlProvider.getPull")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) { + const match = pullUrlPattern.exec(input.reference.trim()); + const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null; + const target: Target = + urlHost && match?.[2] + ? { host: urlHost, repository: match[2] } + : yield* resolveTarget("getChangeRequest", input); + // A pull request URL on another host is not a GitCafe pull request number. + const number = match + ? urlHost + ? match[3] + : undefined + : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1]; + if (number === undefined) + return yield* error( + "getChangeRequest", + input.cwd, + "A GitCafe pull request is a number or a git.cafe pull request URL.", + ); + const pull = yield* read( + "getChangeRequest", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls/${number}` }, + PullDetail, + ); + const owner = target.repository.split("/")[0] ?? null; + return { + provider: "gitcafe", + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + baseRefName: pull.targetBranch, + headRefName: pull.sourceBranch, + ...(pull.headOid ? { headSha: pull.headOid } : {}), + state: pull.state, + ...(pull.draft ? { isDraft: true } : {}), + closedAt: pull.closedAt, + mergedAt: pull.mergedAt, + updatedAt: pull.updatedAt, + isCrossRepository: pull.sourceRepo !== null, + headRepositoryNameWithOwner: pull.sourceRepo + ? `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` + : target.repository, + headRepositoryOwnerLogin: pull.sourceRepo?.owner ?? owner, + } satisfies ChangeRequest; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitcafe", + repositoryNameFromRemoteUrl: (url) => GitCafeHosts.parseGitCafeRemote(url)?.repository ?? null, + getChangeRequest: getPull, + listChangeRequests: Effect.fn("GitCafeSourceControlProvider.listChangeRequests")( + function* (input) { + const target = yield* resolveTarget("listChangeRequests", input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const limit = input.limit ?? 20; + const items: Array = []; + let after: string | null = null; + do { + const query = new URLSearchParams({ + sourceBranches: encodeBranches([branch]), + limit: String(Math.min(limit, 100)), + sort: "newest", + }); + if (input.state !== "all") query.set("state", input.state); + if (after !== null) query.set("after", after); + const page: typeof Pulls.Type = yield* read( + "listChangeRequests", + input.cwd, + { host: target.host, path: `/repos/${target.repository}/pulls?${query}` }, + Pulls, + ); + for (const pull of page.items) { + if (pull.sourceBranch !== branch || items.length >= limit) continue; + // List rows omit fork identity, so only the branch's own rows are read in full. + const detail = yield* getPull({ + cwd: input.cwd, + reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + }); + if ( + (!source?.repository || detail.headRepositoryNameWithOwner === source.repository) && + (!source?.owner || detail.headRepositoryOwnerLogin === source.owner) + ) + items.push(detail); + } + after = page.items.length === 0 || page.next === after ? null : page.next; + } while (after !== null && items.length < limit); + return items; + }, + ), + createChangeRequest: Effect.fn("GitCafeSourceControlProvider.createChangeRequest")( + function* (input) { + const target = yield* resolveTarget("createChangeRequest", { + ...input, + ...(input.target?.repository ? { repository: input.target.repository } : {}), + }); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const sourceRepository = + source?.repository ?? + (source?.owner + ? `${source.owner}/${target.repository.split("/")[1]}` + : target.repository); + const branch = SourceControlProvider.sourceBranch(input); + yield* cafe("createChangeRequest", input.cwd, target, [ + "pr", + "create", + "--json", + "--repo", + target.repository, + "--head", + sourceRepository === target.repository ? branch : `${sourceRepository}:${branch}`, + "--base", + input.target?.refName ?? input.baseRefName, + "--title", + input.title, + "--body-file", + input.bodyFile, + ]); + }, + ), + getRepositoryCloneUrls: Effect.fn("GitCafeSourceControlProvider.getRepositoryCloneUrls")( + function* (input) { + const target = yield* resolveTarget("getRepositoryCloneUrls", input); + yield* read( + "getRepositoryCloneUrls", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return cloneUrls(target); + }, + ), + createRepository: Effect.fn("GitCafeSourceControlProvider.createRepository")(function* (input) { + const target = yield* resolveTarget("createRepository", input); + const [owner = "", name = ""] = target.repository.split("/"); + const output = yield* cafe("createRepository", input.cwd, target, [ + "repo", + "create", + name, + "--org", + owner, + "--visibility", + input.visibility, + "--json", + ]); + const created = yield* decodeCreatedRepository(output.stdout).pipe( + Effect.mapError((cause) => + error("createRepository", input.cwd, "GitCafe returned an unreadable response.", cause), + ), + ); + // GitCafe admits a repository asynchronously: `cafe repo create` answers while admission is + // still running, so wait for it to settle the way `cafe repo create --clone` does. + const settled = (state: string) => state === "complete" || state === "blocked"; + const pending = created.data.resource; + const resource = settled(pending.state) + ? pending + : yield* read( + "createRepository", + input.cwd, + { + host: target.host, + path: `/orgs/${pending.owner}/admissions/${pending.repoId}`, + }, + RepositoryAdmission, + ).pipe( + Effect.repeat({ + until: (admission) => settled(admission.state), + schedule: Schedule.spaced(ADMISSION_POLL_INTERVAL), + }), + Effect.timeoutOption(ADMISSION_TIMEOUT), + Effect.map(Option.getOrElse(() => pending)), + ); + // Anything short of complete is not yet usable. + if (resource.state !== "complete") + return yield* error( + "createRepository", + input.cwd, + `GitCafe repository ${target.repository} is ${resource.state}. Check it on GitCafe before retrying.`, + ); + return cloneUrls({ host: target.host, repository: `${resource.owner}/${resource.name}` }); + }), + getDefaultBranch: Effect.fn("GitCafeSourceControlProvider.getDefaultBranch")(function* (input) { + const target = yield* resolveTarget("getDefaultBranch", input); + const repository = yield* read( + "getDefaultBranch", + input.cwd, + { host: target.host, path: `/repos/${target.repository}` }, + Repository, + ); + return repository.defaultBranch; + }), + // `cafe` has no forced checkout, so the branch is fetched and switched to with git. + checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")( + function* (input) { + const pull = yield* getPull(input); + const pullHost = new URL(pull.url).host; + const sourceRepository = pull.headRepositoryNameWithOwner; + if (!sourceRepository) + return yield* error( + "checkoutChangeRequest", + input.cwd, + "GitCafe did not name the pull request's source repository.", + ); + const contextRemote = input.context + ? GitCafeHosts.parseGitCafeRemote(input.context.remoteUrl) + : null; + const git = host.git; + yield* Effect.gen(function* () { + const remoteName = + input.context && + contextRemote?.repository === sourceRepository && + contextRemote.host === pullHost + ? input.context.remoteName + : yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: "gitcafe", + // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise. + url: isSshRemote(input.context?.remoteUrl) + ? `ssh@${pullHost}:${sourceRepository}.git` + : `https://${pullHost}/${sourceRepository}.git`, + }); + const localBranch = pull.isCrossRepository + ? `pr-${pull.number}/${pull.headRefName}` + : pull.headRefName; + const branches = yield* git.listLocalBranchNames(input.cwd); + const remoteBranch = { cwd: input.cwd, remoteName, remoteBranch: pull.headRefName }; + if (input.force !== true && !branches.includes(localBranch)) + yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch }); + else yield* git.fetchRemoteTrackingBranch(remoteBranch); + // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits. + if (input.force === true) + yield* git.execute({ + operation: "GitCafeSourceControlProvider.checkoutChangeRequest", + cwd: input.cwd, + args: [ + "checkout", + "-B", + localBranch, + `refs/remotes/${remoteName}/${pull.headRefName}`, + "--", + ], + }); + yield* git.setBranchUpstream({ ...remoteBranch, branch: localBranch }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + error( + "checkoutChangeRequest", + input.cwd, + "Could not check out the GitCafe pull request.", + cause, + ), + ), + ); + }, + ), + }); +}); diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts new file mode 100644 index 000000000000..467614c2ec1c --- /dev/null +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * 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. + * + * @module source-control-gitcafe/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; +import * as GitCafeSourceControlProvider from "./GitCafeSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "gitcafe", + make: Effect.all({ + sourceControl: GitCafeSourceControlProvider.make, + discovery: Effect.succeed(GitCafeSourceControlProvider.discovery), + pullRequests: GitCafePullRequestProvider.make, + }), +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts new file mode 100644 index 000000000000..a828a99f6ff1 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -0,0 +1,46 @@ +/** + * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service): + * + * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` โ€” the only hosts GitCafe lives on. + * - `isGitCafeHost(host: string): boolean` โ€” case-insensitive membership in `GITCAFE_HOSTS`. + * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null` + * โ€” the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. + */ +import { assert, describe, it } from "@effect/vitest"; + +import * as GitCafeHosts from "./gitCafeHosts.ts"; + +describe("gitCafeHosts", () => { + it("knows exactly the production and staging hosts", () => { + assert.deepStrictEqual([...GitCafeHosts.GITCAFE_HOSTS], ["git.cafe", "staging.git.cafe"]); + assert.isTrue(GitCafeHosts.isGitCafeHost("Git.Cafe")); + assert.isTrue(GitCafeHosts.isGitCafeHost("staging.git.cafe")); + for (const host of ["cafe", "evil.git.cafe", "git.cafe.example.com", "github.com"]) { + assert.isFalse(GitCafeHosts.isGitCafeHost(host), host); + } + }); + + it.each([ + ["https://git.cafe/team/project.git", "git.cafe"], + ["https://git.cafe/team/project", "git.cafe"], + ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"], + ["ssh@git.cafe:team/project.git", "git.cafe"], + ["git@staging.git.cafe:team/project.git", "staging.git.cafe"], + ["ssh://git@git.cafe/team/project.git", "git.cafe"], + ])("reads the host and repository of %s", (url, host) => { + assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), { + host, + repository: "team/project", + }); + }); + + it.each([ + "https://github.com/team/project.git", + "https://evil.git.cafe/team/project.git", + "https://git.cafe/team/nested/project.git", + "https://git.cafe/team", + "team/project", + ])("is not a GitCafe repository: %s", (url) => { + assert.isNull(GitCafeHosts.parseGitCafeRemote(url)); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts new file mode 100644 index 000000000000..a04b759b2d5f --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -0,0 +1,49 @@ +/** + * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no + * self-hosted installs, so the list is fixed rather than discovered. + * + * @module source-control-gitcafe/server/gitCafeHosts + */ + +export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const; +export type GitCafeHost = (typeof GITCAFE_HOSTS)[number]; + +/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */ +export function gitCafeHost(host: string): GitCafeHost | null { + const normalized = host.trim().toLowerCase(); + return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null; +} + +export function isGitCafeHost(host: string): boolean { + return gitCafeHost(host) !== null; +} + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; +const REMOTE_PATTERNS = [ + /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, + /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu, + /^[^@/:]+@([^/:]+):(.+)$/u, +]; + +/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */ +export function gitCafeRepositoryPath(path: string): string | null { + const repository = path + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); + return REPOSITORY.test(repository) ? repository : null; +} + +/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */ +export function parseGitCafeRemote( + url: string, +): { readonly host: GitCafeHost; readonly repository: string } | null { + for (const pattern of REMOTE_PATTERNS) { + const match = pattern.exec(url.trim()); + if (!match?.[1] || !match[2]) continue; + const host = gitCafeHost(match[1]); + const repository = gitCafeRepositoryPath(match[2]); + return host && repository ? { host, repository } : null; + } + return null; +} diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts new file mode 100644 index 000000000000..41d578cdf1e0 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -0,0 +1,547 @@ +/** + * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral + * `Provider*` shapes. Pass 2 creates these exports: + * + * Schemas (REST bodies as GitCafe sends them): + * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt, + * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`), + * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions` + * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`), + * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`, + * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`), + * `GitCafeChecks` (`{ items, next }`). + * + * Conversions (all take the repository host, so staging stays on staging): + * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest` + * - `toViewerPermissions(detail): PullRequestViewerPermissions` โ€” fills `editChangeRequest`. + * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }): + * ProviderChangeRequestActivity` โ€” fills `canEdit` on both copies of a remark and `canResolve` + * on review threads, from each comment's `capabilities`. + * - `toStack(stack, { repository, host }): ProviderChangeRequestStack` + * - `toDiff(diff): ProviderDiffSlice` + * - `toChecks(checks): ReadonlyArray` + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts"; + +const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull); +const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail); +const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments); +const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews); +const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits); +const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions); +const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope); +const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff); +const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks); + +const timestamp = "2026-09-12T12:00:00Z"; +const actor = { + kind: "local", + actorId: "act_one", + handle: "alice", + displayName: null, + avatarUrl: "/avatars/one", +}; +const pull = { + id: "pr_one", + number: 7, + title: "Change", + state: "open", + draft: true, + sourceBranch: "feature", + targetBranch: "main", + headOid: "abcdef", + author: actor, + createdAt: timestamp, + updatedAt: timestamp, + version: 1, +}; +const detail = { + ...pull, + draft: false, + description: "Body", + closedAt: null, + mergedAt: null, + sourceRepo: null, + capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true }, +}; +const lineComment = { + author: actor, + body: "Review", + path: "file.ts", + line: 3, + side: "right", + createdAt: timestamp, + commitOid: "abcdef", + resolvedAt: null, +}; +const capabilities = (overrides: Record = {}) => ({ + edit: false, + hide: false, + unhide: false, + delete: false, + resolve: false, + unresolve: false, + ...overrides, +}); +const noReviews = decodeReviews({ items: [], next: null }); +const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null }); +const activity = ( + comments: ReadonlyArray>, + extra: { readonly reactions?: unknown; readonly host?: string } = {}, +) => + GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: comments, next: null }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: extra.host ?? "git.cafe", + ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }), + }); + +describe("toChangeRequest", () => { + it("reads draft state, the repository host's URLs and absolute avatars", () => { + const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(row.state, "open"); + assert.isTrue(row.isDraft); + assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7"); + assert.deepStrictEqual(row.author, { + login: "alice", + name: null, + avatarUrl: "https://staging.git.cafe/avatars/one", + }); + }); + + it("names linked GitHub reviewers by both logins and drops unavailable ones", () => { + const row = GitCafePullRequestJson.toChangeRequest( + decodePull({ + ...pull, + author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null }, + reviewers: [ + { actor }, + { + actor: { + kind: "github", + actorId: "act_github", + login: "upstream", + avatarUrl: null, + linkedProfile: { handle: "linked-local" }, + }, + }, + { actor: { kind: "unavailable", actorId: "act_deleted" } }, + ], + }), + { repository: "owner/repo", host: "git.cafe" }, + ); + assert.strictEqual(row.author?.login, "remote-user"); + assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]); + }); + + it("requires capabilities on a detail response", () => { + assert.throws(() => decodePullDetail(pull)); + }); +}); + +describe("toViewerPermissions", () => { + const permissions = (overrides: Record) => + GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides })); + + it("lets an editor rewrite the title and description, and nobody else", () => { + assert.isTrue(permissions({}).editChangeRequest); + assert.isFalse( + permissions({ + capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true }, + }).editChangeRequest, + ); + }); + + it("offers lifecycle actions by state and never a standalone branch update", () => { + assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]); + assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]); + assert.deepStrictEqual(permissions({ state: "merged" }).actions, []); + assert.isTrue(permissions({}).stackRebase); + }); + + it("keeps a locked conversation's composer for moderators only", () => { + const locked = (moderate: boolean) => + permissions({ + lockedAt: timestamp, + capabilities: { comment: true, review: false, merge: false, edit: false, moderate }, + }); + assert.isFalse(locked(false).comment); + assert.isFalse(locked(false).resolve); + assert.isTrue(locked(true).comment); + assert.deepStrictEqual(locked(true).verdicts, []); + }); + + it("offers no composer to a viewer GitCafe won't let comment", () => { + const silent = permissions({ + capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true }, + }); + assert.isFalse(silent.comment); + assert.isFalse(silent.resolve); + }); +}); + +describe("toActivity", () => { + it("groups line comments by their persisted thread id and marks a paged list truncated", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ + items: [ + { ...lineComment, id: "reply", threadId: "thread-one" }, + { ...lineComment, id: "other", threadId: "thread-two" }, + ], + next: "next", + }), + reviews: noReviews, + commits: noCommits, + headOid: "abcdef", + host: "git.cafe", + }); + assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [ + "thread-one", + "thread-two", + ]); + assert.isTrue(result.commentsTruncated); + }); + + it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => { + const result = activity([ + { ...lineComment, id: "root", threadId: "root", resolvedAt }, + { ...lineComment, id: "reply", threadId: "root", resolvedAt: null }, + ]); + assert.strictEqual(result.reviewThreads.length, 1); + assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null); + assert.strictEqual(result.reviewThreads[0]?.comments.length, 2); + }); + + it("carries per-remark edit permission onto both copies of a remark", () => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + capabilities: capabilities({ edit: false, resolve: true }), + }, + { + ...lineComment, + id: "reply", + threadId: "root", + capabilities: capabilities({ edit: true }), + }, + { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null }, + ]); + const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit; + assert.strictEqual(canEdit("root"), false); + assert.strictEqual(canEdit("reply"), true); + // A host that says nothing about a remark leaves the page to guess. + assert.isUndefined(canEdit("silent")); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]), + [ + ["root", false], + ["reply", true], + ], + ); + }); + + it.each([ + [null, { resolve: true, unresolve: false }, true], + [null, { resolve: false, unresolve: true }, false], + [timestamp, { resolve: true, unresolve: false }, false], + [timestamp, { resolve: false, unresolve: true }, true], + ] as const)( + "reads canResolve for a thread resolved at %s with %o as %s", + (resolvedAt, allowed, canResolve) => { + const result = activity([ + { + ...lineComment, + id: "root", + threadId: "root", + resolvedAt, + capabilities: capabilities(allowed), + }, + ]); + assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve); + }, + ); + + it("leaves canResolve absent when the root says nothing", () => { + const result = activity([{ ...lineComment, id: "root", threadId: "root" }]); + assert.isUndefined(result.reviewThreads[0]?.canResolve); + }); + + it("maps review verdicts, dismissals and commits", () => { + const result = GitCafePullRequestJson.toActivity({ + comments: decodeComments({ items: [], next: null }), + reviews: decodeReviews({ + items: [ + { + id: "r1", + author: actor, + body: "LGTM", + verdict: "approve", + dismissedAt: null, + createdAt: timestamp, + }, + { + id: "r2", + author: actor, + body: null, + verdict: "request_changes", + dismissedAt: timestamp, + createdAt: "2026-09-12T13:00:00Z", + }, + ], + next: null, + }), + commits: decodeCommits({ + items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }], + headOid: "abcdef", + truncated: false, + next: null, + }), + host: "git.cafe", + }); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]), + [ + ["r1", "review", "approved"], + ["r2", "review", "dismissed"], + ], + ); + assert.deepStrictEqual( + result.commits.map((commit) => commit.oid), + ["abcdef"], + ); + }); + + it("maps supported reactions to the pull request and to matching comments in both views", () => { + const result = activity( + [ + { ...lineComment, id: "comment-a", threadId: "comment-a" }, + { ...lineComment, id: "comment-b", threadId: "comment-a" }, + ], + { + reactions: { + next: null, + items: [ + { + subject: { kind: "pull_request_comment", id: "comment-b" }, + emoji: { kind: "unicode", value: "๐Ÿ‘" }, + count: 3, + viewerReactionId: "reaction-viewer", + reactors: [actor, { kind: "unavailable", actorId: "deleted" }], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "๐Ÿš€" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + // Neither a custom emoji nor one outside the shared set is shown under another name. + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "custom", id: "party-parrot" }, + count: 2, + viewerReactionId: null, + reactors: [actor], + }, + { + subject: { kind: "pull_request", id: "pr_one" }, + emoji: { kind: "unicode", value: "๐Ÿ”ฅ" }, + count: 1, + viewerReactionId: null, + reactors: [actor], + }, + ], + }, + }, + ); + const thumbsUp = { + content: "thumbs-up", + count: 3, + actors: ["alice"], + viewerHasReacted: true, + } as const; + assert.deepStrictEqual(result.reactions, [ + { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false }, + ]); + assert.deepStrictEqual( + result.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + assert.deepStrictEqual( + result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]), + [ + ["comment-a", []], + ["comment-b", [thumbsUp]], + ], + ); + }); +}); + +describe("toStack", () => { + it("orders members by position and reports heads only where GitCafe gives them", () => { + const { stack } = decodeStackEnvelope({ + stack: { + id: "stack-one", + number: 3, + revision: 1, + landingBase: "main", + members: [ + { + pullRequestNumber: 7, + title: "Top", + state: "open", + draft: true, + sourceBranch: "feature", + position: 2, + }, + { + pullRequestNumber: 6, + title: "Base", + state: "merged", + draft: false, + sourceBranch: "base", + headOid: "0123456789abcdef0123456789abcdef01234567", + position: 1, + }, + ], + }, + }); + assert.isNotNull(stack); + const result = GitCafePullRequestJson.toStack(stack!, { + repository: "owner/repo", + host: "staging.git.cafe", + }); + assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3"); + assert.strictEqual(result.base, "main"); + assert.deepStrictEqual(result.layers, [ + { + number: 6, + title: "Base", + headBranch: "base", + state: "merged", + isDraft: false, + headSha: "0123456789abcdef0123456789abcdef01234567", + }, + { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true }, + ]); + }); + + it("reads an unstacked pull request as no stack", () => { + assert.isNull(decodeStackEnvelope({ stack: null }).stack); + }); +}); + +describe("toDiff", () => { + it("converts numeric hunk coordinates into a unified patch", () => { + const diff = decodeDiff({ + items: [ + { + path: "file.ts", + oldPath: null, + status: "modified", + additions: 1, + deletions: 1, + binary: false, + hunks: [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "old\n" }, + { origin: "+", content: "new\n" }, + ], + }, + ], + }, + ], + truncated: false, + }); + assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n"); + }); + + it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => { + const diff = decodeDiff({ + items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }], + truncated: false, + }); + const result = GitCafePullRequestJson.toDiff(diff); + assert.isTrue(result.truncated); + assert.deepStrictEqual(result.omittedFileStats, [ + { path: "large.ts", additions: 0, deletions: 0 }, + ]); + }); + + it("quotes renamed and binary paths", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "new image.png", + oldPath: "old image.png", + status: "renamed", + binary: true, + hunks: [], + }, + ], + truncated: false, + }), + ); + // Git quotes a name for the bytes a header can't carry, not for a space. + assert.include(patch, "rename from old image.png"); + assert.include(patch, "Binary files a/old image.png and b/new image.png differ"); + }); + + it("C-escapes a name whose newline or tab would break the patch headers", () => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }], + truncated: false, + }), + ); + assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"'); + assert.notInclude(patch, "two\nlines"); + }); +}); + +describe("toChecks", () => { + it("maps GitCafe's status and conclusion onto the shared check states", () => { + const check = (status: string, conclusion: string | null) => ({ + name: `${status}-${conclusion}`, + status, + conclusion, + summary: null, + detailsUrl: null, + }); + const checks = GitCafePullRequestJson.toChecks( + decodeChecks({ + items: [ + check("queued", null), + check("in_progress", null), + check("completed", "success"), + check("completed", "action_required"), + check("completed", "timed_out"), + ], + next: null, + }), + ); + assert.deepStrictEqual( + checks.map((item) => item.status), + ["pending", "pending", "success", "action-required", "failure"], + ); + }); +}); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts new file mode 100644 index 000000000000..453083a7a6b4 --- /dev/null +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -0,0 +1,578 @@ +/** + * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every + * conversion takes the repository's host, so a staging repository's links stay on staging. + * + * @module source-control-gitcafe/server/gitCafePullRequestJson + */ +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import * as DateTime from "effect/DateTime"; +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + type PullRequestActor, + type PullRequestCheck, + type PullRequestComment, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewThread, + type PullRequestViewerPermissions, +} from "@t3tools/contracts"; +import type { + ProviderChangeRequest, + ProviderChangeRequestActivity, + ProviderChangeRequestStack, + ProviderDiffSlice, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +const Actor = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("local"), + actorId: Schema.String, + handle: TrimmedNonEmptyString, + displayName: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("github"), + actorId: Schema.String, + login: TrimmedNonEmptyString, + avatarUrl: Schema.NullOr(Schema.String), + linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))), + }), + Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }), +]); +type Actor = typeof Actor.Type; + +const PullState = Schema.Literals(["open", "closed", "merged"]); + +export const GitCafePull = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + title: TrimmedNonEmptyString, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + targetBranch: Schema.String, + headOid: Schema.NullOr(Schema.String), + author: Actor, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + version: NonNegativeInt, + reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))), + labels: Schema.optional( + Schema.Array( + Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }), + ), + ), +}); +export type GitCafePull = typeof GitCafePull.Type; + +export const GitCafePulls = Schema.Struct({ + items: Schema.Array(GitCafePull), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafePullDetail = Schema.Struct({ + ...GitCafePull.fields, + description: Schema.NullOr(Schema.String), + closedAt: Schema.NullOr(IsoDateTime), + mergedAt: Schema.NullOr(IsoDateTime), + sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), + /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ + observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), + mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), + lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + capabilities: Schema.Struct({ + comment: Schema.Boolean, + review: Schema.Boolean, + merge: Schema.Boolean, + edit: Schema.Boolean, + moderate: Schema.Boolean, + }), +}); +export type GitCafePullDetail = typeof GitCafePullDetail.Type; + +const CommentCapabilities = Schema.Struct({ + edit: Schema.Boolean, + hide: Schema.Boolean, + unhide: Schema.Boolean, + delete: Schema.Boolean, + resolve: Schema.Boolean, + unresolve: Schema.Boolean, +}); + +const GitCafeComment = Schema.Struct({ + id: TrimmedNonEmptyString, + threadId: TrimmedNonEmptyString, + /** What a resolution or rewrite fences on; absent from older answers. */ + version: Schema.optional(NonNegativeInt), + author: Actor, + body: Schema.NullOr(Schema.String), + createdAt: IsoDateTime, + path: Schema.NullOr(Schema.String), + // The API allows any integer here; only a positive line anchors a review thread. + line: Schema.NullOr(Schema.Int), + side: Schema.NullOr(Schema.Literals(["left", "right"])), + commitOid: Schema.NullOr(Schema.String), + resolvedAt: Schema.NullOr(IsoDateTime), + capabilities: Schema.optional(CommentCapabilities), +}); +type GitCafeComment = typeof GitCafeComment.Type; + +export const GitCafeComments = Schema.Struct({ + items: Schema.Array(GitCafeComment), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeReviews = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + author: Actor, + body: Schema.NullOr(Schema.String), + verdict: Schema.Literals(["approve", "request_changes", "comment"]), + dismissedAt: Schema.NullOr(IsoDateTime), + createdAt: IsoDateTime, + }), + ), + next: Schema.NullOr(Schema.String), +}); + +export const GitCafeCommits = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }), + ), + truncated: Schema.Boolean, + next: Schema.NullOr(Schema.String), + headOid: Schema.String, +}); + +export const GitCafeReactions = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + subject: Schema.Struct({ + kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]), + id: TrimmedNonEmptyString, + }), + emoji: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }), + Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }), + ]), + count: PositiveInt, + viewerReactionId: Schema.NullOr(Schema.String), + reactors: Schema.Array(Actor), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +const GitCafeStack = Schema.Struct({ + id: TrimmedNonEmptyString, + number: PositiveInt, + /** What a land or restack fences on. */ + revision: PositiveInt, + landingBase: Schema.String, + members: Schema.Array( + Schema.Struct({ + pullRequestNumber: PositiveInt, + title: Schema.String, + state: PullState, + draft: Schema.Boolean, + sourceBranch: Schema.String, + headOid: Schema.optional(Schema.String), + position: PositiveInt, + }), + ), +}); +export type GitCafeStack = typeof GitCafeStack.Type; + +export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) }); + +const GitCafeDiffFile = Schema.Struct({ + path: TrimmedNonEmptyString, + oldPath: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional( + Schema.Literals([ + "added", + "deleted", + "modified", + "typeChanged", + "conflicted", + "renamed", + "copied", + ]), + ), + additions: Schema.optional(NonNegativeInt), + deletions: Schema.optional(NonNegativeInt), + binary: Schema.optional(Schema.Boolean), + tooLarge: Schema.optional(Schema.Boolean), + hunksOmitted: Schema.optional(Schema.Boolean), + isSubmodule: Schema.optional(Schema.Boolean), + oldOid: Schema.optional(Schema.String), + newOid: Schema.optional(Schema.String), + // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. + hunks: Schema.optional( + Schema.Array( + Schema.Struct({ + oldStart: NonNegativeInt, + oldLines: NonNegativeInt, + newStart: NonNegativeInt, + newLines: NonNegativeInt, + lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })), + }), + ), + ), +}); +export type GitCafeDiffFile = typeof GitCafeDiffFile.Type; +export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile); + +export const GitCafeDiff = Schema.Struct({ + items: GitCafeDiffFiles, + truncated: Schema.optional(Schema.Boolean), +}); + +export const GitCafeChecks = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + status: Schema.Literals(["queued", "in_progress", "completed"]), + conclusion: Schema.NullOr(Schema.String), + summary: Schema.NullOr(Schema.String), + detailsUrl: Schema.NullOr(Schema.String), + }), + ), + next: Schema.NullOr(Schema.String), +}); + +interface RepositoryOnHost { + readonly repository: string; + readonly host: string; +} + +function toActor(actor: Actor, host: string): PullRequestActor | null { + if (actor.kind === "unavailable") return null; + return { + login: actor.kind === "local" ? actor.handle : actor.login, + name: actor.kind === "local" ? actor.displayName : null, + // GitCafe serves its own avatars by path, so a relative one is read against the host. + avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href, + }; +} + +/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ +export function toReviewers( + pull: Pick, + host: string, +): ReadonlyArray { + return (pull.reviewers ?? []).flatMap(({ actor }) => { + const reviewer = toActor(actor, host); + return reviewer === null ? [] : [reviewer]; + }); +} + +/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ +function reviewerLogins(actor: Actor, host: string): ReadonlyArray { + const reviewer = toActor(actor, host); + if (reviewer === null) return []; + return actor.kind === "github" && actor.linkedProfile + ? [reviewer.login, actor.linkedProfile.handle] + : [reviewer.login]; +} + +export function toChangeRequest( + pull: GitCafePull, + target: RepositoryOnHost, +): ProviderChangeRequest { + return { + number: pull.number, + title: pull.title, + url: `https://${target.host}/${target.repository}/pulls/${pull.number}`, + author: toActor(pull.author, target.host), + headBranch: pull.sourceBranch, + baseBranch: pull.targetBranch, + state: pull.state, + isDraft: pull.draft, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: pull.createdAt, + updatedAt: pull.updatedAt, + reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) => + reviewerLogins(actor, target.host), + ), + labels: pull.labels ?? [], + }; +} + +/** What GitCafe's own capabilities let this viewer do with the pull request. */ +export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { + const { comment, edit, review, merge, moderate } = detail.capabilities; + // A locked conversation keeps its composer for moderators only. + const open = comment && (detail.lockedAt == null || moderate); + return { + actions: [ + // A merged pull request is settled; a closed one can only be reopened. + ...(edit && detail.state === "open" + ? ([detail.draft ? "ready" : "draft", "close"] as const) + : edit && detail.state === "closed" + ? (["reopen"] as const) + : []), + ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []), + ], + stackRebase: merge, + comment: open, + // Each thread's root narrows this further with its own `canResolve`. + resolve: open, + verdicts: review ? ["approve", "request-changes", "comment"] : [], + requestReviewers: false, + editChangeRequest: edit, + // GitCafe's only branch update moves a whole stack. + updateMethods: [], + }; +} + +const REACTIONS: Readonly> = { + "๐Ÿ‘": "thumbs-up", + "๐Ÿ‘Ž": "thumbs-down", + "๐Ÿ˜„": "laugh", + "๐ŸŽ‰": "hooray", + "๐Ÿ˜•": "confused", + "โค": "heart", + "๐Ÿš€": "rocket", + "๐Ÿ‘€": "eyes", +}; + +/** The emoji GitCafe takes for one of the shared reactions. */ +export const REACTION_EMOJI: Readonly> = { + "thumbs-up": "๐Ÿ‘", + "thumbs-down": "๐Ÿ‘Ž", + laugh: "๐Ÿ˜„", + hooray: "๐ŸŽ‰", + confused: "๐Ÿ˜•", + heart: "โค๏ธ", + rocket: "๐Ÿš€", + eyes: "๐Ÿ‘€", +}; + +/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ +export function reactionContent(emoji: string): PullRequestReactionContent | undefined { + return REACTIONS[emoji.replaceAll("\uFE0F", "")]; +} + +function toReactions(data: typeof GitCafeReactions.Type, host: string) { + const reactions: Array = []; + const byComment = new Map>(); + for (const item of data.items) { + const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined; + if (content === undefined) continue; + const reaction: PullRequestReaction = { + content, + count: item.count, + actors: item.reactors.flatMap((actor) => { + const reactor = toActor(actor, host); + return reactor === null ? [] : [reactor.login]; + }), + viewerHasReacted: item.viewerReactionId !== null, + }; + if (item.subject.kind === "pull_request") reactions.push(reaction); + else if (item.subject.kind === "pull_request_comment") + byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]); + } + return { reactions, byComment }; +} + +const REVIEW_STATES = { + approve: "approved", + request_changes: "changes_requested", + comment: "commented", +} as const; + +/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ +export function toActivity(input: { + readonly comments: typeof GitCafeComments.Type; + readonly reviews: typeof GitCafeReviews.Type; + readonly commits: typeof GitCafeCommits.Type; + readonly reactions?: typeof GitCafeReactions.Type; + readonly headOid?: string; + readonly host: string; +}): ProviderChangeRequestActivity { + const { host } = input; + const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host); + const remark = (comment: GitCafeComment) => ({ + id: comment.id, + author: toActor(comment.author, host), + body: comment.body ?? "", + createdAt: comment.createdAt, + url: null, + ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), + ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), + }); + // Stable, so remarks written in the same instant keep GitCafe's own order. + const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + const groups = new Map>(); + for (const comment of ordered) { + if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null) + continue; + groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]); + } + const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => { + const first = group[0]!; + const latest = group.at(-1)!; + const root = group.find((comment) => comment.id === comment.threadId); + return { + id: first.threadId, + path: first.path!, + line: first.line, + side: first.side!, + isResolved: root?.resolvedAt != null, + isOutdated: + input.headOid !== undefined && + latest.commitOid !== null && + latest.commitOid !== input.headOid, + comments: group.map(remark), + ...(root?.capabilities === undefined + ? {} + : { + canResolve: + root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve, + }), + }; + }); + const comments: Array = [ + ...ordered.map((comment) => ({ + ...remark(comment), + kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const), + path: comment.path, + reviewState: null, + })), + ...input.reviews.items.map((review) => ({ + id: review.id, + kind: "review" as const, + author: toActor(review.author, host), + body: review.body ?? "", + createdAt: review.createdAt, + url: null, + path: null, + reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict], + })), + ]; + return { + comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)), + commentCount: input.comments.items.length + input.reviews.items.length, + commentsTruncated: input.comments.next !== null || input.reviews.next !== null, + reviewThreads, + commits: input.commits.items.map((commit) => ({ + oid: commit.oid, + messageHeadline: commit.summary, + committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)), + })), + ...(reactions === undefined ? {} : { reactions: reactions.reactions }), + }; +} + +export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack { + return { + id: stack.id, + number: stack.number, + url: `https://${target.host}/${target.repository}/stacks/${stack.number}`, + base: stack.landingBase, + layers: stack.members + .toSorted((a, b) => a.position - b.position) + .map((member) => ({ + number: member.pullRequestNumber, + title: member.title, + headBranch: member.sourceBranch, + state: member.state, + isDraft: member.draft, + ...(member.headOid === undefined ? {} : { headSha: member.headOid }), + })), + }; +} + +/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ +export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { + const chunks: Array = []; + const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; + for (const file of diff.items) { + const oldPath = file.oldPath ?? file.path; + const a = quoteGitPatchPath(`a/${oldPath}`); + const b = quoteGitPatchPath(`b/${file.path}`); + const before = file.status === "added" ? "/dev/null" : a; + const after = file.status === "deleted" ? "/dev/null" : b; + const mode = file.isSubmodule ? "160000" : "100644"; + const lines = [`diff --git ${a} ${b}`]; + if (file.status === "added") lines.push(`new file mode ${mode}`); + if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); + if (file.status === "renamed" || file.status === "copied") { + const action = file.status === "renamed" ? "rename" : "copy"; + lines.push( + `${action} from ${quoteGitPatchPath(oldPath)}`, + `${action} to ${quoteGitPatchPath(file.path)}`, + ); + } + if (file.binary) { + lines.push(`Binary files ${before} and ${after} differ`); + } else if (file.tooLarge || file.hunksOmitted) { + omittedFileStats.push({ + path: file.path, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + }); + } else if (file.isSubmodule) { + const hasOld = file.status !== "added" && file.oldOid !== undefined; + const hasNew = file.status !== "deleted" && file.newOid !== undefined; + lines.push(`--- ${before}`, `+++ ${after}`); + lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`); + if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`); + if (hasNew) lines.push(`+Subproject commit ${file.newOid}`); + } else { + const hunks = file.hunks ?? []; + if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); + for (const hunk of hunks) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + for (const line of hunk.lines) { + // GitCafe marks a missing final newline with libgit2's own origins. + if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); + else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + } + } + } + chunks.push(lines.join("\n")); + } + return { + patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`, + truncated: diff.truncated === true || omittedFileStats.length > 0, + nextCursor: null, + ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }), + }; +} + +function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] { + if (check.status !== "completed") return "pending"; + switch (check.conclusion) { + case "success": + return "success"; + case "neutral": + return "neutral"; + case "skipped": + return "skipped"; + case "cancelled": + return "cancelled"; + case "action_required": + return "action-required"; + default: + return "failure"; + } +} + +export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray { + return checks.items.map((check) => ({ + name: check.name, + status: checkStatus(check), + description: check.summary, + url: check.detailsUrl, + })); +} diff --git a/packages/source-control-gitcafe/tsconfig.json b/packages/source-control-gitcafe/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/source-control-gitcafe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e44380dde31..249f45a453cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-gitcafe': + specifier: workspace:* + version: link:../../packages/source-control-gitcafe '@t3tools/source-control-github': specifier: workspace:* version: link:../../packages/source-control-github @@ -1643,6 +1646,37 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-gitcafe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-github: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7bc6da481716..272026efd71e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -41,6 +41,7 @@ const workspaceFiles = [ "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-gitcafe/package.json", "packages/source-control-github/package.json", "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json", From 500cb4266f2bb290aba2c2038295500ff7b25188 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:21:14 -0700 Subject: [PATCH 272/530] fix(web): add provider wizard no longer shifts sideways while it grows (#17292) --- apps/web/src/components/ui/wizard.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 695d4b047ff9..be2b10ab761c 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -4,6 +4,7 @@ import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; +import { ScrollArea } from "./scroll-area"; /** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ export function WizardPopup({ @@ -14,14 +15,10 @@ export function WizardPopup({ readonly size?: "default" | "wide"; }) { return ( - -

    + + +
    {children}
    +
    ); } From 42f71dfe0768ee768283d50140e7d360a635ee28 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:23:15 -0700 Subject: [PATCH 273/530] fix(web): PR search keeps the caret where you type (#17675) --- .../components/pullRequest/PullRequestListFilters.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index cbb0e01b9da3..1fb90f0b3052 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -104,6 +104,7 @@ export function PullRequestSearchInput({ busy?: boolean; onChange: (value: string) => void; }) { + const [draft, setDraft] = useState(null); return ( @@ -111,8 +112,13 @@ export function PullRequestSearchInput({ onChange(event.currentTarget.value)} + value={draft ?? value} + onFocus={() => setDraft(value)} + onBlur={() => setDraft(null)} + onChange={(event) => { + setDraft(event.currentTarget.value); + onChange(event.currentTarget.value); + }} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" /> From 6d5ea190a4a54b01dfe6500e032526839e3258d1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:24:11 -0700 Subject: [PATCH 274/530] fix(server): thread PR badges catch up when another environment reads the PR (#17729) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../pullRequest/PullRequestService.test.ts | 42 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 41 ++++++++++++++++++ apps/server/src/ws.ts | 1 + .../src/state/pullRequestRouting.ts | 22 ++++++++++ .../src/state/pullRequests.test.ts | 11 +++++ packages/contracts/src/pullRequest.ts | 11 +++++ packages/contracts/src/rpc.ts | 9 ++++ 9 files changed, 139 insertions(+) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..11756026f6d1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -135,6 +135,7 @@ export const RPC_REQUIRED_SCOPES = { // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsReportState]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope, // The candidate list is a read like the detail beside it; asking somebody for a review is a // write like every other one. diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792a..26a4b007b8b6 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -118,6 +118,7 @@ const RPC_AGGREGATES = { [WS_METHODS.pullRequestsSetReaction]: "pull-requests", [WS_METHODS.pullRequestsSetFilesViewed]: "pull-requests", [WS_METHODS.pullRequestsInvalidate]: "pull-requests", + [WS_METHODS.pullRequestsReportState]: "pull-requests", [WS_METHODS.pullRequestsSubscribeRefreshes]: "pull-requests", [WS_METHODS.pullRequestsReviewerCandidates]: "pull-requests", [WS_METHODS.pullRequestsRequestReviewers]: "pull-requests", diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c74faa92f06d..117fd3f69a32 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4972,6 +4972,48 @@ it.effect("announces state a detail read sees first or newly", () => }), ); +it.effect("announces a reported state without trusting it", () => + Effect.gen(function* () { + let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" }; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), + }), + ], + }); + const announced: Array = []; + yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => + Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), + ).pipe(Effect.forkChild({ startImmediately: true })); + const note = (state: "open" | "closed" | "merged") => + service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); + const readDetail = Effect.gen(function* () { + yield* service.invalidate({ reference }); + yield* service.detail(reference); + yield* Effect.yieldNow; + }); + + yield* readDetail; + yield* note("closed"); + yield* note("closed"); + assert.strictEqual(announced.length, 2); + yield* TestClock.adjust("5 minutes"); + yield* note("closed"); + assert.strictEqual(announced.length, 3); + + yield* note("open"); + assert.strictEqual(announced.length, 4); + + yield* note("merged"); + detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; + yield* readDetail; + assert.strictEqual(announced.length, 6); + }), +); + it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => Effect.gen(function* () { const gate = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index b44d713c3158..51377f1b33b1 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -42,6 +42,7 @@ import { type PullRequestFilesViewedResult, type PullRequestDiffResult, type PullRequestInvalidateInput, + type PullRequestReportStateInput, type PullRequestListEntry, type PullRequestListFilters, type PullRequestListInput, @@ -160,6 +161,8 @@ const detailTimeToLive = (state: PullRequestState | undefined) => * page and a watched pull request's change reaches its watch. */ const CHECKS_CACHE_TTL = Duration.seconds(15); +/** How long a repeated reported state is skipped, so a wrong one cannot hide a real change for long. */ +const REPORTED_STATE_DEDUPE_MS = 5 * 60 * 1_000; const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); @@ -244,6 +247,13 @@ export class PullRequestService extends Context.Service< never, Scope.Scope >; + /** + * The state a read routed to another environment saw. A new one reaches + * `subscribeStateChanges`, so the host is asked again; the report itself is never trusted. + */ + readonly reportState: ( + input: PullRequestReportStateInput, + ) => Effect.Effect; readonly subscribeRefreshes: Stream.Stream; readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; @@ -3115,6 +3125,36 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); + // The last state each routed read reported, kept apart from `detailStates`: an unverified report + // must never outrank a reading this environment made itself. + const reportedStates = new Map< + string, + { readonly state: PullRequestState; readonly atMs: number } + >(); + const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => + Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( + Effect.flatMap(([ref, nowMs]) => + Effect.suspend(() => { + const scope = refScope(ref); + const reported = reportedStates.get(scope); + if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) + return Effect.void; + reportedStates.delete(scope); + if (reportedStates.size >= REF_EPOCH_CAPACITY) { + const oldest = reportedStates.keys().next().value; + if (oldest !== undefined) reportedStates.delete(oldest); + } + reportedStates.set(scope, { state, atMs: nowMs }); + return ref.host === undefined + ? Effect.void + : PubSub.publish(stateChanges, { + host: ref.host, + repository: ref.repository, + number: ref.number, + }); + }), + ), + ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); // Record the summary from a host or cache read, not the stale value @@ -3432,6 +3472,7 @@ export const make = Effect.gen(function* () { subscribeStateChanges: PubSub.subscribe(stateChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), + reportState, subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe( Stream.filter((revision) => revision > 0), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fce520ca7e3b..c1a01b963a50 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2518,6 +2518,7 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.setThreadResolution(input)), [WS_METHODS.pullRequestsSetReaction]: (input) => withPullRequestViewer(input, pullRequests.setReaction(input)), + [WS_METHODS.pullRequestsReportState]: (input) => pullRequests.reportState(input), [WS_METHODS.pullRequestsInvalidate]: (input) => pullRequests.invalidate(input, { notifyReaders: true }).pipe( // A reader asking for fresh host state also wants the thread badges it feeds to diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index f8781837c361..2233dd8a6722 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -2,6 +2,7 @@ import { WS_METHODS, PullRequestRef, PullRequestInvalidateInput, + PullRequestState, type EnvironmentId, type PullRequestRoutingResult, type PullRequestRoutingIdentityResult, @@ -57,6 +58,7 @@ const writes = new Set([ WS_METHODS.pullRequestsSetLabels, ]); const isRef = Schema.is(PullRequestRef); +const hasState = Schema.is(Schema.Struct({ state: PullRequestState })); const isInvalidation = Schema.is(PullRequestInvalidateInput); const readTimeout = (environmentId: EnvironmentId) => Effect.timeoutOrElse({ @@ -208,6 +210,21 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi ); }); +/** + * The origin owns the threads linked to this pull request, but a routed read never reaches it. + * Tell it which state the read saw so it checks again. Best effort, and never holds up the read. + */ +const reportState = ( + { allowStale: _allowStale, expectedAccountId: _expectedAccountId, ...reference }: PullRequestRef, + state: PullRequestState, +) => + request(WS_METHODS.pullRequestsReportState, { reference, state }).pipe( + Effect.timeoutOption("5 seconds"), + Effect.ignore, + Effect.forkDetach, + Effect.asVoid, + ); + /** Credentials stay on their environments. Only a verified host and account cross the wire. */ export function createPullRequestRouter() { const routedRequest = Effect.fn("PullRequestRouting.request")(function* < @@ -356,6 +373,11 @@ export function createPullRequestRouter() { ? guardedSource : registry.run(id, request(tag, routedInput)) ).pipe( + Effect.tap((result) => + id !== origin.target.environmentId && hasState(result) + ? reportState(ref, result.state) + : Effect.void, + ), Effect.tap(() => Effect.sync(() => { const entry = used.get(refKey) ?? { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 8762e9edb399..75b76d8c3f4e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -995,6 +995,7 @@ it.effect.each( it.live("keeps source workspace metadata when an alternate answers a detail read", () => Effect.scoped( Effect.gen(function* () { + const noted = yield* Deferred.make(); const clientFor = (local: boolean) => ({ [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: () => @@ -1013,8 +1014,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "local project", workspaceRoot: "/Users/local/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", }) : Effect.never, + [WS_METHODS.pullRequestsReportState]: (input: unknown) => + Deferred.succeed(noted, input).pipe(Effect.asVoid), }) as unknown as WsRpcProtocolClient; const { environmentRegistry, supervisor } = yield* makeTestRuntime( clientFor(false), @@ -1035,6 +1040,12 @@ it.live("keeps source workspace metadata when an alternate answers a detail read projectTitle: "source project", workspaceRoot: "/srv/source/repo", title: "github title", + state: "closed", + updatedAt: "2026-10-10T01:52:36Z", + }); + expect(yield* Deferred.await(noted)).toEqual({ + reference: { projectId: "project-1", repository: "acme/web", number: 7 }, + state: "closed", }); }), ), diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1efbc1808d14..82e5dc1cd15f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -849,6 +849,17 @@ export const PullRequestInvalidateInput = Schema.Struct({ }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; +/** + * The state a read routed to another environment saw, reported to the environment the read was + * for so its thread links can catch up. A hint only: that environment confirms with the host + * before writing anything. + */ +export const PullRequestReportStateInput = Schema.Struct({ + reference: PullRequestRef, + state: PullRequestState, +}); +export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type; + export const PullRequestDetail = Schema.Struct({ provider: SourceControlProviderKind, capabilities: PullRequestCapabilities, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa..289b7041be16 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -171,6 +171,7 @@ import { PullRequestDiffFileContentsResult, PullRequestFilesViewedResult, PullRequestInvalidateInput, + PullRequestReportStateInput, PullRequestListInput, PullRequestListResult, PullRequestListStatsInput, @@ -543,6 +544,7 @@ export const WS_METHODS = { pullRequestsSetThreadResolution: "pullRequests.setThreadResolution", pullRequestsSetReaction: "pullRequests.setReaction", pullRequestsInvalidate: "pullRequests.invalidate", + pullRequestsReportState: "pullRequests.reportState", pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", @@ -1087,6 +1089,12 @@ const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, error: PullRequestRpcError, }); +const WsPullRequestsReportStateRpc = Rpc.make(WS_METHODS.pullRequestsReportState, { + payload: PullRequestReportStateInput, + success: Schema.Void, + error: PullRequestRpcError, +}); + const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { payload: Schema.Struct({}), success: NonNegativeInt, @@ -1916,6 +1924,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsSetThreadResolutionRpc, WsPullRequestsSetReactionRpc, WsPullRequestsInvalidateRpc, + WsPullRequestsReportStateRpc, WsPullRequestsSubscribeRefreshesRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, From bd2346eda2e2c380d1844869c7fd16c279d2190f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 00:36:04 -0700 Subject: [PATCH 275/530] fix(server): refuse editor paths with line breaks or quotes when the editor is a Windows command shim (#17749) Co-authored-by: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 89 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 13 +++ packages/contracts/src/editor.ts | 13 +++ 3 files changed, 115 insertions(+) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 6df4ea16db88..d16391a3ae70 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,7 +7,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -1381,6 +1383,93 @@ it.effect("keeps scanning after the caller is interrupted and shares that scan", ); }); +const launchWindowsShimEditor = (cwd: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); + let spawned: ChildProcess.StandardCommand | undefined; + const exit = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.launchEditor({ editor: "vscode", cwd }).pipe(Effect.exit); + }).pipe( + Effect.provide( + layerTest({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + return { exit, spawned }; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect.each([ + ["CRLF", "C:\\workspace\\file.ts\r\ncalc.exe"], + ["LF", "C:\\workspace\\file.ts\ncalc.exe"], + ["double quote", 'C:\\workspace\\file.ts" & calc.exe & "'], +] as const)("does not start a Windows command shim for a path with a $0", ([, cwd]) => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor(cwd); + assert.equal(spawned, undefined); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.instanceOf( + Cause.squash(exit.cause), + ExternalLauncher.ExternalLauncherUnsupportedTargetError, + ); + } + }), +); + +it.effect("starts a Windows command shim for unicode paths with cmd metacharacters", () => + Effect.gen(function* () { + const { exit, spawned } = yield* launchWindowsShimEditor( + "C:\\Users\\jรถ\\R&D (100%)\\naรฏve ^file!.ts:3:7", + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.ok(spawned); + assert.equal(spawned.options.shell, true); + assert.deepEqual(spawned.args, [ + '^"--goto^"', + '^"C:\\Users\\jรถ\\R^&D^ ^(100^%^)\\naรฏve^ ^^file^!.ts:3:7^"', + ]); + }), +); + +it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const zed = path.join(binDir, "zed"); + yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); + yield* fileSystem.chmod(zed, 0o755); + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir, HOME: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 74e95b9ddcc4..8ab130b01e38 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -14,6 +14,7 @@ import { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput, @@ -53,6 +54,7 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, + ExternalLauncherUnsupportedTargetError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { @@ -74,6 +76,14 @@ interface TargetPathAndPosition { readonly column: Option.Option; } +/** + * Windows command shims (`code.cmd`) forward arguments through `%*`, which + * cmd.exe parses a second time after the first escaping layer is gone: a + * line break ends the command there, and a double quote closes the quoting + * that keeps `&` or `|` literal. Neither can occur in a Windows path. + */ +// oxlint-disable-next-line no-control-regex +const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", @@ -726,6 +736,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu } const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env }); + if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) { + return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor }); + } yield* launchAndUnref( { command: spawnCommand.command, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75dca95786bf..7fa324a425ad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -283,6 +283,18 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError()( + "ExternalLauncherUnsupportedTargetError", + { + editor: EditorId, + }, +) { + override get message(): string { + return `${this.editor} cannot open a path containing line breaks or double quotes`; + } +} + const ExternalLauncherSpawnFields = { command: Schema.String, args: Schema.Array(Schema.String), @@ -318,6 +330,7 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, ExternalLauncherCommandNotFoundError, + ExternalLauncherUnsupportedTargetError, ExternalLauncherBrowserSpawnError, ExternalLauncherEditorSpawnError, ]); From c77a7b7eebd6ea8a3a63bbcaaa7c1707fe65c86e Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 03:34:14 -0500 Subject: [PATCH 276/530] fix(web): align compact button touch targets (#17748) --- .../BranchToolbarBranchSelector.tsx | 6 ++- .../src/components/ComposerContextLabel.tsx | 4 +- apps/web/src/components/GitActionsControl.tsx | 41 +++++++++++-------- .../src/components/ProjectScriptsControl.tsx | 9 ++-- .../chat/ComposerPrimaryActions.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 3 +- .../components/chat/ThreadDetailsPrRow.tsx | 5 ++- .../chat/threadDetailsPanelStyles.ts | 5 +++ .../web/src/components/ui/middle-truncate.tsx | 10 ++++- 9 files changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1d4185b9092f..7af46f2d47a2 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -779,7 +779,11 @@ export function BranchToolbarBranchSelector({ )} /> - + {displayMode !== "panel" ? ( diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 1abecfdf8ca8..8efcba43a75c 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "../lib/utils"; +import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyles"; /** Keeps text measurable while the composer's outer label box collapses. */ export function ComposerContextLabel({ @@ -16,7 +17,7 @@ export function ComposerContextLabel({ className={cn( "min-w-0", displayMode === "panel" - ? "flex-1 truncate text-left" + ? "flex-1 overflow-x-clip overflow-y-visible text-left" : "max-w-[240px] group-data-[compact]/composer-context:max-w-0", )} > @@ -24,6 +25,7 @@ export function ComposerContextLabel({ data-composer-label-motion className={cn( "block w-full min-w-0 truncate", + displayMode === "panel" && THREAD_DETAILS_PANEL_LABEL_CLASS, displayMode === "toolbar" && "max-w-[240px] transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none", )} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2b684cd0cd4e..2063767f315c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -127,6 +127,7 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; @@ -441,23 +442,25 @@ function GitActionProgressButtonContent({ aria-live="polite" className={cn( "grid min-w-0 flex-1 items-center", - // Pin the title row to the button's minimum content height (min-height - // minus vertical padding and border) so revealing the output row - // extends the button downward without re-centering โ€” the title must - // not shift. No row gap: the collapsed output row must contribute zero - // height so the single-line running button matches the static button - // exactly. The panel column gap matches the static row's icon-to-label - // distance (gap-2.5 plus the label's ml-0.5). In the panel the elapsed - // counter renders outside the button (in the menu-chevron slot), so - // there is no trailing column. + // Pin the title row to the button's minimum content height so output + // expands below it. Panel controls leave 24px after padding and border. + // No row gap: collapsed output must not add height. The panel's elapsed + // counter uses the menu-chevron slot, so it has no trailing column. isPanel - ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.75rem] gap-x-3" + ? "grid-cols-[auto_minmax(0,1fr)] grid-rows-[1.5rem] gap-x-3" : "grid-cols-[auto_minmax(0,1fr)_auto] grid-rows-[1.25rem] gap-x-2.5 sm:grid-rows-[1rem]", )} role="status" >