diff --git a/apps/server/package.json b/apps/server/package.json index c8d4062d60eb3..a610117eb1575 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "type": "module", "scripts": { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 5b34e06a61f31..1c9a24ff03cb3 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1186,6 +1186,33 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.skipIf(!symlinksSupported)( + "reports image attachment dimensions when the attachments directory is a symlink", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachments-link-" }); + const realAttachmentsDir = path.join(root, "real"); + const linkedAttachmentsDir = path.join(root, "linked"); + yield* fs.makeDirectory(realAttachmentsDir); + yield* fs.symlink(realAttachmentsDir, linkedAttachmentsDir); + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000041"; + yield* fs.writeFile(path.join(realAttachmentsDir, `${attachmentId}.png`), screenshotPng); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + attachmentsDir: linkedAttachmentsDir, + }), + ); + expect(result.imageDimensions).toEqual({ width: 390, height: 844 }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("serves document attachments inline when a viewer requests it", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c27aefc7a69cf..5092b31455eed 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -652,7 +652,10 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ? inlinePreviewMimeTypeForExtension(extension) : undefined; if (!isGenericFile) { - imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + imageDimensions = yield* fileSystem.realPath(attachmentPath).pipe( + Effect.flatMap(readImageDimensionsFromHeader), + Effect.orElseSucceed(() => null), + ); } claims = { version: 1, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index ce3b4d1a5f677..202807128fab1 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2588,7 +2588,45 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("applies a V2-native thread's project settings to generated commit text", () => + it.effect("uses an explicit project's provider settings for generated commit text", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nproject\n"); + const projectId = ProjectId.make("project:git-action-settings"); + const disabledInstanceId = DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId; + let generatedModelSelection: + | TextGeneration.CommitMessageGenerationInput["modelSelection"] + | undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [disabledInstanceId]: { enabled: false } }, + }, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedModelSelection = input.modelSelection; + return Effect.succeed({ subject: "Use the project writer", body: "" }); + }, + }, + }); + + yield* manager.runStackedAction({ + actionId: "test-project-action-settings", + cwd: repoDir, + action: "commit", + projectId, + }); + + expect(generatedModelSelection?.instanceId).toBe(ProviderInstanceId.make("claudeAgent")); + }), + ); + + it.effect("prefers a V2-native thread's project settings over an explicit project", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2675,6 +2713,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { cwd: repoDir, action: "commit", threadId, + projectId: ProjectId.make("project:other"), }); expect(generatedModelSelection).toEqual(projectModel); @@ -3900,7 +3939,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { return { repoDir, manager, providerCalls, firstCommit, pullHead }; }); - it.effect.each(["switch", "create-and-switch", "feature-branch"] as const)( + it.effect.each(["switch", "create-and-switch", "feature-branch", "commit"] as const)( "local PR checkout excludes a concurrent %s from a nested project with an alternate root", (operation) => Effect.gen(function* () { @@ -3950,7 +3989,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ...manager, runStackedAction: (input, options) => Effect.sync(() => { - events.push("feature-branch"); + events.push(input.featureBranch ? "feature-branch" : "commit"); NodeFS.writeFileSync( NodePath.join(repoDir, "second-client.txt"), "second client\n", @@ -3987,9 +4026,9 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }) : workflows[1]!.runStackedAction({ cwd: nestedCwd, - actionId: "second-client-feature-branch", + actionId: `second-client-${operation}`, action: "commit", - featureBranch: true, + featureBranch: operation === "feature-branch", commitMessage: "Second client commit", }), { startImmediately: true }, @@ -4002,15 +4041,16 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(events).toEqual([operation]); expect(prepared).toMatchObject({ branch: "feature/pr-safe", isOnPullRequestHead: true }); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainCommit); - expect((yield* runGit(repoDir, ["rev-parse", "feature/pr-safe"])).stdout.trim()).toBe( - pullHead, - ); + const pullBranch = operation === "commit" ? "feature/pr-safe^" : "feature/pr-safe"; + expect((yield* runGit(repoDir, ["rev-parse", pullBranch])).stdout.trim()).toBe(pullHead); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe( operation === "switch" ? "main" - : "branch" in switched - ? switched.branch.name - : "second-client", + : operation === "commit" + ? "feature/pr-safe" + : "branch" in switched + ? switched.branch.name + : "second-client", ); if ("commit" in switched) { expect(switched.commit.status).toBe("created"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index df315a913e58d..0d7165868b45c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -672,19 +672,25 @@ export const make = Effect.gen(function* () { const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; + readonly projectId?: ProjectId | undefined; }) { const settings = yield* serverSettingsService.getSettings; if (!hasProjectSettingsOverrides(settings)) return { settings, projectId: null as ProjectId | null }; - const projectId: ProjectId | null = yield* input.threadId !== undefined - ? threads.getThreadShell(input.threadId).pipe( - Effect.map((thread) => thread?.projectId ?? null), - Effect.orElseSucceed(() => null), - ) - : projects.findActiveByWorkspaceRoot(input.cwd).pipe( - Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), - Effect.orElseSucceed(() => null), - ); + const threadProjectId = + input.threadId === undefined + ? null + : yield* threads.getThreadShell(input.threadId).pipe( + Effect.map((thread) => thread?.projectId ?? null), + Effect.orElseSucceed(() => null), + ); + const projectId = + threadProjectId ?? + input.projectId ?? + (yield* projects.findActiveByWorkspaceRoot(input.cwd).pipe( + Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; }); const readThreadContext = Effect.fnUntraced( diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 6ee2899863063..b956755423af5 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -396,12 +396,10 @@ export const make = Effect.gen(function* () { runStackedAction: Effect.fn("GitWorkflowService.runStackedAction")(function (input, options) { return ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.flatMap((handle) => - input.featureBranch - ? withCheckoutLease( - handle.repository.rootPath, - gitManager.runStackedAction(input, options), - ) - : gitManager.runStackedAction(input, options), + withCheckoutLease( + handle.repository.rootPath, + gitManager.runStackedAction(input, options), + ), ), ); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e3de12f78eb0c..b07f0927a3827 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -200,6 +200,21 @@ function makeClaudeTestTurnInput(input: { } describe("ClaudeAdapterV2 runtime query policy", () => { + it.each([ + "-- --thinking-display omitted", + "--thinking-display summarized -- --thinking-display omitted", + ])("does not treat operands after -- as SDK flags: %s", (launchArgs) => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "thinking-thread", + resume: false, + cwd: "/workspace", + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + }); + assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" }); + assert.equal(options.extraArgs?.["thinking-display"], "summarized"); + }); + it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], @@ -218,6 +233,28 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]); }); + it("passes every --add-dir launch directory to Claude", () => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-add-dir", + resume: false, + cwd: "/workspace", + attachmentsDir: "/attachments", + settings: { + ...AUTO_COMPACT_CLAUDE_SETTINGS, + launchArgs: "--add-dir ../apps ../lib --verbose --add-dir=/shared", + }, + }); + assert.deepEqual(options.additionalDirectories, [ + "/workspace", + "/attachments", + "../apps", + "../lib", + "/shared", + ]); + assert.isUndefined(options.extraArgs?.["add-dir"]); + }); + it("gives delegated children Claude's subagent prompt cache TTL", () => { const lineage = (relationshipToParent: "fork" | "subagent" | null) => ({ lineage: { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index eac77647697c0..55ba6a47fedd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -130,6 +130,7 @@ import { type ClaudeSkillTarget, } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { + claudeLaunchArgDirectories, discoverClaudeSkills, skillOverrideSettingsPaths, } from "../../provider/Drivers/ClaudeSkills.ts"; @@ -1371,6 +1372,8 @@ export function makeClaudeQueryOptions(input: { }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const { + // claudeLaunchArgDirectories reads every --add-dir into additionalDirectories. + "add-dir": _launchArgAddDir, "permission-mode": launchArgPermissionMode, "dangerously-skip-permissions": launchArgSkipPermissions, "append-system-prompt": launchArgAppendSystemPrompt, @@ -1496,6 +1499,7 @@ export function makeClaudeQueryOptions(input: { const additionalDirectories = [ ...(input.cwd === null ? [] : [input.cwd]), ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]), + ...claudeLaunchArgDirectories(input.settings?.launchArgs), ]; const withDirectories = additionalDirectories.length === 0 ? options : { ...options, additionalDirectories }; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index fa9c3df423163..157cb14c4af25 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -16,6 +16,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, type OrchestrationV2TurnItem, ProjectId, type ProviderApprovalDecision, @@ -604,6 +605,7 @@ function codexReplayPreamble(input: { readonly nativeTurnId: string; readonly prompt: string; readonly sentPrompt?: string; + readonly effort?: string; }): Array { return [ { @@ -702,7 +704,14 @@ function codexReplayPreamble(input: { approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" }, summary: "detailed", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + ...(input.effort === undefined ? {} : { effort: input.effort }), + collaborationMode: { + mode: "default", + settings: { + model: "gpt-5.4", + ...(input.effort === undefined ? {} : { reasoning_effort: input.effort }), + }, + }, }, }, }, @@ -5160,6 +5169,73 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const MCP_APP_DEFERRED_RESULT = { + content: [{ type: "text", text: "An interactive form is being shown to the user." }], + structuredContent: { status: "awaiting_user_submission" }, + }; + const mcpAppDeferredTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: mcpAppTranscript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === "item/completed/app-tool" + ? { + ...entry, + frame: { + method: "item/completed", + params: { + item: { + ...mcpAppToolItem("inProgress"), + status: "failed", + result: MCP_APP_DEFERRED_RESULT, + }, + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + } + : entry, + ), + }); + + it.effect("captures the app of a tool call that defers to its form", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppDeferredTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "failed" + ? 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-deferred"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + assert.equal( + readMcpAppReference(output[MCP_APP_OUTPUT_KEY])?.resourceUri, + MCP_APP_RESOURCE, + ); + assert.deepEqual(output.result, { ...MCP_APP_DEFERRED_RESULT, isError: true }); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; @@ -6242,6 +6318,101 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each(["activity", "spawn", "activity then spawn"] as const)( + "creates a child from %s without the parent's effort", + (source) => + Effect.scoped( + Effect.gen(function* () { + const created = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const activity = { + type: "subAgentActivity", + id: "call-codex-resume-spawn", + kind: "started", + agentThreadId: RESUME_CHILD_THREAD, + agentPath: "/root/resume_agent", + }; + const spawn = { + type: "collabAgentToolCall", + id: "call-codex-resume-spawn", + tool: "spawnAgent", + status: "completed", + senderThreadId: RESUME_NATIVE_THREAD, + receiverThreadIds: [RESUME_CHILD_THREAD], + model: "gpt-5.4", + reasoningEffort: "high", + agentsStates: { [RESUME_CHILD_THREAD]: { status: "completed", message: "finished" } }, + }; + const items = + source === "activity" ? [activity] : source === "spawn" ? [spawn] : [activity, spawn]; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: RESUME_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: RESUME_NATIVE_THREAD, + nativeTurnId: RESUME_NATIVE_TURN, + prompt: RESUME_PROMPT, + effort: "low", + }), + ...items.map((item) => ({ + type: "emit_inbound" as const, + frame: { + method: "item/completed", + params: { + item, + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + })), + ], + }), + (event) => + event.type === "app_thread.created" + ? Deferred.succeed(created, event.appThread) + : event.type === "subagent.updated" && event.subagent.status === "completed" + ? Deferred.succeed(completed, event.subagent) + : Effect.void, + ); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-child-effort"), + text: RESUME_PROMPT, + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + const child = yield* Deferred.await(created); + assert.equal( + child.modelSelection.options?.find(({ id }) => id === "reasoningEffort")?.value, + source === "spawn" ? "high" : undefined, + ); + if (source !== "activity") { + const task = yield* Deferred.await(completed); + assert.equal( + task.modelSelection?.options?.find(({ id }) => id === "reasoningEffort")?.value, + "high", + ); + assert.lengthOf( + harness.events.filter((event) => event.type === "app_thread.created"), + 1, + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves a subagent result across a trailing empty final and resume", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 596830ffaa0da..9cd00c3331341 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2688,9 +2688,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly emitInitialPrompt: boolean; }) { const registeredSubagents = yield* Ref.get(subagentThreads); - if (registeredSubagents.has(input.nativeThreadId)) { - return; - } if (input.model && !subagentSelections.has(input.nativeThreadId)) { yield* updateSubagentSelection( input.nativeThreadId, @@ -2698,6 +2695,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( input.reasoningEffort, ); } + if (registeredSubagents.has(input.nativeThreadId)) { + return; + } const now = yield* DateTime.now; const subagentNodeId = idAllocator.derive.nodeFromProviderItem({ @@ -2774,9 +2774,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( parentNodeId: subagentNodeId, activeProviderThreadId: providerThread.id, providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: { + // A child's effort can differ from the parent's. Until Codex reports it, show none. + modelSelection: task.modelSelection ?? { ...input.context.input.modelSelection, model: task.model ?? input.context.input.modelSelection.model, + options: (input.context.input.modelSelection.options ?? []).filter( + ({ id }) => id !== "reasoningEffort", + ), }, title: subagentThreadTitle({ parentTitle: input.context.projectionAppThread.title, @@ -3711,7 +3715,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const reference = - item.status === "completed" && item.result != null && item.error == null + item.result != null && item.error == null ? yield* restore( getNativeThreadId(context.providerThread).pipe( Effect.flatMap((threadId) => @@ -3765,7 +3769,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? artifacts.turnItem : { ...artifacts.turnItem, - output: { [MCP_APP_OUTPUT_KEY]: reference, result: item.result }, + output: { + [MCP_APP_OUTPUT_KEY]: reference, + result: + item.status === "failed" + ? { ...item.result, isError: true } + : item.result, + }, }; yield* emitProviderEvent({ type: "node.updated", diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 4a11130e0070f..905c3f60d33fc 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -255,6 +255,120 @@ it.effect("marks a never-visited thread's first completion unread", () => }).pipe(Effect.provide(layerTest)), ); +const completeAndSnoozeThread = Effect.fn("test.completeAndSnoozeThread")(function* ( + name: string, + snoozeMinutes: number, +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${name}`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: "Snoozed completion", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`dispatch-${name}`), + threadId, + messageId: MessageId.make(`dispatch-${name}`), + text: "Run in the background", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`interrupt-${name}`), + threadId, + runId: (yield* projections.getThreadProjection(threadId)).runs[0]!.id, + }); + const completedAt = (yield* projections.getThreadProjection(threadId)).runs.at(-1)?.completedAt; + assert.ok(completedAt); + yield* TestClock.adjust("1 second"); + const snoozedUntil = DateTime.add(yield* DateTime.now, { minutes: snoozeMinutes }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`snooze-${name}`), + threadId, + snoozedUntil: DateTime.formatIso(snoozedUntil), + }); + return { threadId, completedAt, snoozedUntil }; +}); + +it.effect("mark unread retires an expired snooze so a dismissed wake stays dismissed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt, snoozedUntil } = yield* completeAndSnoozeThread( + "mark-unread-expired-snooze", + 1, + ); + yield* TestClock.adjust("2 minutes"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("dismiss-woke"), + threadId, + visitedAt: DateTime.formatIso(snoozedUntil), + }); + const dismissed = yield* projections.getThread(threadId); + assert.deepEqual(dismissed.lastVisitedAt, snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-expired-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...dismissed, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + snoozedUntil: null, + snoozedAt: null, + lastSnoozeWakeAt: snoozedUntil, + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("mark unread leaves a future snooze untouched", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt } = yield* completeAndSnoozeThread( + "mark-unread-future-snooze", + 60, + ); + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("visit-snoozed"), + threadId, + visitedAt: DateTime.formatIso(yield* DateTime.now), + }); + const visited = yield* projections.getThread(threadId); + assert.isNotNull(visited.snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-future-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...visited, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 9e3050206b546..47f822b70b126 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -140,6 +140,7 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, type OlderSettledThreadsListing, + type ActiveShellIndexEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -391,6 +392,11 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly countThreads: () => Effect.Effect; + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -3228,8 +3234,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } let markUnreadVisitedAt: DateTime.Utc | null = null; if (command.type === "thread.mark-unread") { - const projection = yield* loadProjectionForCommand(command, ["runs"]); - const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null; + const shell = yield* projectionStore + .getThreadShell(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const latestRunCompletedAt = shell?.latestRunCompletedAt ?? null; if (latestRunCompletedAt === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -3353,8 +3365,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: thread.updatedAt, }; } - case "thread.mark-unread": - return { ...thread, lastVisitedAt: markUnreadVisitedAt }; + case "thread.mark-unread": { + // Retire the wake before rewinding the visit, preserving its settlement timer. + const snoozeExpired = + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) <= DateTime.toEpochMillis(now); + return { + ...thread, + lastVisitedAt: markUnreadVisitedAt, + ...(snoozeExpired + ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: thread.snoozedUntil } + : {}), + }; + } case "thread.metadata.update": { const previousRecovery = thread.limitRecovery?.runId === command.limitRecovery?.runId && @@ -13417,6 +13440,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError(shellProjectionError)); }, ), + countThreads: Effect.fn("OrchestratorV2.countThreads")(function () { + return projectionStore.countThreads().pipe(Effect.mapError(shellProjectionError)); + }), + listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { + return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); + }), listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { return projectionStore .listOlderSettledThreads(query) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 93d591e147827..dd5da22f4d431 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -26,6 +26,7 @@ import { ThreadId, TurnItemId, type ThreadPullRequestLink, + isLiveSubagentShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -76,6 +77,33 @@ const traceSqlStatements = () => { return { statements, tracer }; }; +/** The shell index flags every live shell and matches its shells on the fields subscriber trims read. */ +const assertShellIndexCoversShells = Effect.fnUntraced(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const shells = (yield* store.getShellSnapshot({ location: "active" })).threads; + const index = yield* store.listActiveShellIndex(); + const iso = (value: DateTime.Utc | null | undefined) => + value == null ? null : DateTime.formatIso(value); + const trimmed = ( + row: Pick< + ProjectionStore.ActiveShellIndexEntry, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" + >, + ) => ({ + id: row.id, + projectId: row.projectId, + lineage: row.lineage, + settledOverride: row.settledOverride, + settledAt: iso(row.settledAt), + pinnedAt: iso(row.pinnedAt), + snoozedUntil: iso(row.snoozedUntil), + }); + assert.deepEqual(index.map(trimmed), shells.map(trimmed)); + shells.forEach((shell, position) => { + if (isLiveSubagentShell(shell)) assert.isTrue(index[position]?.mayBeLive, shell.id); + }); +}); + const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* ( suffix: string, ) { @@ -272,6 +300,7 @@ it.effect("SQL shells flag only an active turn blocked solely on T3 thread waits assert.equal(shell?.status, "running"); assert.equal(shell?.blockedOnThreadWait ?? false, expected); } + yield* assertShellIndexCoversShells(); }); const wait = { ...itemBase, @@ -531,6 +560,7 @@ it.effect("SQL shells use the latest subagent owner for duplicate provider-threa const listed = (yield* store.getShellSnapshot()).threads.find( (thread) => thread.id === targetThreadId, ); + yield* assertShellIndexCoversShells(); for (const shell of [yield* store.getThreadShell(targetThreadId), listed]) { assert.equal(shell?.providerInstanceId, otherInstanceId); assert.equal(shell?.activeRunId, null); @@ -1336,6 +1366,7 @@ it.effect("SQL list older settled threads below a cursor", () => const next = yield* list({ settledAt: DateTime.formatIso(at(4)), threadId: id("b") }, 10); assert.deepEqual(next.threadIds, [id("c"), id("d"), id("snoozed")]); assert.deepEqual(yield* list(before, 25, []), { threadIds: [], counts: [] }); + yield* assertShellIndexCoversShells(); const scoped = yield* list(before, 0, [ProjectId.make("project:b")]); assert.deepEqual(scoped, { threadIds: [], @@ -1374,6 +1405,70 @@ it.effect("lists only active subagent IDs without loading shell data", () => }).pipe(Effect.provide(layerTest)), ); +// regression: #25621 SQLite rejects statements binding more than 32,766 variables +it.effect("lists every thread in the shell snapshot past SQLite's bound variable limit", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const templateId = ThreadId.make("thread:many:00000"); + yield* store.apply({ + id: EventId.make(`event:${templateId}:created`), + type: "thread.created", + threadId: templateId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: templateId, + projectId: ProjectId.make("project:many"), + title: "Many", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: templateId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + const threadCount = 32_767; + yield* sql` + CREATE TEMP TABLE many_threads AS + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${threadCount - 1}) + SELECT t.*, n.i AS clone_index FROM orchestration_v2_projection_threads t, n + WHERE t.thread_id = ${templateId} + `; + yield* sql` + UPDATE many_threads SET + thread_id = printf('thread:many:%05d', clone_index), + payload_json = json_set( + payload_json, + '$.id', printf('thread:many:%05d', clone_index), + '$.lineage.rootThreadId', printf('thread:many:%05d', clone_index) + ) + `; + yield* sql`ALTER TABLE many_threads DROP COLUMN clone_index`; + yield* sql`INSERT INTO orchestration_v2_projection_threads SELECT * FROM many_threads`; + + const [unscoped, active] = yield* Effect.all([ + store.getShellSnapshot(), + store.getShellSnapshot({ location: "active" }), + ]); + assert.strictEqual(unscoped.threads.length, threadCount); + assert.strictEqual(active.threads.length, threadCount); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => @@ -3734,6 +3829,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( (row) => row.id === threadId, )!; + yield* assertShellIndexCoversShells(); for (const questionShell of [ sqlShell, ProjectionStore.threadShellFromProjection(projection), @@ -4796,6 +4892,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET status = 'running', payload_json = json_set(payload_json, '$.status', 'running') WHERE run_id = ${runId} `; + yield* assertShellIndexCoversShells(); const secretOnlyShell = (yield* projectionStore.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, ); @@ -6402,6 +6499,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (candidate) => candidate.id === threadId, ); assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks); + yield* assertShellIndexCoversShells(); return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase; }); @@ -6576,10 +6674,23 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: releasedAt, }, }); + const { statements, tracer } = traceSqlStatements(); assert.deepEqual( - (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + (yield* store + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.withTracer(tracer))).runs.map((run) => run.id), [runId, newerRunId], ); + // Live-item filters must hit the active partial indexes, not walk the thread's history. + const sql = yield* SqlClient.SqlClient; + for (const statement of statements.filter((query) => query.includes("turn_items"))) { + const plan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statement}`, + )).map((row) => row.detail); + for (const detail of plan) { + assert.notMatch(detail, /turn_items_(thread_ordinal|thread_run)_idx \(thread_id=\?\)$/); + } + } }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index dc3c8b119cdef..8cdcdc072ce21 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -393,6 +393,15 @@ export interface ShellSnapshotOptions { readonly threadIds?: ReadonlyArray; } +/** What a shell subscriber's trims read from an active thread, without building its shell. */ +export interface ActiveShellIndexEntry extends Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" +> { + /** False only when no run, request, work item, or watch can make the shell live. */ + readonly mayBeLive: boolean; +} + export interface OlderSettledThreadsQuery { readonly before: OrchestrationV2OlderSettledCursor; readonly projectIds?: ReadonlyArray | undefined; @@ -450,6 +459,13 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Threads that are not deleted, archived ones included. */ + readonly countThreads: () => Effect.Effect; + /** Every active thread, in shell order. */ + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, @@ -5939,7 +5955,7 @@ export const layer: Layer.Layer = worktreePath === undefined ? sql`` : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` - }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN ${sql.in(threadIds)}`} + }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -5952,14 +5968,14 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, run_id, ordinal FROM orchestration_v2_projection_runs - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellRunItemCounts = (threadIds: ReadonlyArray) => sql` SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE run_id IS NOT NULL AND thread_id IN ${sql.in(threadIds)} + WHERE run_id IS NOT NULL AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id `; @@ -5974,7 +5990,7 @@ export const layer: Layer.Layer = FROM ( SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id ) counts LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_id @@ -5991,7 +6007,7 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, payload_json FROM orchestration_v2_projection_provider_threads - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellPendingTurnItemRows = ( @@ -6007,7 +6023,7 @@ export const layer: Layer.Layer = WHERE i.type IN ('command_execution', 'dynamic_tool', 'subagent') AND i.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') AND (i.run_id IS NULL OR r.status <> 'rolled_back') - ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN ${sql.in(threadIds)}`} + ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ${ activeRunIds.length === 0 ? sql`` @@ -6016,7 +6032,7 @@ export const layer: Layer.Layer = UNION ALL SELECT i.thread_id, i.payload_json, NULL AS owning_provider_instance_id FROM orchestration_v2_projection_turn_items i - WHERE i.run_id IN ${sql.in(activeRunIds)} + WHERE i.run_id IN (SELECT value FROM json_each(${encodeIdList(activeRunIds)})) AND i.type IN ('file_change', 'file_search', 'web_search', 'compaction') AND i.status IN ('running', 'waiting') ` @@ -6613,6 +6629,98 @@ export const layer: Layer.Layer = Effect.mapError(shellSnapshotReadError), ); + const countThreads = Effect.fn("ProjectionStore.countThreads")(function* () { + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL + `; + return rows[0]?.count ?? 0; + }, Effect.mapError(shellSnapshotReadError)); + + const listActiveShellIndex = Effect.fn("ProjectionStore.listActiveShellIndex")(function* () { + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string; + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly settled_override: string | null; + readonly settled_at: string | null; + readonly pinned_at: string | null; + readonly snoozed_until: string | null; + readonly may_be_live: number; + }>` + SELECT + t.thread_id, + t.project_id, + json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, + json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, + json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.settledOverride') AS settled_override, + json_extract(t.payload_json, '$.settledAt') AS settled_at, + json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, + json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until, + -- A superset of isLiveSubagentShell: the sources of its run status, pending request, + -- and pending background work. + ( + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs r + WHERE r.thread_id = t.thread_id + AND r.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runtime_requests request + WHERE request.thread_id = t.thread_id AND request.status = 'pending' + ) + -- Same predicates as the shell's pending item and link reads, for their partial indexes. + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items item + WHERE item.thread_id = t.thread_id + AND item.type IN ('command_execution', 'dynamic_tool', 'subagent') + AND item.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items link_request + WHERE link_request.thread_id = t.thread_id + AND link_request.type = 'link_request' AND link_request.status = 'waiting' + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_provider_threads provider_thread + WHERE provider_thread.thread_id = t.thread_id + AND json_array_length(provider_thread.payload_json, '$.pendingBackgroundTasks') > 0 + ) + OR EXISTS ( + SELECT 1 FROM json_each(t.payload_json, '$.pullRequests') link + WHERE json_type(link.value, '$.watch') IS NOT NULL + ) + ) AS may_be_live + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.archivedAt') IS NULL + ORDER BY t.updated_at ASC, t.thread_id ASC + `; + return rows.map((row): ActiveShellIndexEntry => ({ + id: ThreadId.make(row.thread_id), + projectId: ProjectId.make(row.project_id), + lineage: { + parentThreadId: + row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), + relationshipToParent: + row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" + ? row.relationship_to_parent + : null, + rootThreadId: ThreadId.make(row.root_thread_id), + }, + settledOverride: + row.settled_override === "settled" || row.settled_override === "active" + ? row.settled_override + : null, + settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), + pinnedAt: row.pinned_at === null ? null : DateTime.makeUnsafe(row.pinned_at), + snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), + mayBeLive: row.may_be_live === 1, + })); + }, Effect.mapError(shellSnapshotReadError)); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( shareId: GuestShareId, ) { @@ -6767,6 +6875,8 @@ export const layer: Layer.Layer = apply, getShellSnapshot: readOnReader(getShellSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), + countThreads: readOnReader(countThreads), + listActiveShellIndex: readOnReader(listActiveShellIndex), readShellSnapshot: readOnReader(readShellSnapshot), listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index a39f6b7f036a6..1d79e87eb9b91 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -213,6 +213,8 @@ it.effect( getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), + countThreads: () => Effect.die("unused countThreads"), + listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 7a922d79c7317..beaf7961e3782 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1304,6 +1304,34 @@ effectIt.effect("leaves the run starting when a session-open failure will be ret }), ); +effectIt.effect("fails the run without a retry when the provider needs sign-in", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + openFailure: new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ProviderDriverKind.make("antigravity"), + providerSessionId: ProviderSessionId.make("new-provider-session"), + requiresSignIn: true, + }), + }); + + yield* harness.startWithRetry; + + expect(harness.open).toHaveBeenCalledOnce(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + expect(harness.projection().turnItems).toMatchObject([ + { + type: "error", + status: "failed", + failure: { + class: "provider_error", + message: "Failed to open antigravity provider session new-provider-session.", + }, + }, + ]); + }), +); + effectIt.effect("does not terminalize a provider-session open interruption", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", interruptOpen: true }); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 94ba406b106f5..fad06732fb48e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -92,6 +92,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError; @@ -56,7 +57,7 @@ export const bufferShellLiveStream = ( limits, ); -export function buildActiveShellSnapshot(input: { +function buildActiveShellSnapshot(input: { readonly projects: ReadonlyArray; readonly threads: OrchestrationV2ThreadShellSnapshot; readonly snapshotSequence: number; @@ -70,26 +71,119 @@ export function buildActiveShellSnapshot(input: { }; } +type ShellTrimRow = Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" +>; + /** - * Idle subagent children have no sidebar row, so clients that opt in read them from their thread - * family instead. A child stays while it is live or the parent of another child, so the request - * and working walks over shell rows never meet a gap in a nested chain. + * What a subscriber's trims read across every active row. Computed from full shells or from the + * store's index, so a snapshot read only for the rows it sends trims the same way. */ -export function omitIdleSubagentShells( - threads: ReadonlyArray, -): ReadonlyArray { - const parentIds = new Set(); - for (const thread of threads) { - if (thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId) { - parentIds.add(thread.lineage.parentThreadId); +interface ShellTrimFacts { + readonly subagentParentIds: ReadonlySet; + readonly subagentThreadIds: Set; + /** Undefined when no thread is settled, so none is older. */ + readonly olderSettledBeforeMs: number | undefined; + readonly olderSettledCounts: ReadonlyArray; +} + +const NEWEST_SETTLED_KEPT = 25; + +function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { + const subagentParentIds = new Set(); + const subagentThreadIds = new Set(); + const settledTimes: Array = []; + for (const row of rows) { + if (row.lineage.relationshipToParent === "subagent") { + subagentThreadIds.add(row.id); + if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); + } else if (row.settledOverride === "settled" && row.settledAt !== null) { + settledTimes.push(DateTime.toEpochMillis(row.settledAt)); + } + } + settledTimes.sort((left, right) => right - left); + // With fewer than 25, the oldest one is the cut, so nothing is older. + const olderSettledBeforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1); + const counts = new Map(); + if (olderSettledBeforeMs !== undefined) { + for (const row of rows) { + if (isOlderSettledThreadShell(row, olderSettledBeforeMs)) { + counts.set(row.projectId, (counts.get(row.projectId) ?? 0) + 1); + } } } + return { + subagentParentIds, + subagentThreadIds, + olderSettledBeforeMs, + olderSettledCounts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }; +} + +function subagentAncestorIds(threads: ReadonlyArray>) { + const ancestorIds = new Set(); + for (const thread of threads) { + if (thread.lineage.relationshipToParent !== "subagent") continue; + if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); + ancestorIds.add(thread.lineage.rootThreadId); + } + return ancestorIds; +} + +function omitIdleSubagentShellsWith( + threads: ReadonlyArray, + facts: Pick, +): ReadonlyArray { return threads.filter( (thread) => thread.lineage.relationshipToParent !== "subagent" || isLiveSubagentShell(thread) || - parentIds.has(thread.id), + facts.subagentParentIds.has(thread.id), + ); +} + +function omitOlderSettledShellsWith( + input: { + readonly threads: ReadonlyArray; + readonly snapshotSequence: number; + readonly now: DateTime.Utc; + }, + facts: Pick, +): { + readonly threads: ReadonlyArray; + readonly olderSettled: OrchestrationV2OlderSettledThreads; +} { + const nowMs = DateTime.toEpochMillis(input.now); + const beforeMs = facts.olderSettledBeforeMs ?? nowMs; + const ancestorIds = subagentAncestorIds(input.threads); + const threads = input.threads.filter( + (thread) => + !isOlderSettledThreadShell(thread, beforeMs) || + thread.pinnedAt != null || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || + isLiveSubagentShell(thread) || + ancestorIds.has(thread.id), ); + return { + threads, + olderSettled: { + before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), + asOfSequence: input.snapshotSequence, + counts: facts.olderSettledCounts, + }, + }; +} + +/** + * Idle subagent children have no sidebar row, so clients that opt in read them from their thread + * family instead. A child stays while it is live or the parent of another child, so the request + * and working walks over shell rows never meet a gap in a nested chain. + */ +export function omitIdleSubagentShells( + threads: ReadonlyArray, +): ReadonlyArray { + return omitIdleSubagentShellsWith(threads, shellTrimFacts(threads)); } /** @@ -103,18 +197,13 @@ export function activeShellRowsForSubscriber( readonly threads: ReadonlyArray; readonly subagentThreadIds: Set; } { + const facts = shellTrimFacts(threads); return { - threads: omitIdleSubagents ? omitIdleSubagentShells(threads) : threads, - subagentThreadIds: new Set( - threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ), + threads: omitIdleSubagents ? omitIdleSubagentShellsWith(threads, facts) : threads, + subagentThreadIds: facts.subagentThreadIds, }; } -const NEWEST_SETTLED_KEPT = 25; - /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while @@ -128,77 +217,107 @@ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; readonly olderSettled: OrchestrationV2OlderSettledThreads; } { - const nowMs = DateTime.toEpochMillis(input.now); - const settledTimes = input.threads - .flatMap((thread) => - thread.lineage.relationshipToParent !== "subagent" && - thread.settledOverride === "settled" && - thread.settledAt !== null - ? [DateTime.toEpochMillis(thread.settledAt)] + return omitOlderSettledShellsWith(input, shellTrimFacts(input.threads)); +} + +interface ShellSubscriberOptions { + readonly omitIdleSubagents: boolean; + readonly omitOlderSettled: boolean; +} + +function trimShellSnapshot( + base: OrchestrationV2ShellSnapshot, + facts: ShellTrimFacts, + options: ShellSubscriberOptions & { readonly now: DateTime.Utc }, +): OrchestrationV2ShellSnapshot { + const threads = options.omitIdleSubagents + ? omitIdleSubagentShellsWith(base.threads, facts) + : base.threads; + if (!options.omitOlderSettled) return { ...base, threads }; + const older = omitOlderSettledShellsWith( + { threads, snapshotSequence: base.snapshotSequence, now: options.now }, + facts, + ); + return { ...base, threads: older.threads, olderSettled: older.olderSettled }; +} + +/** + * The threads whose shells a trimmed snapshot can contain: a superset of what the trims keep, so + * trimming the shells read for these ids gives the snapshot trimming every shell would. + */ +function shellSubscriberThreadIds( + index: ReadonlyArray, + options: ShellSubscriberOptions, +): ReadonlyArray { + const facts = shellTrimFacts(index); + const subagents = index.filter( + (entry) => + entry.lineage.relationshipToParent === "subagent" && + (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), + ); + const ancestorIds = subagentAncestorIds(subagents); + const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); + const beforeMs = facts.olderSettledBeforeMs; + return index.flatMap((entry) => + entry.lineage.relationshipToParent === "subagent" + ? keptSubagentIds.has(entry.id) + ? [entry.id] + : [] + : !options.omitOlderSettled || + beforeMs === undefined || + !isOlderSettledThreadShell(entry, beforeMs) || + entry.pinnedAt != null || + entry.snoozedUntil != null || + entry.mayBeLive || + ancestorIds.has(entry.id) + ? [entry.id] : [], - ) - .sort((left, right) => right - left); - // With fewer than 25, the oldest one is the cut, so nothing is older. - const beforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1) ?? nowMs; - const ancestorIds = new Set(); - for (const thread of input.threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; - if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); - ancestorIds.add(thread.lineage.rootThreadId); - } - const counts = new Map(); - const threads = input.threads.filter((thread) => { - if (!isOlderSettledThreadShell(thread, beforeMs)) return true; - counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); - return ( - thread.pinnedAt != null || - (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || - isLiveSubagentShell(thread) || - ancestorIds.has(thread.id) - ); - }); - return { - threads, - olderSettled: { - before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), - asOfSequence: input.snapshotSequence, - counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), - }, - }; + ); } /** - * The snapshot a shell subscriber gets for its opt-ins, and the subagent ids whose transcript - * events its stream drops (from every row, so an omitted child that wakes keeps that filter). + * A subscriber's active shell snapshot, read like `loadShellSnapshotParts`. With an opt-in, the + * store's index decides which shells to read, so threads the trims drop are never built. */ -export function shellSnapshotForSubscriber( - base: OrchestrationV2ShellSnapshot, - options: { - readonly omitIdleSubagents: boolean; - readonly omitOlderSettled: boolean; - readonly now: DateTime.Utc; - }, -): { - readonly snapshot: OrchestrationV2ShellSnapshot; - readonly subagentThreadIds: Set; -} { - const rows = activeShellRowsForSubscriber(base.threads, options.omitIdleSubagents); - if (!options.omitOlderSettled) { +export const loadSubscriberShellSnapshot = Effect.fn("ShellStream.loadSubscriberShellSnapshot")( + function* (input: { + readonly sql: SqlClient.SqlClient; + readonly listShellIndex: Effect.Effect, E1>; + readonly readThreads: ( + threadIds: ReadonlyArray | undefined, + ) => Effect.Effect, E2>; + readonly listProjects: Effect.Effect, E4>; + readonly latestSequence: Effect.Effect; + readonly options: ShellSubscriberOptions; + }) { + const trims = input.options.omitIdleSubagents || input.options.omitOlderSettled; + const read = yield* input.sql + .withTransaction( + Effect.gen(function* () { + const index = trims ? yield* input.listShellIndex : null; + return { + index, + decodeThreads: yield* input.readThreads( + index === null ? undefined : shellSubscriberThreadIds(index, input.options), + ), + projects: yield* input.listProjects, + snapshotSequence: yield* input.latestSequence, + }; + }), + ) + .pipe(NodeSqliteClient.readOnly); + const base = buildActiveShellSnapshot({ + projects: read.projects, + threads: yield* read.decodeThreads, + snapshotSequence: read.snapshotSequence, + }); + const facts = shellTrimFacts(read.index ?? base.threads); return { - snapshot: { ...base, threads: rows.threads }, - subagentThreadIds: rows.subagentThreadIds, + snapshot: trimShellSnapshot(base, facts, { ...input.options, now: yield* DateTime.now }), + subagentThreadIds: facts.subagentThreadIds, }; - } - const older = omitOlderSettledShells({ - threads: rows.threads, - snapshotSequence: base.snapshotSequence, - now: options.now, - }); - return { - snapshot: { ...base, threads: older.threads, olderSettled: older.olderSettled }, - subagentThreadIds: rows.subagentThreadIds, - }; -} + }, +); /** * One transaction covers the reads so they agree; the threads are decoded after it commits, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index d3506649f6a62..95abcbaf5c1a8 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { makeSubagentChildThread, @@ -26,7 +27,9 @@ import { endRunlessRootTurns, } from "@t3tools/provider-core/server/subagentProjection"; -import { emptyProjection } from "./ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); const childThreadId = ThreadId.make("thread:subagent-awake-child"); @@ -117,8 +120,62 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it.effect.each([ + ["sql", ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))], +] as const)( + "%s: a pinned parent's subagent child is born unpinned and settles once idle", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const parentThread = { ...makeParentThread(), pinnedAt: parentCreatedAt, pinOrderKey: "a0" }; + const childThread = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: NodeId.make("node:subagent-parent"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + }); + + assert.equal(parentThread.pinnedAt, parentCreatedAt); + assert.equal(parentThread.pinOrderKey, "a0"); + assert.isNull(childThread.pinnedAt); + assert.isNull(childThread.pinOrderKey); + + for (const thread of [parentThread, childThread]) { + yield* store.apply({ + id: EventId.make(`event:${thread.id}`), + type: "thread.created", + threadId: thread.id, + occurredAt: thread.createdAt, + payload: thread, + }); + } + const savedParent = yield* store.getThread(parentThreadId); + assert.deepEqual(savedParent.pinnedAt, parentCreatedAt); + assert.equal(savedParent.pinOrderKey, "a0"); + const candidates = yield* store.getSettlementCandidates(); + assert.deepEqual( + candidates.map((candidate) => candidate.id), + [childThreadId], + ); + const settledAt = resolveAutoSettlementAt({ + thread: candidates[0]!, + pullRequest: null, + nowMs: DateTime.toEpochMillis(DateTime.add(childCreatedAt, { days: 4 })), + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + }); + assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt)); + }).pipe(Effect.provide(testLayer)), +); + function taskFixture() { - const projection = emptyProjection({ + const projection = ProjectionStore.emptyProjection({ type: "thread.created", id: EventId.make("task-fixture"), threadId: parentThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7a45a54a312f4..b68eef4b2a40c 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -514,12 +514,9 @@ const make = Effect.gen(function* () { // Update parents first so children inherit names; reread each pass for newly updated descendants. const visited = new Set([threadId]); while (true) { - const snapshot = yield* threads.getShellSnapshot(); + const snapshot = yield* threads.getShellSnapshot({ worktreePath: worktreeCwd }); const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => - !visited.has(thread.id) && - thread.worktreePath === worktreeCwd && - thread.branch === oldBranch, + (thread) => !visited.has(thread.id) && thread.branch === oldBranch, ); if (sharing.length === 0) break; for (const thread of sharing) { @@ -1066,12 +1063,14 @@ const make = Effect.gen(function* () { "ThreadLaunchService.prepareRun", )(function* (input) { const scheduled = yield* Effect.gen(function* () { - const projection = yield* threads.getThreadProjection(input.threadId); - const run = projection.runs.find((candidate) => candidate.id === input.runId); + const { thread, runs } = yield* threads.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + const run = runs.find((candidate) => candidate.id === input.runId); const workspacePreparation = run?.workspacePreparation; if (run?.status !== "preparing" || workspacePreparation === undefined) return; if (!(yield* reservePreparation(input.commandId))) return; - yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe( + yield* scheduleRunPreparation(input, thread, run, workspacePreparation).pipe( Effect.onError(() => releasePreparation(input.commandId)), ); }).pipe(Effect.exit); @@ -1093,30 +1092,33 @@ const make = Effect.gen(function* () { return dispatched; }); - const scheduleRunPreparation = ( + const scheduleRunPreparation = Effect.fn("ThreadLaunchService.scheduleRunPreparation")(function* ( input: ThreadLaunchPreparationInput, - projection: OrchestrationV2ThreadProjection, + thread: OrchestrationV2ThreadProjection["thread"], run: OrchestrationV2ThreadProjection["runs"][number], workspacePreparation: ThreadLaunchWorkspaceStrategy, - ) => { - const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + ) { + const { messages } = yield* threads.getThreadRecords(input.threadId, ["messages"], { + messageIds: [run.userMessageId], + }); + const message = messages.find((candidate) => candidate.id === run.userMessageId); const reuse = workspacePreparation.type === "worktree" && - projection.thread.worktreePath !== null && - projection.thread.branch !== null + thread.worktreePath !== null && + thread.branch !== null ? { strategy: { type: "existing_worktree" as const, - worktreePath: projection.thread.worktreePath, - branch: projection.thread.branch, + worktreePath: thread.worktreePath, + branch: thread.branch, }, reusedWorktree: { baseRef: workspacePreparation.baseRef }, } : null; - return schedulePreparation( + return yield* schedulePreparation( { commandId: input.commandId, - projectId: projection.thread.projectId, + projectId: thread.projectId, workspaceStrategy: reuse?.strategy ?? workspacePreparation, ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }), ...(message === undefined @@ -1132,7 +1134,7 @@ const make = Effect.gen(function* () { input.threadId, run.id, ); - }; + }); return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index fa70245fce768..664002ed619d6 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -395,10 +395,15 @@ export interface ThreadManagementServiceShape { ProjectionRecords, ThreadManagementProjectionLoadError | ThreadManagementThreadNotFoundError >; - readonly getShellSnapshot: (options?: { - readonly location?: "active" | "archive"; - }) => Effect.Effect; + readonly getShellSnapshot: ( + options?: Pick< + ShellSnapshotOptions, + "location" | "projectId" | "unsettledOnly" | "worktreePath" | "threadIds" + >, + ) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; + readonly countThreads: Orchestrator.OrchestratorV2["Service"]["countThreads"]; + readonly listActiveShellIndex: Orchestrator.OrchestratorV2["Service"]["listActiveShellIndex"]; readonly listOlderSettledThreads: Orchestrator.OrchestratorV2["Service"]["listOlderSettledThreads"]; readonly readShellSnapshot: ( options?: Pick, @@ -1334,6 +1339,8 @@ const make = Effect.gen(function* () { getProjectThreadRecords, getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, + countThreads: orchestrator.countThreads, + listActiveShellIndex: orchestrator.listActiveShellIndex, listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 57226c0c47858..7fb2b3369126f 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -179,6 +179,128 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => + Effect.scoped( + Effect.gen(function* () { + const parent = { + ...threadShell("parent-thread"), + branch: "feature/parent", + worktreePath: "/workspace/parent", + }; + const child: OrchestrationV2ThreadShell = { + ...threadShell("child-thread"), + branch: "t3/reply-ok", + worktreePath: "/workspace/child", + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent", + }, + }; + const project: OrchestrationProjectShell = { + id: parent.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const domainEvents = yield* Queue.unbounded(); + const sweeps = yield* Queue.unbounded(); + const childLookup = yield* Deferred.make(); + const lookups: Array<{ readonly branch: string; readonly refresh: boolean }> = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.fromQueue(domainEvents), + getShellSnapshot: () => + Queue.offer(sweeps, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [parent, child], + archivedThreads: [], + }), + ), + getThreadEventSequence: () => Effect.succeed(1), + getThreadShell: (threadId) => + Effect.succeed([parent, child].find((thread) => thread.id === threadId) ?? null), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + const refresh = options?.refresh ?? false; + lookups.push({ branch: input.branch, refresh }); + if (input.branch === child.branch && refresh) { + yield* Deferred.succeed(childLookup, undefined); + } + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({}), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + + // Startup backfill sweep, then one periodic sweep. + yield* Queue.take(sweeps); + yield* service.drain; + yield* TestClock.adjust("1 minute"); + yield* Queue.take(sweeps); + yield* service.drain; + const sweepLookups = lookups.map((lookup) => lookup.branch).toSorted(); + + // The child's own turn still discovers its pull request. + yield* Queue.offer(domainEvents, { + type: "checkpoint.captured", + threadId: child.id, + } as OrchestrationV2DomainEvent); + yield* Deferred.await(childLookup); + yield* service.drain; + + expect(sweepLookups).toEqual([parent.branch, parent.branch]); + expect(lookups.slice(sweepLookups.length)).toEqual([ + { branch: child.branch, refresh: true }, + ]); + }).pipe(Effect.provide(dependencies)); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 7b3c09ba15b8a..0db9feac8587e 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -139,9 +139,16 @@ export const make = Effect.gen(function* () { readThreadSnapshot(request), projectStore.listShells(), ]); + // Subagent children refresh after their own runs; sweeping them costs a lookup each per minute. + const snapshotThreads = + request.threadId === null + ? threadSnapshot.threads.filter( + (thread) => thread.lineage.relationshipToParent !== "subagent", + ) + : threadSnapshot.threads; const projects = new Map(projectShells.map((project) => [project.id, project])); if (request.backfill) { - for (const thread of threadSnapshot.threads) { + for (const thread of snapshotThreads) { if ( (thread.settledOverride === "settled" || thread.settledAt !== null) && thread.branchPullRequest == null @@ -151,13 +158,13 @@ export const make = Effect.gen(function* () { } } const visibleThreadIds = new Set( - threadSnapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + snapshotThreads.filter((thread) => thread.branch !== null).map((thread) => thread.id), ); const checkedIds = request.threadId === null ? [...pendingBackfill.keys()] : [request.threadId]; for (const threadId of checkedIds) { if (!visibleThreadIds.has(threadId)) pendingBackfill.delete(threadId); } - const threads = threadSnapshot.threads.filter( + const threads = snapshotThreads.filter( (thread) => thread.archivedAt === null && ((thread.settledOverride !== "settled" && thread.settledAt === null) || diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index eb6a2ceb58138..526e0ed729e8d 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -842,6 +842,29 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("stops settling the rest of a sweep once auto-settlement is turned off", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + // Sweeps settle 8 threads at a time, so the ninth starts after a dispatch turned it off. + const threads = Array.from({ length: 9 }, (_, index) => makeThread(`idle-${index}`)); + let disable: Effect.Effect = Effect.void; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot(threads), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 }, + onDispatch: () => disable, + }); + disable = fixture.updateSettings({ sidebarAutoSettleAfterDays: null }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + const settled = (yield* Ref.get(fixture.commands)).map((command) => command.threadId); + expect(settled).not.toContain(threads[8]!.id); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("reevaluates immediately after a pull request merge", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 509dcd0307dc2..6a3d4fffb0399 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -321,8 +321,10 @@ export const make = Effect.gen(function* () { // Merge events resweep all candidates because unlinked threads settle through branch lookup. const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); - const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")( + // Runs per candidate thread, thousands per sweep on busy servers; the sweep owns the span. + const settleThread = Effect.fnUntraced( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + // thread.auto-settle does not recheck settings, so read them per decision; reads are cached. const currentSettings = resolveProjectSettings( yield* settingsService.getSettings, thread.projectId, @@ -414,7 +416,8 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); - const wouldSettle = Effect.fn("ThreadSettlementServiceV2.wouldSettle")(function* ( + // wouldSettle and pullRequestFor run per lookup group; the git and pull request reads they make carry the spans. + const wouldSettle = Effect.fnUntraced(function* ( group: ReadonlyArray<(typeof lookupCandidates)[number]>, pullRequest: SettlementPullRequest, ) { @@ -435,7 +438,7 @@ export const make = Effect.gen(function* () { }); }); - const pullRequestFor = Effect.fn("ThreadSettlementServiceV2.pullRequestFor")(function* ( + const pullRequestFor = Effect.fnUntraced(function* ( group: ReadonlyArray<(typeof lookupCandidates)[number]>, ) { const thread = group[0]!; diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 3c4baab80ef07..80ee5c32d6152 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -10,7 +10,10 @@ import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { + capProjectionCheckpointFilesForWire, + projectThreadProjectionForWire, +} from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; @@ -44,10 +47,9 @@ export function boundedSnapshotResponseFields(input: { readonly compactIds: boolean; }) { const { bounded } = input; + const capped = capProjectionCheckpointFilesForWire(bounded.projection); // Dedupe keeps shared items one object, which the compaction's identity check needs. - const projection = input.dedupeBodies - ? dedupeSnapshotBodies(bounded.projection) - : bounded.projection; + const projection = input.dedupeBodies ? dedupeSnapshotBodies(capped) : capped; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(projection) : null; return { projection: compactProjection ?? projection, diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 22f6f8493c95c..d8edfcf78a892 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -1,4 +1,7 @@ import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, MessageId, ContextHandoffId, ProviderThreadId, @@ -400,6 +403,46 @@ describe("live body deduper", () => { ]); }); + it("caps a large checkpoint's files for the wire and still dedupes its item", () => { + const files = Array.from({ length: 250 }, (_, index) => ({ + path: `vendor/file-${index}.ts`, + kind: "modified", + additions: 2, + deletions: 1, + })); + const checkpointId = CheckpointId.make("checkpoint-large"); + const scopeId = CheckpointScopeId.make("scope-large"); + const captured = event(1, { + type: "checkpoint.captured", + payload: { + id: checkpointId, + threadId: base.threadId, + scopeId, + runId: null, + nodeId: NodeId.make("node-large"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: null, + ref: CheckpointRef.make("refs/t3/checkpoints/large"), + status: "ready", + files, + capturedAt: at, + }, + }); + const item = event(2, { + type: "turn-item.updated", + payload: { ...base, type: "checkpoint", checkpointId, scopeId, files }, + }); + const dedupe = makeLiveBodyDeduper(); + const [record, card] = [captured, item].map((streamed) => + dedupe({ ...streamed, event: projectDomainEventForWire(streamed.event) }), + ); + + const filesTotal = { count: 250, additions: 500, deletions: 250 }; + expect(payloadOf(record!)).toMatchObject({ files: files.slice(0, 200), filesTotal }); + expect(payloadOf(card!)).toMatchObject({ files: [], filesOmitted: true, filesTotal }); + }); + it("ships a reply whole when its message arrives after it", () => { const first = reply(1, "a"); expect([first, message(2, "a")].map(makeLiveBodyDeduper())[0]).toBe(first); diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index f33e3a04c0e44..a040f0816baf0 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -22,6 +22,8 @@ import { htmlRenderFromBridgeCommand, } from "@t3tools/shared/toolOutput"; +// A checkpoint over vendored or generated trees can list 15k+ files; the card needs a prefix. +const MAX_WIRE_CHECKPOINT_FILES = 200; const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; const MAX_ON_DEMAND_BYTES = 256 * 1024; @@ -97,8 +99,59 @@ function summarizeDynamicValue(value: unknown): unknown { }; } +type CheckpointFiles = Pick; + +// Same input, same output object: snapshot compaction relies on shared item identity. +const cappedCheckpointFiles = new WeakMap(); + +function capCheckpointFiles(value: T): T { + if (value.files.length <= MAX_WIRE_CHECKPOINT_FILES || value.filesTotal !== undefined) { + return value; + } + const cached = cappedCheckpointFiles.get(value); + if (cached !== undefined) return cached as T; + let additions = 0; + let deletions = 0; + for (const file of value.files) { + additions += file.additions; + deletions += file.deletions; + } + const capped = { + ...value, + files: value.files.slice(0, MAX_WIRE_CHECKPOINT_FILES), + filesTotal: { count: value.files.length, additions, deletions }, + }; + cappedCheckpointFiles.set(value, capped); + return capped; +} + +/** Idempotent; bounded snapshots that skip `projectThreadProjectionForWire` still need it. */ +export function capProjectionCheckpointFilesForWire( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const checkpoints = projection.checkpoints.map(capCheckpointFiles); + const turnItems = projection.turnItems.map(capCheckpointItemFiles); + const visibleTurnItems = projection.visibleTurnItems.map((row) => { + const item = capCheckpointItemFiles(row.item); + return item === row.item ? row : { ...row, item }; + }); + const unchanged = (next: ReadonlyArray, previous: ReadonlyArray) => + next.every((value, index) => value === previous[index]); + return unchanged(checkpoints, projection.checkpoints) && + unchanged(turnItems, projection.turnItems) && + unchanged(visibleTurnItems, projection.visibleTurnItems) + ? projection + : { ...projection, checkpoints, turnItems, visibleTurnItems }; +} + +function capCheckpointItemFiles(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + return item.type === "checkpoint" ? capCheckpointFiles(item) : item; +} + export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { + case "checkpoint": + return capCheckpointFiles(item); case "handoff": { const { summary: _summary, ...projected } = item; return projected; @@ -249,6 +302,7 @@ export function projectThreadProjectionForWire( }; return { ...projection, + checkpoints: projection.checkpoints.map(capCheckpointFiles), contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire), turnItems: projection.turnItems.map(project), visibleTurnItems: projection.visibleTurnItems.map((row) => ({ @@ -265,7 +319,9 @@ export function projectDomainEventForWire( ? { ...event, payload: projectTurnItemForWire(event.payload) } : event.type === "context-handoff.updated" ? { ...event, payload: projectContextHandoffForWire(event.payload) } - : event; + : event.type === "checkpoint.captured" + ? { ...event, payload: capCheckpointFiles(event.payload) } + : event; } const LIVE_DEDUPE_MEMORY = 32; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 05030b6508a6b..cdf15026b11e4 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6255,6 +6255,114 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { assert.instanceOf(invalidVisit, Orchestrator.OrchestratorDispatchError); }), ); + + it.effect("marks unread before the user's last turn when an automatic compaction followed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-mark-unread-compacted"); + const turnCompletedAt = DateTime.makeUnsafe("2026-07-24T10:00:00.000Z"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Compacted thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const runs = [ + { text: "Continue", at: turnCompletedAt }, + { + text: "/compact", + idleCompaction: "beforeCacheExpires" as const, + at: DateTime.makeUnsafe("2026-07-24T11:00:00.000Z"), + }, + ]; + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make(`${threadId}:seed`), + events: runs.flatMap(({ at, ...message }, index) => { + const runId = RunId.make(`${threadId}:run-${index}`); + const messageId = MessageId.make(`${threadId}:message-${index}`); + return [ + { + id: EventId.make(`${messageId}:event`), + type: "message.updated" as const, + threadId, + occurredAt: at, + payload: { + ...message, + id: messageId, + threadId, + runId, + nodeId: null, + role: "user" as const, + attachments: [], + streaming: false, + createdBy: "user" as const, + creationSource: "web" as const, + createdAt: at, + updatedAt: at, + }, + }, + { + id: EventId.make(`${runId}:event`), + type: "run.created" as const, + threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: index + 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed" as const, + queuePosition: null, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }, + ]; + }), + }); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make(`${threadId}:mark-unread`), + threadId, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.thread.lastVisitedAt, + DateTime.subtract(turnCompletedAt, { milliseconds: 1 }), + ); + const shell = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(shell!.latestRunCompletedAt, turnCompletedAt); + assert.deepEqual(shell!.lastVisitedAt, projection.thread.lastVisitedAt); + + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`${threadId}:visit`), + threadId, + visitedAt: DateTime.formatIso(turnCompletedAt), + }); + const revisited = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(revisited!.lastVisitedAt, turnCompletedAt); + }), + ); }); it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) => { diff --git a/apps/server/src/persistence/DatabaseStorage.ts b/apps/server/src/persistence/DatabaseStorage.ts index b0e305d65e918..d8123e2e9400c 100644 --- a/apps/server/src/persistence/DatabaseStorage.ts +++ b/apps/server/src/persistence/DatabaseStorage.ts @@ -33,10 +33,24 @@ const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // Only VACUUM changes these, and doctor refuses to VACUUM while a server has the database open. + let layout: { readonly pageSize: number; readonly incrementalVacuum: boolean } | undefined; + const readLayout = Effect.suspend(() => + layout !== undefined + ? Effect.succeed(layout) + : Effect.gen(function* () { + const size = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; + const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; + layout = { + pageSize: size[0]?.page_size ?? 0, + incrementalVacuum: mode[0]?.auto_vacuum === 2, + }; + return layout; + }), + ); const reclaim = Effect.gen(function* () { - const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; - if (mode[0]?.auto_vacuum !== 2) return; + if (!(yield* readLayout).incrementalVacuum) return; // The driver steps once per statement; incremental_vacuum frees one page per step. Yield between pages. for (let page = 0; page < RECLAIM_PAGES_PER_PASS; page++) { const free = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1b9f7394656c4..c6db28e9e6d56 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -68,6 +68,7 @@ import { reconcileCoderMigrationHistory } from "./CoderMigrationHistory.ts"; import Migration0068 from "./Migrations/068_ShellStopRequestIndex.ts"; import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; // Coder: T3 Coder has no auth sessions or pairing. Upstream's auth migrations keep // their IDs so later upstream migrations stay aligned, but they create nothing. @@ -150,6 +151,7 @@ export const migrationEntries = [ [70, "PeerLinks", omittedPeerLinkMigration], [71, "PeerLinkFence", omittedPeerLinkMigration], [72, "WaitingLinkRequestIndex", Migration0072], + [73, "ActiveTurnItemIndexes", Migration0073], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts new file mode 100644 index 0000000000000..d9759bca6d82a --- /dev/null +++ b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Recovery and provider-thread reads filter live items by this exact predicate; without + // these, each read walked every historical item in the thread. + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_ordinal_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE status IN ('pending', 'running', 'waiting') + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_run_idx + ON orchestration_v2_projection_turn_items(thread_id, run_id) + WHERE status IN ('pending', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 45b28834a4ad7..940d626514336 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -5,12 +5,57 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "./Sqlite.ts"; +// Linux names an open, already-deleted file " (deleted)" in /proc/self/fd. +// SQLite names its temp files "etilqs_*" and, without TMPDIR, puts them in +// /var/tmp while os.tmpdir() is /tmp, so match the name, not the directory. +const openDeletedTempFiles = () => + NodeFS.readdirSync("/proc/self/fd").flatMap((fd) => { + try { + const target = NodeFS.readlinkSync(`/proc/self/fd/${fd}`); + return target.includes("/etilqs_") && target.endsWith(" (deleted)") ? [target] : []; + } catch { + return []; + } + }); + +it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "keeps a write transaction's statement journal off the temp directory", + () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-temp-")); + const persistence = SqlitePersistence.layerFromPath( + NodePath.join(tempDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE journal_probe(id INTEGER PRIMARY KEY, payload TEXT NOT NULL)`; + for (let id = 0; id < 64; id++) { + yield* sql`INSERT INTO journal_probe VALUES (${id}, ${"x".repeat(4096)})`; + } + const before = new Set(openDeletedTempFiles()); + const opened = yield* sql.withTransaction( + Effect.gen(function* () { + // A multi-row update can fail halfway, so SQLite journals every page + // it changes, here well past the 64 KiB it holds in memory by default. + yield* sql`UPDATE journal_probe SET payload = payload || 'y'`; + return openDeletedTempFiles().filter((target) => !before.has(target)); + }), + ); + assert.deepEqual(opened, []); + }).pipe( + Effect.provide(persistence), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }, +); + it.effect("drops planner statistics a database already has", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); const dbPath = NodePath.join(tempDir, "state.sqlite"); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index 48c1ab8fc8913..2f21ea0a8a942 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -28,6 +28,9 @@ const layerSetup = Layer.effectDiscard( yield* sql`PRAGMA synchronous = NORMAL;`; // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; + // Keep statement journals and sort spills off the main thread's disk path. + // Large statements and open savepoints can increase peak memory use. + yield* sql`PRAGMA temp_store = MEMORY;`; // PASSIVE checkpoints retain peak WAL size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index 561cd72ea9f71..ec8ded1cab5ac 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +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"; @@ -182,3 +183,28 @@ it.effect("starts fresh without V1 and never imports over existing V2 state", () Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), ); }); + +it.effect("removes copies left by a killed start, but not one that is still running", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-abandoned-")); + const killed = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + const running = `.v2-import-${process.pid}-Kq81Zx`; + const unrelated = ".v2-import-not-a-copy"; + return Effect.gen(function* () { + for (const name of [`.v2-import-${killed}-Wm3nPa`, ".v2-import-Q3xZ9a", running]) { + NodeFS.mkdirSync(NodePath.join(directory, name)); + NodeFS.writeFileSync(NodePath.join(directory, name, "snapshot.sqlite"), "partial copy"); + } + NodeFS.mkdirSync(NodePath.join(directory, unrelated)); + const destinationPath = NodePath.join(directory, "statev2.sqlite"); + NodeFS.writeFileSync(destinationPath, "existing V2 database"); + yield* initializeV2Database(destinationPath); + assert.deepEqual( + NodeFS.readdirSync(directory).sort(), + [running, unrelated, "statev2.sqlite"].sort(), + ); + assert.equal(NodeFS.readFileSync(destinationPath, "utf8"), "existing V2 database"); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), + ); +}); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 755907b5076f9..4f6d8d969c3ec 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -14,6 +14,33 @@ export class V2DatabaseImportError extends Schema.TaggedError { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +// Legacy names have no PID; newer copies stay while their process is alive. +const removeAbandonedCopies = Effect.fn("removeAbandonedCopies")(function* (directory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + for (const entry of entries) { + const copy = /^\.v2-import-(?:(\d+)-)?[A-Za-z0-9]{6}$/.exec(entry); + if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue; + yield* fs.remove(path.join(directory, entry), { recursive: true }); + } +}); + /** Its copied legacy tables remain the source for lazy transcript import. */ export const initializeV2Database = Effect.fn("initializeV2Database")(function* ( destinationPath: string, @@ -22,12 +49,13 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* const path = yield* Path.Path; const directory = path.dirname(destinationPath); const sourcePath = path.join(directory, "state.sqlite"); + yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true })); yield* Effect.gen(function* () { if (yield* fs.exists(destinationPath)) return; if (!(yield* fs.exists(sourcePath))) return; const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ directory, - prefix: ".v2-import-", + prefix: `.v2-import-${process.pid}-`, }); const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite"); yield* Effect.tryPromise(async () => { diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 91e40578091a7..c08062c09d1ad 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -52,6 +52,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -147,6 +148,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 85351585b72a6..2738cc37bc26d 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -457,15 +457,13 @@ export const make = Effect.gen(function* () { ) { const { projectId } = input; const snapshot = yield* threadProjections - .getShellSnapshot() + .getShellSnapshot({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), ), ); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads]; if (projectThreads.length > 0 && input.force !== true) { return yield* new ProjectNotEmptyError({ projectId }); } diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 2897cc0b84282..860280aa91e89 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -358,6 +358,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { '--add-dir "extra skills"', '--add-dir="extra skills"', `--add-dir missing --add-dir "${extraDir}"`, + `--add-dir "${extraDir}" --add-dir missing`, + `--add-dir missing "${extraDir}"`, ]) { const settings = { homePath: configDir, launchArgs }; const skills = yield* discoverClaudeSkills(settings, workspace); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index fece7e0d0bcad..f5b3b9abe3756 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { parse as parseYamlDocument } from "yaml"; @@ -428,6 +428,26 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); +// Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. +export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { + const directories: string[] = []; + let collecting = false; + for (const token of tokenizeCliArgs(launchArgs)) { + if (token === "--") break; + if (token === "--add-dir") { + collecting = true; + } else if (token.startsWith("--add-dir=")) { + directories.push(token.slice("--add-dir=".length)); + collecting = false; + } else if (token.startsWith("-")) { + collecting = false; + } else if (collecting) { + directories.push(token); + } + } + return directories; +} + /** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, @@ -443,22 +463,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), - ...(additionalDirectory && (cwd || path.isAbsolute(additionalDirectory)) - ? [ - { - directory: path.join( - path.resolve(cwd ?? ".", additionalDirectory), - ".claude", - "skills", - ), - scope: "project" as const, - }, - ] - : []), + ...claudeLaunchArgDirectories(config.launchArgs) + .filter((directory) => directory && (cwd || path.isAbsolute(directory))) + .map((directory) => ({ + directory: path.join(path.resolve(cwd ?? ".", directory), ".claude", "skills"), + scope: "project" as const, + })), ...(yield* discoverPluginSkillRoots( configDirPath, cwd, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index a7f282256cf45..e067d1a31f696 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -115,6 +115,107 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ); + it.effect.skipIf(windowsHost)( + "runs the standalone updater against the home that holds a shared install", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + // One standalone install, linked onto PATH and used by two account homes. + // `İ` gets longer when lowercased, so the home must not be sliced from a + // lowercased copy of the path. + const installHome = NodePath.join(tempDir, "codex-İnstall"); + const realBinaryPath = NodePath.join( + installHome, + "packages", + "standalone", + "releases", + "0.162.1-x86_64-unknown-linux-musl", + "bin", + "codex", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const updates = yield* Effect.forEach(["codex-p", "codex-vm"], (account) => + Effect.gen(function* () { + const homePath = NodePath.join(tempDir, `home-${account}`); + yield* fs.makeDirectory(homePath, { recursive: true }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(account), + displayName: account, + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath }, + }); + return (yield* instance.snapshot.resolveMaintenance()).update; + }), + ); + + for (const update of updates) { + expect(update).toMatchObject({ + command: `${binaryPath} update`, + args: ["update"], + lockKey: "codex-native", + env: { CODEX_HOME: installHome }, + }); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + + it.effect.skipIf(windowsHost)("derives the standalone update home from either command path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-update-home-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const linkedInstallHome = NodePath.join(tempDir, "install"); + const fixtures = [ + { binary: "codex", realPath: "/packages/standalone/bin/codex", home: "/" }, + { binary: "codex", realPath: "C:\\Packages\\Standalone\\bin\\codex.exe", home: "C:\\" }, + // The standalone tree itself is a symlink, so only the resolved path names its home. + { + binary: NodePath.join("install", "packages", "standalone", "bin", "codex"), + realPath: "/disk/codex/releases/0.163.0/bin/codex", + home: linkedInstallHome, + }, + ]; + + for (const fixture of fixtures) { + const binaryPath = NodePath.join(tempDir, fixture.binary); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-update-home"), + displayName: "Codex update home", + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: tempDir }, + }).pipe( + // Virtualize only the executable target: these trees do not belong to this test. + Effect.provideService(FileSystem.FileSystem, { + ...fs, + realPath: (path) => + path === binaryPath ? Effect.succeed(fixture.realPath) : fs.realPath(path), + }), + ); + expect((yield* instance.snapshot.resolveMaintenance()).update?.env).toEqual({ + CODEX_HOME: fixture.home, + }); + } + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), + ); + for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 7ef2afd3f4c5d..485d7e98a23e7 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -34,7 +34,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -51,20 +50,36 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -function isCodexStandaloneCommandPath(commandPath: string): boolean { - return normalizeCommandPath(commandPath).includes("/packages/standalone/"); +const CODEX_STANDALONE_HOME_PATTERN = /^(.*)\/packages\/standalone\//is; + +function codexStandaloneHome(commandPath: string): string | null { + // Slash replacement preserves indices; Unicode lowercasing can change the path length. + const match = CODEX_STANDALONE_HOME_PATTERN.exec(commandPath.replaceAll("\\", "/")); + if (!match) { + return null; + } + const home = match[1]!; + // Keep root separators: `C:` is drive-relative, and an empty home is not `/`. + const isRoot = home === "" || /^[a-z]:$/i.test(home); + return commandPath.slice(0, home.length + (isRoot ? 1 : 0)); } -/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ +// Standalone updates locate their install through CODEX_HOME. Account and auth-overlay +// homes can share an executable, so only the updater uses the executable's owning home. function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", nativeUpdate: { args: ["update"], - isCommandPath: isCodexStandaloneCommandPath, + isCommandPath: (commandPath) => codexStandaloneHome(commandPath) !== null, requiresCommandPath: true, - env: { CODEX_HOME: sharedHomePath }, + env: (context) => ({ + CODEX_HOME: + codexStandaloneHome(context.realCommandPath) ?? + codexStandaloneHome(context.resolvedCommandPath) ?? + sharedHomePath, + }), }, }); } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 09e1d7674044f..1070b49a0dcf2 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -167,3 +168,64 @@ it.effect("keeps the task list order when a toggle or edit rewrites updated_at", }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect( + "idle polls still wake for tasks saved later, recorded due times, and beside far-future tasks", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const launched = yield* Deferred.make(); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: (input) => + Deferred.succeed(launched, input.title).pipe( + Effect.as({ threadId: "thread-idle-poll", resumed: false } as never), + ), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + // The first poll finds no tasks and remembers that nothing is due. + yield* TestClock.adjust("6 seconds"); + // Its year-10000+ next run sorts first as text; it must not hide the task below. + yield* service.upsert( + yield* decodeUpsertInput({ + title: "Far future", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 3e14 }, + projectId: "project-idle-poll", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + const created = yield* service.upsert( + yield* decodeUpsertInput({ + title: "Idle poll", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project-idle-poll", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + // This poll records the new task's due time; the later ones must still fire it. + yield* TestClock.adjust("6 seconds"); + yield* TestClock.adjust("60 seconds"); + expect(yield* Deferred.await(launched)).toBe(created.task.title); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 0ccd233879e44..3829124f4e57c 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -393,6 +393,35 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct return tasks; }); +// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. +const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; + +/** + * Remembers when a poller next has work so idle scheduler ticks skip SQL. + * `invalidate` after every write that can move work earlier; a tick records + * only if no write happened since it read `version`. + */ +const makeDueHint = () => { + let version = 0; + let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; + return { + invalidate: Effect.sync(() => { + version++; + hint = null; + }), + version: Effect.sync(() => version), + isIdle: (nowMs: number) => + Effect.sync( + () => + hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, + ), + record: (readVersion: number, dueAtMs: number, nowMs: number) => + Effect.sync(() => { + if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; + }), + }; +}; + export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { @@ -423,7 +452,13 @@ export const layer = Layer.effect( const activeRuns = yield* Ref.make>(new Set()); // Notifications re-emit the full list; slow subscribers need only the newest dirty signal. const changesPubSub = yield* PubSub.sliding(1); - const notifyChanged = PubSub.publish(changesPubSub, undefined).pipe(Effect.asVoid); + const dueTasks = makeDueHint(); + const pendingWebhooks = makeDueHint(); + // Every scheduled_tasks write that can change next_run_at, enabled or run state notifies. + const notifyChanged = dueTasks.invalidate.pipe( + Effect.andThen(PubSub.publish(changesPubSub, undefined)), + Effect.asVoid, + ); const selectAllRows = () => sql` @@ -969,6 +1004,28 @@ export const layer = Layer.effect( const runDueTasks = Effect.fn("ScheduledTaskService.runDueTasks")(function* () { const now = yield* localNow; + const nowMs = DateTime.toEpochMillis(now); + if (yield* dueTasks.isIdle(nowMs)) return; + const readVersion = yield* dueTasks.version; + const [next] = yield* sql<{ next_run_at: string | null }>` + SELECT MIN(next_run_at) AS next_run_at FROM scheduled_tasks + WHERE enabled = 1 AND next_run_at IS NOT NULL AND last_run_status <> 'running' + `.pipe( + NodeSqliteClient.readOnly, + Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), + ); + // Compare as text like listDueTasks: expanded years (`+011533-…`) sort first but parse far ahead. + const nextRunAt = next?.next_run_at ?? null; + if (nextRunAt === null || nextRunAt > iso(now)) { + const nextDueAt = + nextRunAt === null + ? Option.some(Infinity) + : DateTime.make(nextRunAt).pipe(Option.map(DateTime.toEpochMillis)); + if (Option.isSome(nextDueAt) && nextDueAt.value > nowMs) { + yield* dueTasks.record(readVersion, nextDueAt.value, nowMs); + } + return; + } const tasks = yield* listDueTasks(now).pipe( Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); @@ -1688,6 +1745,8 @@ export const layer = Layer.effect( const triggerWebhook: ScheduledTaskService["Service"]["triggerWebhook"] = (request) => triggerWebhookUnobserved(request).pipe( + // Accepted deliveries may have queued a coalescing window. + Effect.ensuring(pendingWebhooks.invalidate), Effect.tapError(() => observeDelivery(request, "error", undefined, undefined)), Effect.withSpan("ScheduledTaskService.triggerWebhook", { attributes: { @@ -2082,10 +2141,19 @@ export const layer = Layer.effect( const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { const now = DateTime.toEpochMillis(yield* localNow); + if (yield* pendingWebhooks.isIdle(now)) return; + const readVersion = yield* pendingWebhooks.version; yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; const tasks = yield* sql<{ task_id: ScheduledTaskId }>` SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') `.pipe(NodeSqliteClient.readOnly); + // Workers can leave rows behind to retry, so keep polling while any are claimed. + if (tasks.length === 0) { + const [next] = yield* sql<{ due_at: number | null }>` + SELECT MIN(due_at) AS due_at FROM scheduled_task_webhook_pending WHERE state = 'pending' + `.pipe(NodeSqliteClient.readOnly); + yield* pendingWebhooks.record(readVersion, next?.due_at ?? Infinity, now); + } yield* Effect.forEach( tasks, ({ task_id: taskId }) => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6f4e87f44705e..78741d03c200f 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -211,6 +211,31 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("keeps an override key a newer server saved when a project setting is edited", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("project-a"); + yield* fs.writeFileString( + config.settingsPath, + `{ "projectSettingsOverrides": { "project-a": { "defaultAutoPull": true, "addedByNewerServer": 3 } } }`, + ); + const loaded = yield* service.getSettings; + + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { ...loaded.projectSettingsOverrides[projectId], defaultAutoPull: false }, + }, + }); + + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.projectSettingsOverrides, { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 3 }, + }); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("moves an idle compaction mode saved in the legacy switch to its own field", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -234,6 +259,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("sends clients a known secret provider's config but never an unknown kind's", () => + Effect.gen(function* () { + const settings = yield* decodeServerSettings({ + secretProviders: { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault", config: { token: "bao-token" } }, + }, + }); + assert.deepStrictEqual( + ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault" }, + }, + ); + }), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1003,6 +1046,54 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("reads settings secrets once per settings change", () => { + let reads = 0; + const layerSecret = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.ServerSecretStore, (store) => ({ + ...store, + get: (name: string) => + Effect.suspend(() => { + reads++; + return store.get(name); + }), + })), + ).pipe(Layer.provide(ServerSecretStore.layer)); + const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(layerSecret), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-settings-secret-cache-test-" }), + ), + ), + ); + return Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_cached"); + const service = yield* ServerSettingsModule.ServerSettingsService; + const save = (value: string) => + service.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "API_TOKEN", value, sensitive: true }], + config: {}, + }, + }, + }); + const token = service.getSettings.pipe( + Effect.map((settings) => settings.providerInstances[instanceId]?.environment?.[0]?.value), + ); + yield* save("sk-first"); + reads = 0; + assert.equal(yield* token, "sk-first"); + assert.equal(yield* token, "sk-first"); + assert.equal(reads, 0); + yield* save("sk-second"); + assert.equal(yield* token, "sk-second"); + }).pipe(Effect.provide(layerSettings)); + }); + it.effect("keeps the inline value on disk when secret migration fails", () => { const cause = new ServerSecretStore.SecretStorePersistError({ resource: "provider environment secret", @@ -1340,6 +1431,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; let failRename = false; let settingsPathToFail: string | undefined; + let readDuringWrite: Effect.Effect = Effect.void; const writeFailure = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -1350,7 +1442,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ...fileSystem, rename: (fromPath, toPath) => failRename && toPath === settingsPathToFail - ? Effect.fail(writeFailure) + ? readDuringWrite.pipe(Effect.andThen(Effect.fail(writeFailure))) : fileSystem.rename(fromPath, toPath), }); const instanceId = ProviderInstanceId.make("antigravity_write_failure"); @@ -1372,6 +1464,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); failRename = true; + readDuringWrite = Effect.ignore(serverSettings.getSettings); const failedUpdate = yield* serverSettings .updateProviderInstance({ operation: "upsert", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index a1e0d34c7dd53..48839bb8535d9 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -3,10 +3,12 @@ import { ModelSelection, ProjectId, ProjectScript, + keepUnknownOverrideKeys, ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, + isKnownSecretProviderKind, isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, @@ -128,11 +130,25 @@ const normalizeServerSettings = ( ); export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { - return mapSettingsSecrets(settings, (secret) => + const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); + return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; +} + +// A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. +function withoutUnknownSecretProviderConfigs( + settings: ServerSettings, +): ServerSettings["secretProviders"] { + return Object.fromEntries( + Object.entries(settings.secretProviders).map(([id, entry]) => { + if (isKnownSecretProviderKind(entry.kind)) return [id, entry]; + const { config: _config, ...envelope } = entry; + return [id, envelope]; + }), + ) as ServerSettings["secretProviders"]; } export function applyProviderInstanceMutation( @@ -277,10 +293,12 @@ const ServerSettingsJson = fromLenientJson( ), projectSettingsOverrides: Schema.Record( ProjectId, - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), + }), + ), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), }), ); @@ -833,6 +851,31 @@ const make = Effect.gen(function* () { }, ); + // Background sweeps read settings every few seconds; reading secret files each time is wasted. + // Every secret write rewrites the settings file, so a new cached object implies fresh secrets. + // A read that overlaps a secret write may see files a rollback then restores, so it is not kept. + let materialized: { readonly source: ServerSettings; readonly value: ServerSettings } | null = + null; + let secretWriteGeneration = 0; + const invalidateMaterialized = Effect.sync(() => { + secretWriteGeneration++; + materialized = null; + }); + const getMaterializedSettings = getSettingsFromCache.pipe( + Effect.flatMap((source) => { + if (materialized?.source === source) return Effect.succeed(materialized.value); + const generation = secretWriteGeneration; + return materializeSettingsSecrets(source).pipe( + Effect.map(resolveTextGenerationProvider), + Effect.tap((value) => + Effect.sync(() => { + if (generation === secretWriteGeneration) materialized = { source, value }; + }), + ), + ); + }), + ); + const materializeChanges = (changes: Stream.Stream) => changes.pipe( Stream.mapEffect((settings) => @@ -1008,8 +1051,9 @@ const make = Effect.gen(function* () { const updated = yield* update(current); const persisted = yield* persistSettingsSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); - const materialized = yield* Effect.uninterruptibleMask(() => + const materializedNext = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { + yield* invalidateMaterialized; const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { @@ -1022,22 +1066,18 @@ const make = Effect.gen(function* () { return yield* Effect.failCause(writeExit.cause); } return materializedExit.value; - }), + }).pipe(Effect.ensuring(invalidateMaterialized)), ); yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); + const resolved = resolveTextGenerationProvider(materializedNext); + materialized = { source: next, value: resolved }; yield* emitChange(next); - return resolveTextGenerationProvider(materialized); + return resolved; }), ); const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => - writeSemaphore.withPermits(1)( - getSettingsFromCache.pipe( - Effect.flatMap(materializeSettingsSecrets), - Effect.map(resolveTextGenerationProvider), - Effect.flatMap(use), - ), - ); + writeSemaphore.withPermits(1)(getMaterializedSettings.pipe(Effect.flatMap(use))); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { @@ -1142,10 +1182,7 @@ const make = Effect.gen(function* () { return { start, ready: Deferred.await(startedDeferred), - getSettings: getSettingsFromCache.pipe( - Effect.flatMap(materializeSettingsSecrets), - Effect.map(resolveTextGenerationProvider), - ), + getSettings: getMaterializedSettings, updateSettings: (patch) => updateAndPersistSettings((current) => Effect.succeed(applyServerSettingsPatch(current, patch)), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index a140288cf4002..c9f4b3b9269d2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -118,7 +118,12 @@ function makeRegistry(input: { kind: provider.kind, label: provider.kind, installHint: "unused", - probeAuth: Effect.die("unused"), + probeAuth: Effect.succeed({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), }, })), ).pipe( @@ -442,3 +447,28 @@ it.effect("asks again once Settings change", () => assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github"); }), ); + +it.effect("answers discovery without waiting on a hosting API that stalls", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + discovery: [ + claimingCli("gitlab"), + { + type: "api", + kind: kind("bitbucket"), + label: "Bitbucket", + installHint: "unused", + probeAuth: Effect.never, + }, + ], + }); + const discovering = yield* registry.discover.pipe(Effect.forkChild); + yield* TestClock.adjust("3 seconds"); + const items = yield* Fiber.join(discovering); + const bitbucket = items.find((item) => item.kind === "bitbucket"); + assert.strictEqual(bitbucket?.status, "available"); + assert.strictEqual(bitbucket?.auth.status, "unknown"); + assert.strictEqual(items.find((item) => item.kind === "gitlab")?.status, "available"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index f854c9d312947..2acc02b2d0868 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -111,7 +111,7 @@ function toRepositoryInfo( }; } -// Redact credentials from client clone snapshots; git still receives the original URL. +// Redact credentials from client clone snapshots; git still receives the credentials. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -129,10 +129,16 @@ function redactRemoteUrl(remoteUrl: string): string { } // Userinfo can contain @; remove through the last one before the host boundary. -const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; +const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]+@/gi; +// Git leaves quotes inside query tokens unescaped; consume through the closing quote. +const QUOTED_URL_QUERY = /(['"])([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?.*\1(?=[\s:]|$)/gi; +const URL_QUERY = /\b([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?[^\s]*/gi; function redactUrlCredentials(text: string): string { - return text.replace(URL_WITH_USERINFO, "$1"); + return text + .replace(QUOTED_URL_QUERY, "$1$2$1") + .replace(URL_QUERY, "$1") + .replace(URL_WITH_USERINFO, "$1"); } // The remote already exists after push failure; advise fixing access and pushing. @@ -353,11 +359,13 @@ export const make = Effect.gen(function* () { if (stderrTail.length > 4) stderrTail.shift(); }); }; + // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host. + const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?"); yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: path.dirname(destinationPath), - args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], + args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c52b7f3233f5b..a595e8b174f65 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -15,6 +15,8 @@ import { DEFAULT_SERVER_SETTINGS, type WorktreeKeepWhen, type StorageCleanupSettings, + type TerminalMetadataStreamEvent, + type TerminalSummary, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "./config.ts"; @@ -31,8 +33,11 @@ import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAd import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { + EventId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, RunId, RuntimeRequestId, ThreadId, @@ -267,8 +272,11 @@ const cleanupFixture = ( let editBeforeRemoval = false; let openSessionCwd: string | null = null; let sessionLive = true; + let onLiveCheck: Effect.Effect = Effect.void; + let outboxStatus: string | null = null; let fullStatusCalls = 0; let snapshotReads = 0; + let terminalListener: ((event: TerminalMetadataStreamEvent) => Effect.Effect) | undefined; let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); const context = yield* Layer.build(StorageCleanup.layer).pipe( @@ -334,7 +342,8 @@ const cleanupFixture = ( Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { - isLive: () => Effect.sync(() => sessionLive), + isLive: () => + Effect.sync(() => sessionLive).pipe(Effect.tap(() => Effect.suspend(() => onLiveCheck))), } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.suspend(() => events), @@ -358,29 +367,39 @@ const cleanupFixture = ( }), }, ] - : strings.join("").includes("deleted_at IS NOT NULL") - ? deletedThreads.map((thread) => ({ - payload_json: JSON.stringify(thread), - workspaceRoot: repo, - })) - : strings.join("").includes('AS "worktreePath"') - ? [ - ...threads.flatMap((thread) => thread.worktreePath ?? []), - ...storedWorktreePaths, - ].map((worktreePath) => ({ worktreePath })) - : [], + : outboxStatus !== null && strings.join("").includes("orchestration_v2_effect_outbox") + ? [{ status: outboxStatus }] + : strings.join("").includes("deleted_at IS NOT NULL") + ? deletedThreads.map((thread) => ({ + payload_json: JSON.stringify(thread), + workspaceRoot: repo, + })) + : strings.join("").includes('AS "worktreePath"') + ? [ + ...threads.flatMap((thread) => thread.worktreePath ?? []), + ...storedWorktreePaths, + ].map((worktreePath) => ({ worktreePath })) + : [], )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: () => Effect.succeed(() => {}), + subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => + Effect.sync(() => { + terminalListener = listener; + return () => {}; + }), } as unknown as TerminalManager.TerminalManager["Service"]), Effect.provideService(ServerActivation, Deferred.await(activated)), ); return { service: Context.get(context, StorageCleanup.StorageCleanup), repo, + setTerminals: (terminals: TerminalSummary[]) => + terminalListener?.({ type: "snapshot", terminals }) ?? Effect.void, editBeforeRemoval: () => { editBeforeRemoval = true; }, @@ -420,6 +439,12 @@ const cleanupFixture = ( setThreads: (next: typeof threads) => { threads = next; }, + setOnLiveCheck: (hook: Effect.Effect) => { + onLiveCheck = hook; + }, + setOutboxStatus: (status: string | null) => { + outboxStatus = status; + }, setDeletedThreads: (next: OrchestrationV2ThreadShell[]) => { deletedThreads = next; }, @@ -933,6 +958,322 @@ describe("deleted thread worktrees", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); + // Releases one stopped-session event after the startup sweep and returns the snapshot reads it caused. + const stopSessionAfterStartupSweep = ( + fixture: Effect.Success>, + threadId: ThreadId, + beforeStop: Effect.Effect = Effect.void, + cwd = fixture.worktree, + ) => + Effect.gen(function* () { + const startupSwept = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + yield* beforeStop; + const readsBefore = fixture.snapshotReads(); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + return fixture.snapshotReads() - readsBefore; + }); + + it.effect("does not sweep when a live thread's session stops and no deleted worktree waits", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const reads = yield* stopSessionAfterStartupSweep(fixture, ThreadId.make("thread-1")); + expect(reads, "a live thread's stopped session re-read every thread").toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once its session stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.sync(() => fixture.setSessionLive(false)), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once a live thread's session in it stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + const child = subagent(owner); + fixture.setDeletedThreads([owner]); + fixture.setThreads([child]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + child.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Provider session is still open", + }); + fixture.setSessionLive(false); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree once its terminal closes and its session stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.setTerminals([ + { + threadId: owner.id, + terminalId: "terminal-1", + cwd: fixture.worktree, + worktreePath: fixture.worktree, + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: DateTime.formatIso(at(0)), + }, + ]); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Open terminal", + }); + yield* fixture.setTerminals([]); + }), + fixture.repo, + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "retries a deleted thread's worktree kept for another reason when a session in it stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Has uncommitted changes (1 file)", + }); + yield* fixture.fs + .writeFileString(`${fixture.worktree}/tracked.txt`, "original\n") + .pipe(Effect.orDie); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("does not sweep for a kept deleted worktree when a session elsewhere stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + fixture.setDeletedThreads([parent(fixture.worktree)]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.void, + fixture.repo, + ); + expect(reads).toBe(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "does not sweep for a deleted thread whose cleanup effect failed when a session elsewhere stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOutboxStatus("failed"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Thread deletion is still pending", + }); + }), + fixture.repo, + ); + expect(reads).toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree when its session stops during the deletion's sweep", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setThreads([]); + const startupSwept = yield* Deferred.make(); + const sessionChecked = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const deleted = { + id: EventId.make("event:thread-deleted"), + type: "thread.deleted", + threadId: owner.id, + occurredAt: at(0), + payload: owner, + } as unknown as OrchestrationV2DomainEvent; + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId: owner.id, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd: fixture.worktree, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(deleted)), + Stream.concat(Stream.fromEffect(Deferred.await(sessionChecked)).pipe(Stream.drain)), + Stream.concat(Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + fixture.setDeletedThreads([owner]); + fixture.setOpenSessionCwd(fixture.worktree); + // The deletion's sweep sees the session live; it stops before that sweep publishes its result. + fixture.setOnLiveCheck( + Effect.gen(function* () { + fixture.setOnLiveCheck(Effect.void); + yield* Deferred.succeed(sessionChecked, undefined); + yield* Deferred.await(delivered); + fixture.setSessionLive(false); + }), + ); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + it.effect("waits out the settle grace of a subagent that just settled", () => Effect.gen(function* () { yield* TestClock.setTime(NOW_MS); @@ -1825,6 +2166,42 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + { linked: "home", outcome: "removed", reason: "Removed 1 rotated logs" }, + { linked: "logs folder", outcome: "kept", reason: "No expired files" }, + ] as const)( + "rotated logs behind a symlinked $linked are $outcome", + ({ linked, outcome, reason }) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const disk = yield* fs.realPath(yield* fs.makeTempDirectoryScoped()); + yield* fs.makeDirectory(`${disk}/real/logs`, { recursive: true }); + if (linked === "home") { + yield* fs.symlink(`${disk}/real`, `${disk}/home`); + } else { + yield* fs.makeDirectory(`${disk}/home`); + yield* fs.symlink(`${disk}/real/logs`, `${disk}/home/logs`); + } + yield* fs.writeFileString(`${disk}/real/logs/server.log.1`, "log"); + yield* fs.utimes(`${disk}/real/logs/server.log.1`, 1, 1); + const { service, enableFiles, disable } = yield* cleanupFixture().pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + logsDir: `${disk}/home/logs`, + }), + ); + disable(); + enableFiles(); + const report = yield* service.runNow; + expect(report.entries.filter((entry) => entry.kind === "logs")).toMatchObject([ + { outcome, reason }, + ]); + expect(yield* fs.exists(`${disk}/real/logs/server.log.1`)).toBe(outcome === "kept"); + }), + ), + ); it.live("reports git failures without aborting the remaining categories", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 960fbd93fe061..17d543ca4c675 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -705,7 +705,10 @@ const make = Effect.gen(function* () { now: number, entries: StorageCleanupReportEntry[], ) { - if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; + if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) + return { waiting: false, kept: [] }; + let waiting = false; + const kept: string[] = []; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -779,7 +782,15 @@ const make = Effect.gen(function* () { bytes: null, files: null, }; - const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); + const keep = (reason: string) => { + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); + entries.push({ ...entry, reason: sentence(reason) }); + }; + // Reasons a provider session stop or finished deletion work can clear. + const waitForStop = (reason: string) => { + if (deleted && settings.worktreeOnDelete) waiting = true; + return reason; + }; yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); @@ -790,10 +801,10 @@ const make = Effect.gen(function* () { thread.branch === null || !group.every((member) => storageCleanupThreadIdle(member, now)) ) - return keep("thread is running or has pending work"); + return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); - if (hasTerminal(worktreePath)) return keep("open terminal"); + if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); @@ -817,7 +828,7 @@ const make = Effect.gen(function* () { SETTLE_SCRIPT_GRACE_MS, ) ) - return keep("settled moments ago"); + return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); const old = !deleted && @@ -924,7 +935,7 @@ const make = Effect.gen(function* () { const latest = (yield* projections.getThreadWorktreePaths({ includeArchived: true, })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); - if (hasTerminal(worktreePath)) return "open terminal"; + if (hasTerminal(worktreePath)) return waitForStop("open terminal"); if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -932,10 +943,13 @@ const make = Effect.gen(function* () { ) return "thread or cleanup settings changed since check"; // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. - const pendingCleanup = yield* sql` - SELECT 1 FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + const pendingCleanup = yield* sql<{ status: string }>` + SELECT status FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') `.pipe(NodeSqliteClient.readOnly); + // Failed effects never retry, so only running work can finish and free the path. + if (pendingCleanup.some((row) => row.status !== "failed")) + return waitForStop("thread deletion is still pending"); if (pendingCleanup.length > 0) return "thread deletion is still pending"; } const latestShells = yield* Effect.forEach(latest, (entry) => @@ -960,7 +974,7 @@ const make = Effect.gen(function* () { ); }) ) - return "thread activity or shared worktree changed since check"; + return waitForStop("thread activity or shared worktree changed since check"); // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions @@ -976,7 +990,7 @@ const make = Effect.gen(function* () { (cwd === worktreePath || inside(worktreePath, cwd)) && (yield* providerSessions.isLive(session.id)) ) - return "provider session is still open"; + return waitForStop("provider session is still open"); } return null; }); @@ -1036,6 +1050,7 @@ const make = Effect.gen(function* () { outcome: "failed", reason: cleanupFailureReason(error), }); + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); yield* Effect.logWarning("storage cleanup failed for worktree", { threadId: thread.id, error, @@ -1049,6 +1064,7 @@ const make = Effect.gen(function* () { yield* projectStore.list({ includeDeleted: true }), entries, ); + return { waiting, kept }; }); const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* ( @@ -1059,8 +1075,10 @@ const make = Effect.gen(function* () { removed: { files: number; bytes: number }, ) { if (days === null || !(yield* fs.exists(root))) return removed; + // A symlinked parent (a linked T3 home) is fine; a symlinked folder is not. const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return removed; + const realParent = yield* fs.realPath(path.dirname(root)); + if (realRoot !== path.join(realParent, path.basename(root))) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { @@ -1088,14 +1106,21 @@ const make = Effect.gen(function* () { }); const reportRef = yield* SubscriptionRef.make(null); + // Only deleted threads' worktrees wait on session stops: any stop while one waits, or a stop inside one kept for another reason. + // A deletion stays pending until a sweep that started after it finishes. + let deletedWorktreeWaits = true; + let keptDeletedWorktrees: ReadonlyArray = []; + let deletions = 0; + let sweptDeletions = 0; const sweep = Effect.fn("StorageCleanup.sweep")(function* ( trigger: StorageCleanupReport["trigger"], ) { + const deletionsAtStart = deletions; const now = yield* Clock.currentTimeMillis; const serverSettings = yield* settingsService.getSettings; const settings = serverSettings.storageCleanup; const entries: StorageCleanupReportEntry[] = []; - yield* cleanWorktrees(serverSettings, now, entries).pipe( + const deletedWorktrees = yield* cleanWorktrees(serverSettings, now, entries).pipe( Effect.catch((error) => Effect.gen(function* () { entries.push({ @@ -1110,9 +1135,13 @@ const make = Effect.gen(function* () { files: null, }); yield* Effect.logWarning("worktree cleanup failed", { error }); + return { waiting: true, kept: [] }; }), ), ); + deletedWorktreeWaits = deletedWorktrees.waiting; + keptDeletedWorktrees = deletedWorktrees.kept; + sweptDeletions = deletionsAtStart; for (const category of [ { kind: "browser-artifacts" as const, @@ -1254,14 +1283,26 @@ const make = Effect.gen(function* () { }), ); yield* forkParked( - Stream.runForEach(events, (event) => - (event.type === "thread.deleted" || - (event.type === "provider-session.updated" && - (event.payload.status === "stopped" || event.payload.status === "error"))) && - anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) + Stream.runForEach(events, (event) => { + if (event.type === "thread.deleted") deletions++; + else if ( + event.type !== "provider-session.updated" || + (event.payload.status !== "stopped" && event.payload.status !== "error") + ) + return Effect.void; + else if (!deletedWorktreeWaits && deletions === sweptDeletions) { + const cwd = pathKey(event.payload.cwd); + if ( + !keptDeletedWorktrees.some( + (worktreePath) => pathKey(worktreePath) === cwd || inside(worktreePath, cwd), + ) + ) + return Effect.void; + } + return anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) ? requestSweep - : Effect.void, - ).pipe( + : Effect.void; + }).pipe( Effect.catchCause((cause) => Effect.logWarning("Storage cleanup event stream failed", { cause }), ), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 354cc10b16402..07be828ba3093 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1019,6 +1019,35 @@ it.layer( }), ); + it.effect.each([ + { name: "OSC", sequence: "\u001b]0;unfinished" }, + { name: "DCS", sequence: "\u001bPunfinished" }, + { name: "PM", sequence: "\u001b^unfinished" }, + { name: "APC", sequence: "\u001b_unfinished" }, + { name: "CSI", sequence: "\u001b[5" }, + { name: "C1 OSC", sequence: "\u009d0;unfinished" }, + { name: "C1 DCS", sequence: "\u0090unfinished" }, + { name: "C1 PM", sequence: "\u009eunfinished" }, + { name: "C1 APC", sequence: "\u009funfinished" }, + { name: "C1 CSI", sequence: "\u009b5" }, + ])("keeps output after a cancelled $name sequence in history", ({ sequence }) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + for (const cancel of ["\u0018", "\u001a"]) { + process.emitData(sequence); + process.emitData(cancel); + process.emitData("next line\n"); + process.emitData(`${sequence}${cancel}next line\n`); + } + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history.match(/next line/g)).toHaveLength(4); + }), + ); + it.effect("handles CSI and DCS query sequences split across output chunks", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 0fd300dbd8a0f..2c43d17cf1032 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1075,6 +1075,10 @@ function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } +function isControlSequenceCancel(codePoint: number): boolean { + return codePoint === 0x18 || codePoint === 0x1a; +} + function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "n") { return true; @@ -1123,7 +1127,7 @@ function stripStringTerminator(value: string): string { function findStringTerminatorIndex(input: string, start: number): number | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { + if (codePoint === 0x07 || codePoint === 0x9c || isControlSequenceCancel(codePoint)) { return index + 1; } if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { @@ -1185,10 +1189,11 @@ function sanitizeTerminalHistoryChunk( if (nextCodePoint === 0x5b) { let cursor = index + 2; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; @@ -1239,10 +1244,11 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x9b) { let cursor = index + 1; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 021d8f371f129..4b134765e1c7a 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -679,9 +679,13 @@ it.effect.each( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect.each(["normal", "flags", "sparse"] as const)( - "checkpoint index inspection handles entries beyond the output cap (index=%s)", - (indexMode) => +it.effect.each( + (["normal", "flags", "sparse"] as const).flatMap((indexMode) => + [1, 3, 4096].map((chunkSize) => ({ indexMode, chunkSize })), + ), +)( + "checkpoint index inspection handles entries beyond the output cap (index=$indexMode, chunk=$chunkSize)", + ({ indexMode, chunkSize }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -717,8 +721,8 @@ it.effect.each(["normal", "flags", "sparse"] as const)( ...input, maxOutputBytes: 8, onStdoutChunk: (chunk) => { - for (let i = 0; i < chunk.length; i++) - input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + for (let i = 0; i < chunk.length; i += chunkSize) + input.onStdoutChunk?.(chunk.subarray(i, i + chunkSize)); }, } : input, @@ -999,6 +1003,24 @@ it.effect("GitVcsDriver treats a missing git executable as outside any repositor }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "GitVcsDriver detects a repository whose path contains a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-newline-repo-" }); + const cwd = path.join(parent, "multi\nline"); + yield* fileSystem.makeDirectory(cwd); + yield* runGit(cwd, ["init"]); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + const repository = yield* driver.detectRepository(cwd); + assert.strictEqual(repository?.rootPath, yield* fileSystem.realPath(cwd)); + assert.strictEqual(repository?.metadataPath, ".git"); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "GitVcsDriver still fails detection when git exists but cannot be spawned", () => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 5ddb53880e1c6..b4d9af81cfbf8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -603,25 +603,43 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { - return null; - } - - const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ - "rev-parse", - "--show-toplevel", - ]); - const gitCommonDir = yield* gitCommand( + // One spawn answers all three; outside a work tree it exits non-zero or prints "false". + const result = yield* gitCommand( vcsProcess, - "GitVcsDriver.detectRepository.commonDir", + "GitVcsDriver.detectRepository", cwd, - ["rev-parse", "--git-common-dir"], - ).pipe(Effect.orElseSucceed(() => null)); + ["rev-parse", "--is-inside-work-tree", "--show-toplevel", "--git-common-dir"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16_384, + }, + ).pipe(Effect.catchIf(isMissingExecutable, () => Effect.succeed(null))); + if (result === null || result.exitCode !== 0) return null; + const lines = result.stdout.replace(/\r?\n$/u, "").split(/\r?\n/u); + if (lines[0]?.trim() !== "true") return null; + // A newline inside a path breaks the line framing; ask for each path on its own. + if (lines.length !== 3) { + const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ + "rev-parse", + "--git-common-dir", + ]).pipe(Effect.orElseSucceed(() => null)); + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + } return { kind: "git" as const, - rootPath: root.stdout.trim(), - metadataPath: gitCommonDir?.stdout.trim() || null, + rootPath: lines[1]!.trim(), + metadataPath: lines[2]!.trim() || null, freshness: yield* nowFreshness(), }; }); @@ -916,7 +934,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( let specialFlags = false; let recordStart = true; let skipped = false; - let skippedRecord: number[] = []; + let skippedRecord: Uint8Array[] = []; const skippedPaths: string[] = []; yield* vcsProcess.run({ operation, @@ -926,27 +944,35 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", + // Only tags and sparse skip-worktree paths matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { - for (const byte of chunk) { - if (recordStart) skipped = byte === 83; + let start = 0; + while (start < chunk.length) { + const end = chunk.indexOf(0, start); + if (recordStart) { + const tag = chunk[start]!; + skipped = tag === 83; + if ((tag >= 97 && tag <= 122) || (!sparseCheckout && tag === 83)) { + specialFlags = true; + } + } if (skipped && sparseCheckout) { - if (byte !== 0) skippedRecord.push(byte); - else { - if (skippedRecord.at(-1) !== 47) { - const name = Buffer.from(skippedRecord).subarray(2); + skippedRecord.push( + Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)), + ); + if (end !== -1) { + const record = Buffer.concat(skippedRecord); + if (record.at(-1) !== 47) { + const name = record.subarray(2); if (!NodeBuffer.isUtf8(name)) specialFlags = true; else skippedPaths.push(name.toString("utf8")); } skippedRecord = []; } } - if ( - recordStart && - ((byte >= 97 && byte <= 122) || (!sparseCheckout && byte === 83)) - ) { - specialFlags = true; - } - recordStart = byte === 0; + recordStart = end !== -1; + if (!recordStart) break; + start = end + 1; } }, }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 02f16f816b043..200799adcdd63 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -407,6 +407,7 @@ describe("pullCurrentBranch", () => { const driver = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; const { cwd, updater, initialBranch, remoteHead } = yield* cloneBehindRemote; + yield* git(cwd, ["tag", initialBranch]); yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); const remoteName = customMapping ? "team/upstream" : "origin"; const refPrefix = customMapping @@ -1005,6 +1006,45 @@ describe("rejected git refs", () => { }); it.layer(layerTest)("GitVcsDriver core integration", (it) => { + it.effect("keeps large change sets available for status and commit preparation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + const files = Array.from( + { length: 6_000 }, + (_, index) => `file-${String(index).padStart(5, "0")}-${"x".repeat(160)}.txt`, + ); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "before\n"), { + concurrency: 16, + discard: true, + }); + yield* git(cwd, ["add", "."]); + const unborn = yield* driver.statusDetailsLocal(cwd); + assert.lengthOf(unborn.workingTree.files, files.length); + assert.equal(unborn.workingTree.insertions, files.length); + const initial = yield* driver.commit(cwd, "initial", ""); + assert.match(initial.commitSha, /^[a-f0-9]{40}$/); + assert.isFalse((yield* driver.statusDetailsLocal(cwd)).hasWorkingTreeChanges); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "after\n"), { + concurrency: 16, + discard: true, + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.isTrue(status.hasWorkingTreeChanges); + assert.lengthOf(status.workingTree.files, files.length); + assert.equal(status.workingTree.insertions, files.length); + assert.equal(status.workingTree.deletions, files.length); + const context = yield* driver.prepareCommitContext(cwd, undefined, { includePatch: false }); + assert.isNotNull(context); + assert.isAbove(context!.stagedSummary.length, 1_000_000); + assert.lengthOf(context!.stagedSummary.split("\n"), files.length); + }), + ); describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { @@ -2282,6 +2322,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { + it.effect("reads the current branch by name when a tag shares it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "release"]); + yield* git(cwd, ["tag", "release"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const remoteStatus = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + assert.equal(remoteStatus.branch, "release"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const branchRange = preview.sources.find((source) => source.kind === "branch-range"); + assert.equal(branchRange?.headRef, "release"); + }), + ); + it.effect("does not open a project folder laid out as a bare repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3488,6 +3544,42 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps generated worktree folders short without changing long branch names", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const worktreesDirectory = yield* makeTmpDir("custom-worktrees-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const folders = new Set(); + const branches = [ + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"c".repeat(90)}`, + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"d".repeat(90)}`, + `feature/${"a".repeat(40)}/5fac`, + `feature/${"a".repeat(40)}/18828`, + `${"a".repeat(22)}😀/${"b".repeat(30)}`, + "z".repeat(40), + "z".repeat(41), + ]; + for (const branch of branches) { + const created = yield* driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: branch }, + { worktreesDirectory }, + ); + const folder = pathService.basename(created.worktree.path); + assert.isAtMost(folder.length, 40); + if (branch.length <= 40) assert.equal(folder, branch); + assert.equal(yield* git(created.worktree.path, ["branch", "--show-current"]), branch); + assert.include( + yield* git(cwd, ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"]), + `worktree ${created.worktree.path}`, + ); + folders.add(folder); + } + assert.equal(folders.size, branches.length); + }), + ); + it.effect("resolves the submodule mode from the option, then t3.json", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1917abb18de3c..73ecd4c5929ca 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -514,6 +514,15 @@ function classifyGitFailure(stderr: string): GitCommandFailureReason | null { return null; } +// `--short` and `--abbrev-ref` print `heads/` when a tag shares the branch's name, so +// branch reads take the full ref and strip the prefix themselves. +function branchNameFromHeadRef(value: string): string | null { + const ref = value.trim(); + return ref.startsWith("refs/heads/") && ref.length > "refs/heads/".length + ? ref.slice("refs/heads/".length) + : null; +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -1399,7 +1408,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -1430,11 +1439,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), Effect.orElseSucceed(() => undefined), ); - const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = - currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 - ? currentBranchOutput - : null; + currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null; return { gitCommonDir, @@ -1925,7 +1931,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], + ["rev-parse", "--symbolic-full-name", "HEAD"], { allowNonZeroExit: true }, ).pipe( Effect.catchTags({ @@ -1947,7 +1953,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetailsRemote.branch", cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], + args: ["rev-parse", "--symbolic-full-name", "HEAD"], }), detail: "Git branch lookup failed.", exitCode: branchResult.exitCode, @@ -1959,12 +1965,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchValue = yield* runGitStdout( "GitVcsDriver.statusDetailsRemote.unbornBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], ); - branch = branchValue.trim() || null; + branch = branchNameFromHeadRef(branchValue); } else { - const branchValue = branchResult.stdout.trim(); - branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + branch = branchNameFromHeadRef(branchResult.stdout); } const currentUpstreamRef = yield* readCurrentUpstreamRef(cwd); let upstreamRef = @@ -2073,12 +2078,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* lockError; } } - const statusResult = yield* executeGitWithStableDiagnostics( + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const statusCacheKey = repositoryPaths?.gitCommonDir; + const readStatus = executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, statusArgs, { allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, }, ).pipe( Effect.catchTags({ @@ -2086,57 +2096,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), }), ); - - if (statusResult === null) { - return NON_REPOSITORY_STATUS_DETAILS; - } - - if (statusResult.exitCode !== 0) { - if (isNonRepositoryGitStderr(statusResult.stderr)) { - return NON_REPOSITORY_STATUS_DETAILS; - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.statusDetails.status", - cwd, - args: statusArgs, - }), - detail: "Git status failed.", - exitCode: statusResult.exitCode, - stdoutLength: statusResult.stdout.length, - stderrLength: statusResult.stderr.length, - }); - } - - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + // diff-index does not read status output, so both spawn together; a non-repository discards it. + const [statusResult, numstatResult, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ + readStatus, // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], - { allowNonZeroExit: true }, + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe( Effect.flatMap((result) => { if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ - "diff-files", - "--numstat", - "-z", - ]), - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ - "diff", - "--cached", - "--numstat", - "-z", - ]), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn", + cwd, + ["diff-files", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn.staged", + cwd, + ["diff", "--cached", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), ]), ([unstagedStdout, stagedStdout]) => [ ...parseReviewNumstat(stagedStdout), @@ -2158,6 +2145,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); }), + Effect.result, ), statusCacheKey ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) @@ -2168,6 +2156,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: "unbounded" }, ); + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } + + if (statusResult.exitCode !== 0) { + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: statusArgs, + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); + } + + const numstatEntries = yield* Effect.fromResult(numstatResult); const statusStdout = statusResult.stdout; let refName: string | null = null; @@ -2211,25 +2221,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const fallbackAheadCount = - includeDivergence && !upstreamRef && refName - ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) - : null; - - if (fallbackAheadCount !== null) { - aheadCount = fallbackAheadCount; - behindCount = 0; - } - const isDefaultBranch = refName !== null && (refName === defaultBranch || (defaultBranch === null && (refName === "main" || refName === "master"))); - if (includeDivergence && refName && !isDefaultBranch) { - aheadOfDefaultCount = - fallbackAheadCount !== null - ? fallbackAheadCount - : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); + const needsFallbackAhead = includeDivergence && !upstreamRef && refName !== null; + const needsAheadOfDefault = includeDivergence && refName !== null && !isDefaultBranch; + const [baseAheadCount, branchChanges] = yield* Effect.all( + [ + refName !== null && (needsFallbackAhead || needsAheadOfDefault) + ? computeAheadCountAgainstBase(cwd, refName, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => 0)) + : Effect.succeed(0), + options?.includeBranchChanges + ? readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => undefined)) + : Effect.undefined, + ], + { concurrency: "unbounded" }, + ); + + if (needsFallbackAhead) { + aheadCount = baseAheadCount; + behindCount = 0; + } + if (needsAheadOfDefault) { + aheadOfDefaultCount = baseAheadCount; } const fileStatMap = new Map(); @@ -2256,12 +2275,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } files.sort((a, b) => a.path.localeCompare(b.path)); - const branchChanges = options?.includeBranchChanges - ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( - Effect.orElseSucceed(() => undefined), - ) - : undefined; - return { isRepo: true, hasOriginRemote: hasPrimaryRemote, @@ -2426,7 +2439,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, ["diff", "--cached", "--name-status"], - { env }, + { env, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { return null; @@ -2490,6 +2503,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); @@ -2768,9 +2782,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdout( "GitVcsDriver.pullCurrentBranch.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], true, - ).pipe(Effect.map((stdout) => stdout.trim())), + ).pipe(Effect.map(branchNameFromHeadRef)), runGitStdout("GitVcsDriver.pullCurrentBranch.currentSha", cwd, ["rev-parse", "HEAD"]).pipe( Effect.map((stdout) => stdout.trim()), ), @@ -2993,6 +3007,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, branch: string | null, explicitBaseRef?: string, + knownOriginDefaultBranch: string | null = null, ) { const base = explicitBaseRef !== undefined @@ -3004,9 +3019,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : explicitBaseRef, }) : branch - ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe( - Effect.orElseSucceed(() => null), - ) + ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { + allowRemoteOfCurrent: true, + knownOriginDefaultBranch, + }).pipe(Effect.orElseSucceed(() => null)) : null; if (base === null) return { baseRef: null, mergeBase: "HEAD" }; const { baseRef, comparisonRef } = base; @@ -3037,8 +3053,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( cwd: string, branch: string | null, + options?: { readonly knownOriginDefaultBranch: string | null }, ) { - const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch); + const { baseRef, mergeBase } = yield* resolveReviewMergeBase( + cwd, + branch, + undefined, + options?.knownOriginDefaultBranch ?? null, + ); const untracked = yield* prepareUntrackedReviewIndex(cwd, []); if (untracked === null) { return yield* new GitCommandError({ @@ -3855,7 +3877,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.refName); yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; @@ -3874,7 +3895,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Worktrees.`, }); } - worktreePath = path.join(parentDir, repoName, sanitizedBranch); + let directoryName = targetBranch.replace(/\//g, "-"); + // Leave room for files beneath the worktree on Windows, regardless of branch length. + if (directoryName.length > 40) { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(targetBranch)) + .pipe(Effect.orDie); + // Avoid splitting a surrogate pair, which Git would list back as a different path. + const prefix = directoryName.slice(0, 23).replace(/[\uD800-\uDBFF]$/, ""); + directoryName = `${prefix}-${Hex.encode(digest).slice(0, 16)}`; + } + worktreePath = path.join(parentDir, repoName, directoryName); } if (input.newRefName) { // Kept apart from the checkout so the shared-config lock only covers the tracking write. diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index e5c8169774830..b6f7dae813744 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -21,6 +21,8 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; +const DETECT_COMMAND = "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir"; + describe("VcsDriverRegistry", () => { const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( @@ -49,10 +51,10 @@ describe("VcsDriverRegistry", () => { stderr: "fatal: not a git repository", }; } - if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n"); - if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`); - if (command === "rev-parse --git-common-dir") { - return processOutput(`${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`); + if (command === DETECT_COMMAND) { + return processOutput( + `true\n${repoDir}\n${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`, + ); } return processOutput(""); }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), @@ -75,7 +77,7 @@ describe("VcsDriverRegistry", () => { yield* fs.remove(path.join(repoDir, ".git"), { recursive: true }); assert.equal(yield* registry.detect({ cwd: repoDir }), null); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -118,7 +120,7 @@ describe("VcsDriverRegistry", () => { yield* fs.makeDirectory(path.join(repoDir, ".git")); assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 53d58fd80a21b..a96b7390c9952 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -86,6 +86,7 @@ function layerTestFor(state: { backgroundWorkEnabled?: boolean; automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; + beforeLocalStatus?: Effect.Effect; fileSystem?: FileSystem.FileSystem; pullCurrentBranch?: GitWorkflowService.GitWorkflowService["Service"]["pullCurrentBranch"]; }) { @@ -105,9 +106,11 @@ function layerTestFor(state: { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => - Effect.sync(() => { + Effect.suspend(() => { state.localStatusCalls += 1; - return state.currentLocalStatus; + return (state.beforeLocalStatus ?? Effect.void).pipe( + Effect.as(state.currentLocalStatus), + ); }), currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName), remoteStatus: (_input, options) => @@ -152,6 +155,47 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b } describe("VcsStatusBroadcaster", () => { + it.effect("refreshes queued behind a running one share the next git status read", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeLocalStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const running = yield* broadcaster + .refreshLocalStatus("/repo") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.await(started); + // Requested while the first read runs, so that read may predate their change. + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const queued = yield* Effect.all( + [broadcaster.refreshLocalStatus("/repo"), broadcaster.refreshLocalStatus("/repo")], + { concurrency: "unbounded" }, + ).pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + + assert.isFalse((yield* Fiber.join(running)).hasWorkingTreeChanges); + const [second, third] = yield* Fiber.join(queued); + assert.isTrue(second.hasWorkingTreeChanges); + assert.isTrue(third.hasWorkingTreeChanges); + assert.strictEqual(state.localStatusCalls, 2); + + yield* broadcaster.refreshLocalStatus("/repo"); + assert.strictEqual(state.localStatusCalls, 3); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("publishes a terminal branch switch without reading remote status", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c6a747105c03a..a4f5ddede9b68 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -369,11 +369,30 @@ export const make = Effect.gen(function* () { ); }); + // A refresh that started after a request saw every change that request could mean, so queued + // callers share it instead of each running git status again. + const localRefreshLocks = yield* KeyedLock.make(); + const localRefreshRequests = new Map(); + const localRefreshResults = new Map< + string, + { readonly startedAt: number; readonly local: VcsStatusLocalResult } + >(); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + const requestedAt = (localRefreshRequests.get(cwd) ?? 0) + 1; + localRefreshRequests.set(cwd, requestedAt); + return yield* localRefreshLocks.withLock( + cwd, + Effect.gen(function* () { + const shared = localRefreshResults.get(cwd); + if (shared !== undefined && shared.startedAt >= requestedAt) return shared.local; + const startedAt = localRefreshRequests.get(cwd) ?? requestedAt; + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + localRefreshResults.set(cwd, { startedAt, local }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }), + ); }, ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index d78c494481575..d36bc397c0e22 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -256,12 +256,23 @@ it.effect("shell snapshots omit idle children and older settled threads only on Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), Effect.provideService(ThreadManagement.ThreadManagementService, { - readShellSnapshot: () => + listActiveShellIndex: () => + Effect.succeed( + threads.map((thread) => ({ + ...thread, + pinnedAt: null, + snoozedUntil: null, + mayBeLive: thread.activityRunStatus !== null, + })), + ), + readShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => Effect.succeed( Effect.succeed({ schemaVersion: 1, snapshotSequence: 0, - threads, + threads: threads.filter( + (thread) => options?.threadIds?.includes(thread.id) ?? true, + ), archivedThreads: [], }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e0a29409352df..e7d9a9363b325 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -70,7 +70,6 @@ import * as DocumentText from "./provider/DocumentText.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import { - buildActiveShellSnapshot, bufferShellLiveStream, coalesceShellApplicationEvents, composeShellStreamWithEnrichment, @@ -80,7 +79,7 @@ import { shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellSnapshotForSubscriber, + loadSubscriberShellSnapshot, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -625,21 +624,23 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); const loadSnapshot = Effect.fn("ws.orchestrationV2.loadShellSnapshot")(function* () { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projects.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const forSubscriber = shellSnapshotForSubscriber(base, { - omitIdleSubagents: input.omitIdleSubagents === true, - omitOlderSettled: input.omitOlderSettled === true, - now: yield* DateTime.now, + const forSubscriber = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projects.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options: { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + }, }); subagentThreadIds = forSubscriber.subagentThreadIds; - const enriched = yield* enrichProjectShells(base.projects); + const enriched = yield* enrichProjectShells(forSubscriber.snapshot.projects); const snapshot = { ...forSubscriber.snapshot, projects: enriched.projects, @@ -968,6 +969,7 @@ const layerWsRpc = () => threadSnapshotPagination: true, threadFind: true, threadFindProgressive: true, + secretProviderSettings: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), diff --git a/apps/web/package.json b/apps/web/package.json index 2ece254d563d1..8d51cbce61554 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 8300f32600fb5..88c1eb4f2d1aa 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,6 +3,7 @@ import { deriveLatestThreadRun, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -167,7 +168,7 @@ export function shouldOpenProactiveTurnDiff(input: { } export function resolveProactiveTurnDiffAction(input: { - checkpoint: Pick | undefined; + checkpoint: Pick | undefined; isGitRepo: boolean | undefined; }): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; @@ -179,11 +180,8 @@ export function resolveProactiveTurnDiffAction(input: { ) { return "ignore"; } - const changedLines = input.checkpoint.files.reduce( - (total, file) => total + file.additions + file.deletions, - 0, - ); - return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore"; + const totals = checkpointFileTotals(input.checkpoint); + return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } /** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aba621c755652..42660924349c3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -258,7 +258,7 @@ import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullReq import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -2331,6 +2331,11 @@ export default function ChatView(props: ChatViewProps) { : EMPTY_PROVIDER_SKILLS; const threadFindEnabled = isServerThread && serverConfig?.threadFind === true; const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); + const openThreadFindStable = useStableCallback((threadRef: ScopedThreadRef) => { + if (threadFindEnabled && scopedThreadKey(threadRef) === activeThreadKey) { + openThreadFind(activeThreadKey); + } + }); useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, @@ -2846,6 +2851,7 @@ export default function ChatView(props: ChatViewProps) { {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} + {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> @@ -3119,7 +3125,7 @@ export default function ChatView(props: ChatViewProps) { : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} - bannerItems={composerBannerItems} + bannerItems={isDraftHeroState ? [] : composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3269,6 +3275,12 @@ export default function ChatView(props: ChatViewProps) { )} + {isDraftHeroState && composerBannerItems.length > 0 ? ( + // Out of flow, like the headline, so a notice never moves the centered composer. +
+ +
+ ) : null}
{ const actions = [ { shortcut: "B", available: true, label: "Browser" }, { shortcut: "D", available: false, label: "Diff" }, + { shortcut: "T", available: true, label: "Terminal" }, + { shortcut: "M", available: true, label: "Device" }, ] as const; + it("falls back to the physical key when the layout types a non-Latin letter or mark", () => { + // Ukrainian layout: the B key types "и". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0438", { code: "KeyB" }))).toBe( + actions[0], + ); + // Greek layout: the B key types "β". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u03b2", { code: "KeyB" }))).toBe( + actions[0], + ); + // Thai layout: the B key types the vowel sign "\u0e34", a combining mark. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0e34", { code: "KeyB" }))).toBe( + actions[0], + ); + // Arabic (101) layout: the B key types lam alef "\u0644\u0627", two code points. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0644\u0627", { code: "KeyB" })), + ).toBe(actions[0]); + // Ukrainian layout: the D key types "\u0432", but the Diff surface is unavailable. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0432", { code: "KeyD" })), + ).toBeNull(); + }); + + it("follows the typed letter on Latin layouts that move it", () => { + // Dvorak: the key at QWERTY B types "x", and "b" sits on QWERTY N. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("x", { code: "KeyB" }))).toBeNull(); + // The same key with Shift or Caps Lock types "X". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("X", { code: "KeyB" }))).toBeNull(); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyN" }))).toBe( + actions[0], + ); + }); + + it("keeps accented Latin letters typed on a letter key", () => { + // Bépo: the T key types "è". + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u00e8", { code: "KeyT" })), + ).toBeNull(); + }); + + it("keeps punctuation typed on a letter key", () => { + // French AZERTY: the M key types ",". + expect(surfaceShortcutActionForKey(actions, shortcutEvent(",", { code: "KeyM" }))).toBeNull(); + }); + + it("keeps a dead key on a letter key", () => { + // A dead key starts an accented letter on a Latin layout. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("Dead", { code: "KeyT" })), + ).toBeNull(); + }); + it("leaves modified, composing, and already-handled key events alone", () => { expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); expect( @@ -40,6 +94,21 @@ describe("surface shortcuts", () => { surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })), ).toBeNull(); }); + + it("leaves AltGr text alone unless it is a plain letter", () => { + const getModifierState = (key: string) => key === "AltGraph"; + // German layout: AltGr+M types "\u00b5" and sets only AltGraph on Linux and Windows. + expect( + surfaceShortcutActionForKey( + actions, + shortcutEvent("\u00b5", { code: "KeyM", getModifierState }), + ), + ).toBeNull(); + // Finnish layout: AltGr+B types "b", which still matches, as it does in keybindings.ts. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyB", getModifierState })), + ).toBe(actions[0]); + }); }); describe("surface shortcut typing contexts", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index a3fe577d1a0a2..138e4d99d6fbe 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -74,6 +74,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; export interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -164,17 +165,17 @@ function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" ->; +> & + Partial>; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string }, >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); return ( - actions.find( - (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), - ) ?? null + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null ); } diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 0b569cbc0c563..868484f37b474 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -1,11 +1,7 @@ -import { type RunId } from "@t3tools/contracts"; +import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts"; import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react"; import { type TurnDiffFileChange } from "../../types"; -import { - buildTurnDiffTree, - summarizeTurnDiffStats, - type TurnDiffTreeNode, -} from "../../lib/turnDiffTree"; +import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { ChevronRightIcon, FileDiffIcon } from "lucide-react"; import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide"; import { cn } from "~/lib/utils"; @@ -30,7 +26,9 @@ type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => vo export const ChangedFilesCard = memo(function ChangedFilesCard(props: { runId: RunId; + /** May be a prefix of the changed files; `totals` covers all of them. */ files: ReadonlyArray; + totals: OrchestrationV2CheckpointFileTotals; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; @@ -40,13 +38,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { const { runId, files, + totals, allDirectoriesExpanded, resolvedTheme, onToggleAllDirectories, onOpenTurnDiff, onFileContextMenu, } = props; - const summaryStat = summarizeTurnDiffStats(files); + const hiddenFileCount = totals.count - files.length; const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); return ( @@ -60,12 +59,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { >
- {files.length} changed file{files.length === 1 ? "" : "s"} + {totals.count.toLocaleString()} changed file{totals.count === 1 ? "" : "s"} - {hasNonZeroStat(summaryStat) && ( + {hasNonZeroStat(totals) && ( @@ -126,6 +125,19 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} onFileContextMenu={onFileContextMenu} /> + {hiddenFileCount > 0 && ( +
+ +
+ )}
); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 91b14485136ba..6e5f2f7b5cc60 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -52,6 +53,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -78,6 +80,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, + onFindInThread, }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -193,6 +196,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, + onFind: onFindInThread, }); const titleButtonRef = useRef(null); const titleMenuTimerRef = useRef(null); diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index ef07497171ee2..0710f2534dada 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -33,27 +33,36 @@ const variantColors: Record = { "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", }; +/** + * An attached surface tucks its bottom under the card below it. A detached one is a whole card + * of its own, for a notice that sits apart from the composer. + */ function Surface({ variant = "default", + detached = false, className, ...props }: ComponentProps<"div"> & { variant?: ComposerBannerVariant; + detached?: boolean; }) { return (
& { +}: ComponentProps & { density?: "default" | "comfortable" | "spacious"; - variant?: ComposerBannerVariant; width?: "fill" | "content"; }) { return ( diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 086826cde86db..755d20ba8c6ae 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -7,7 +7,7 @@ import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; import { observeResize } from "~/lib/observeResize"; /** - * Where a notice sits above the composer. The machine comes first, then what needs you; the + * Where a notice sits in the stack. The machine comes first, then what needs you; the * order is fixed so rows never trade places as notices come and go. */ export type ComposerBannerGroup = "environment" | "attention"; @@ -27,23 +27,33 @@ export interface ComposerBannerStackItem { const GROUPS = ["environment", "attention"] as const; +const FADE_IN = + "transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"; + /** * Notices about sending from this composer, each its own attached card. Consecutive cards join - * edge to edge, and the last joins the composer or the drawer below it. + * edge to edge, and the last joins the composer or the drawer below it. Detached cards stand + * apart instead, for layouts where the composer must not move when a notice appears. */ export function ComposerBannerStack({ items, + detached = false, }: { readonly items: ReadonlyArray; + readonly detached?: boolean; }) { - return GROUPS.flatMap((group) => + const ordered = GROUPS.flatMap((group) => items.filter((item) => (item.group ?? "attention") === group), - ).map((item) => ( - + ); + if (detached) { + return ordered.map((item) => ( +
+ +
+ )); + } + return ordered.map((item) => ( + )); @@ -125,11 +135,18 @@ export function NoticeText({ ); } -function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { +function NoticeCard({ + item, + detached = false, +}: { + readonly item: ComposerBannerStackItem; + readonly detached?: boolean; +}) { return ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 52800d94a8bca..58ad8e8c0900d 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -9,6 +9,7 @@ import { import { makeThreadRequestsResolvedAnnouncer } from "@t3tools/client-runtime/state/thread-requests"; import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ ...(await importOriginal()), @@ -73,20 +74,16 @@ function questionProjection(threadId: ThreadId, requestIds = ["first", "second"] } satisfies OrchestrationV2ThreadProjection; } -let requests: ReturnType; - -function PendingRequestsProbe({ - environmentId, - projection, - loading = false, -}: { +interface ProbeProps { environmentId: EnvironmentId; projection: OrchestrationV2ThreadProjection; loading?: boolean; -}) { +} + +function useProbeRequests({ environmentId, projection, loading = false }: ProbeProps) { const promptRef = useRef(""); const composerRef = useRef(null); - const state = usePendingThreadRequests({ + return usePendingThreadRequests({ environmentId, activeThreadId: projection.thread.id, serverProjection: loading ? null : projection, @@ -97,6 +94,12 @@ function PendingRequestsProbe({ composerRef, setThreadError: () => {}, }); +} + +let requests: ReturnType; + +function PendingRequestsProbe(props: ProbeProps) { + const state = useProbeRequests(props); useLayoutEffect(() => { requests = state; }); @@ -138,6 +141,40 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); }); +function QuestionPositionProbe(props: ProbeProps) { + const state = useProbeRequests(props); + return ( + <> + Question {state.activePendingQuestionIndex + 1} + + + + ); +} + +it("stays on an unanswered question when the composer submits", async () => { + await using screen = await render( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 1"); + + await userEvent.click(screen.getByRole("button", { name: "Choose Incremental" })); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 2"); +}); + it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { using _timers = fakeTimers(); const environmentId = EnvironmentId.make("scope-environment"); diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index 13614b8c9bd4c..213189064ee11 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -41,7 +41,7 @@ function Host({ className, ...props }: ComponentProps<"div">) { "relative z-10 w-full rounded-2xl shadow-composer after:z-1 dark:shadow-none", "group-data-context-row/composer-surface:before:pointer-events-none group-data-context-row/composer-surface:before:absolute group-data-context-row/composer-surface:before:inset-0 group-data-context-row/composer-surface:before:-z-1 group-data-context-row/composer-surface:before:rounded-2xl group-data-context-row/composer-surface:before:bg-(--chat-composer-glass-surface)/(--glass-opacity) group-data-context-row/composer-surface:before:backdrop-blur-(--glass-blur) group-data-context-row/composer-surface:before:backdrop-saturate-(--glass-saturation)", outlineClasses, - "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden", + "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:before:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden", className, )} {...props} diff --git a/apps/web/src/components/chat/DiffStatLabel.test.ts b/apps/web/src/components/chat/DiffStatLabel.test.ts new file mode 100644 index 0000000000000..2ea8bcdb83559 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatCompactDiffCount } from "./DiffStatLabel"; + +describe("formatCompactDiffCount", () => { + it("promotes counts when rounding reaches the next suffix", () => { + expect(formatCompactDiffCount(999_499)).toBe("999k"); + expect(formatCompactDiffCount(999_500)).toBe("1m"); + expect(formatCompactDiffCount(999_499_999)).toBe("999m"); + expect(formatCompactDiffCount(999_500_000)).toBe("1b"); + }); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 9e4c0f7cef5e9..2b60c35723a6f 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -5,13 +5,13 @@ export function hasNonZeroStat(stat: { additions: number; deletions: number }): return stat.additions > 0 || stat.deletions > 0; } -function formatCompactDiffCount(value: number): string { +export function formatCompactDiffCount(value: number): string { if (value < 1000) return String(value); - if (value < 1_000_000) { + if (value < 999_500) { const k = value / 1000; return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`; } - if (value < 1_000_000_000) { + if (value < 999_500_000) { const m = value / 1_000_000; return `${m < 10 ? m.toFixed(1).replace(/\.0$/, "") : Math.round(m)}m`; } diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 2e00e47ee7da0..f1df96dd92133 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -3,7 +3,7 @@ import type { RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; -import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { ChevronDownIcon, SquareIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { AgentElapsed } from "./AgentElapsed"; @@ -14,6 +14,7 @@ import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Spinner } from "../ui/spinner"; import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, @@ -158,7 +159,7 @@ export function ThreadLineageWorkflowRow({ } > {stopping ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index e107bbc4039c0..69403392b5ce8 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,7 +49,6 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, - LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, SquareIcon, @@ -76,6 +75,7 @@ import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; +import { Spinner } from "../ui/spinner"; import { cn } from "~/lib/utils"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, @@ -802,7 +802,7 @@ export function ThreadRelationshipsPanel(props: { onClick={() => void merge()} > {busyAction === "merge" ? ( - + ) : ( )} @@ -841,7 +841,7 @@ export function ThreadRelationshipsPanel(props: { } > {stoppingId === threadId ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 24481a5054990..55f1a164b5eb2 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -9,6 +9,7 @@ import { Button } from "../ui/button"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; import { McpAppFrame } from "./McpAppFrame"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; @@ -348,6 +349,7 @@ function AssistantChangedFilesSectionInner({ diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 855148c3c9de6..755011e94a4a7 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -34,7 +34,7 @@ import { type LucideIcon, MessagesSquareIcon, Redo2Icon, - Undo2Icon, + PencilIcon, } from "lucide-react"; import { Button, InlineButton } from "../ui/button"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; @@ -699,7 +699,7 @@ function RevertUserMessageButton({ /> } > - + Edit from here diff --git a/apps/web/src/components/chat/composerFocusRedirect.ts b/apps/web/src/components/chat/composerFocusRedirect.ts index 9f791f944ea3e..f7730b367925e 100644 --- a/apps/web/src/components/chat/composerFocusRedirect.ts +++ b/apps/web/src/components/chat/composerFocusRedirect.ts @@ -1,3 +1,5 @@ +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; + export const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", @@ -48,11 +50,13 @@ const SURFACE_LAUNCHER_KEYS_ATTRIBUTE = "data-surface-launcher-keys"; // The right-panel launcher registers its own window capture listener when it // mounts. When the panel opens after page load that listener runs after ours, // so we must leave its letters alone or the composer takes them first. -function isSurfaceLauncherKey(key: string): boolean { +function isSurfaceLauncherKey(event: KeyboardEvent): boolean { const launcherKeys = document .querySelector(`[${SURFACE_LAUNCHER_KEYS_ATTRIBUTE}]`) - ?.getAttribute(SURFACE_LAUNCHER_KEYS_ATTRIBUTE); - return launcherKeys?.toLowerCase().includes(key.toLowerCase()) ?? false; + ?.getAttribute(SURFACE_LAUNCHER_KEYS_ATTRIBUTE) + ?.toLowerCase(); + const key = surfaceShortcutKey(event); + return key !== null && (launcherKeys?.includes(key) ?? false); } export function shouldRedirectInputToComposer(event: Event): boolean { @@ -70,7 +74,7 @@ export function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; if (!shouldRedirectInputToComposer(event)) return false; - if (isSurfaceLauncherKey(event.key)) return false; + if (isSurfaceLauncherKey(event)) return false; return true; } diff --git a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx index b601de299dac5..64f56007ceaaa 100644 --- a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx +++ b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; import { render } from "../../../test/render"; import type { ChatComposerHandle } from "./ChatComposer"; import { useChatShortcuts } from "./useChatShortcuts"; @@ -25,7 +26,8 @@ function Launcher(props: { keys: string; received: string[] }) { useEffect(() => { const claim = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (!props.keys.toLowerCase().split("").includes(event.key.toLowerCase())) return; + const key = surfaceShortcutKey(event); + if (key === null || !props.keys.toLowerCase().includes(key)) return; event.preventDefault(); props.received.push(event.key); }; @@ -94,3 +96,39 @@ it("still types other letters into the composer while the launcher is open", asy expect(composerKeys).toEqual(["a"]); expect(received).toEqual([]); }); + +it("leaves a non-Latin launcher letter to the launcher by its physical key", async () => { + const composerKeys: string[] = []; + const received: string[] = []; + await using screen = await render(, options); + await screen.rerender(); + + // Playwright cannot press a key its US layout lacks, so send what a Ukrainian T key sends. + const event = new KeyboardEvent("keydown", { + key: "\u0435", + code: "KeyT", + bubbles: true, + cancelable: true, + }); + (document.activeElement ?? document.body).dispatchEvent(event); + + expect(composerKeys).toEqual([]); + expect(received).toEqual(["\u0435"]); +}); + +it.each([ + { key: "b", code: "KeyN", composer: [], launcher: ["b"] }, + { key: "x", code: "KeyB", composer: ["x"], launcher: [] }, + { key: "è", code: "KeyT", composer: ["è"], launcher: [] }, +])("routes $key on $code by the typed letter", async ({ key, code, composer, launcher }) => { + const composerKeys: string[] = []; + const received: string[] = []; + await using screen = await render(, options); + await screen.rerender(); + + const event = new KeyboardEvent("keydown", { key, code, bubbles: true, cancelable: true }); + (document.activeElement ?? document.body).dispatchEvent(event); + + expect(composerKeys).toEqual(composer); + expect(received).toEqual(launcher); +}); diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index 91c5bd0d6c912..8a07b86b5eece 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -473,6 +473,7 @@ export function usePendingThreadRequests(input: { } return; } + if (!activePendingProgress.canAdvance) return; setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }; const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx index 9785a6764b2b6..3915ecce9a4ca 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx @@ -1,3 +1,4 @@ +import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; @@ -34,3 +35,109 @@ describe("HtmlRenderDocument", () => { expect(onContentHeight).toHaveBeenCalledTimes(1); }); }); + +it("keeps the first and last selected card rings inside the fitted renderer frame", async () => { + const html = injectHtmlRenderBootstrap(`
+ + + +
`); + const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); + using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + const container = document.createElement("div"); + container.style.width = "728px"; + const reports = new Map< + string, + { + selected: boolean; + ring: number; + left: number; + right: number; + top: number; + bottom: number; + pageHeight: number; + viewportHeight: number; + viewportWidth: number; + scrollWidth: number; + } + >(); + const messages = new AbortController(); + using _messages = { [Symbol.dispose]: () => messages.abort() }; + window.addEventListener( + "message", + (event) => { + const frame = container.querySelector("iframe"); + if (event.source === frame?.contentWindow && event.data?.type === "selected-card-bounds") { + reports.set(event.data.id, event.data); + } + }, + { signal: messages.signal }, + ); + const contentHeight = vi.fn((height: number) => { + container.style.setProperty("--content-height", `${height}px`); + }); + await using screen = await render( + <> + + + , + { container }, + ); + + await vi.waitFor(() => expect(contentHeight).toHaveBeenCalled()); + const frame = screen.getByTitle("Edge cards").element() as HTMLIFrameElement; + await vi.waitFor(() => { + expect(frame.getBoundingClientRect().height).toBe(contentHeight.mock.lastCall?.[0]); + }); + + for (const id of ["first", "last"]) { + frame.contentWindow?.postMessage({ type: "select-card", id }, "*"); + await vi.waitFor(() => expect(reports.get(id)?.selected).toBe(true)); + const bounds = reports.get(id)!; + expect(bounds.ring).toBe(5); + expect(bounds.viewportWidth).toBe(728); + expect(bounds.pageHeight).toBe(contentHeight.mock.lastCall?.[0]); + expect(bounds.viewportHeight).toBe(bounds.pageHeight); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth); + expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight); + expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.viewportWidth); + } +}); diff --git a/apps/web/src/components/files/FileExplorerPane.tsx b/apps/web/src/components/files/FileExplorerPane.tsx new file mode 100644 index 0000000000000..c13a814f63051 --- /dev/null +++ b/apps/web/src/components/files/FileExplorerPane.tsx @@ -0,0 +1,90 @@ +import { type CSSProperties, type ReactNode, useState } from "react"; + +import { RightPanelResizeHandle } from "~/components/preview/RightPanelResizeHandle"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "~/hooks/useResizableWidth"; +import { observeResize } from "~/lib/observeResize"; +import { cn } from "~/lib/utils"; + +const MIN_WIDTH = 160; +const MAX_WIDTH = 560; +const FILE_CONTENT_MIN_WIDTH = 256; + +export function FileExplorerPane({ + resizable, + children, +}: { + resizable: boolean; + children: ReactNode; +}) { + const [maxWidth, setMaxWidth] = useState(MAX_WIDTH); + const { width, setWidth, handlers } = useResizableWidth({ + storageKey: "t3code.fileExplorerWidth", + defaultWidth: 256, + minWidth: MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index fce5d49406bf2..090000587492a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -64,6 +64,7 @@ import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { PreviewImage } from "./PreviewImage"; import FileBrowserPanel from "./FileBrowserPanel"; +import { FileExplorerPane } from "./FileExplorerPane"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; @@ -1424,14 +1425,7 @@ export default function FilePreviewPanel({ ) : null}
{showExplorer ? ( - + ) : null}
diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 22cb582493df2..3bc371b5c1ab9 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileBreadcrumbChildren, fileBreadcrumbs, retainedBreadcrumbTrail } from "./filePath"; +import { + breadcrumbPathOf, + fileBreadcrumbChildren, + fileBreadcrumbs, + isListableHostFolder, + retainedBreadcrumbTrail, +} from "./filePath"; describe("fileBreadcrumbs", () => { it("starts host paths outside the workspace at the filesystem root", () => { @@ -20,6 +26,26 @@ describe("fileBreadcrumbs", () => { "\\\\server\\share\\report.md", ]); }); + + it("builds home-relative breadcrumbs with navigable folders", () => { + expect(fileBreadcrumbs("t3code", "~/.claude/CLAUDE.md")).toEqual([ + { label: "~", path: "~/", kind: "directory" }, + { label: ".claude", path: "~/.claude", kind: "directory" }, + { label: "CLAUDE.md", path: "~/.claude/CLAUDE.md", kind: "file" }, + ]); + expect(isListableHostFolder("~/")).toBe(true); + expect(isListableHostFolder("~/.claude")).toBe(true); + }); + + it("keeps a project folder named ~ inside the project", () => { + expect(fileBreadcrumbs("t3code", "~").map((crumb) => crumb.path)).toEqual(["", "~"]); + }); +}); + +describe("breadcrumbPathOf", () => { + it("keeps the home root listable", () => { + expect(breadcrumbPathOf("~/")).toBe("~/"); + }); }); describe("fileBreadcrumbChildren", () => { diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 76e10839bb2c8..f9f3c9a5ac40a 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -23,7 +23,8 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File return { label: part, // A bare drive (`C:`) means the drive's current directory; its root is `C:\`. - path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : path, + // A bare `~` is not a host path; the home folder is `~/`. + path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : hostPath && path === "~" ? "~/" : path, kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), }; }), diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx index dd644ccf0c822..851daaebe5eab 100644 --- a/apps/web/src/components/preview/RightPanelResizeHandle.tsx +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -1,16 +1,23 @@ +import type { ComponentProps } from "react"; import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; -interface Props { +interface Props extends Pick< + ComponentProps<"div">, + "tabIndex" | "onKeyDown" | "aria-valuenow" | "aria-valuemin" | "aria-valuemax" +> { handlers: ResizableWidthHandlers; className?: string; + label?: string; } -export function RightPanelResizeHandle({ handlers, className }: Props) { +export function RightPanelResizeHandle({ handlers, className, label, ...separatorProps }: Props) { return (
); diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx new file mode 100644 index 0000000000000..cdb7d4099e5c7 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx @@ -0,0 +1,70 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, ProjectId, type PullRequestDiffResult } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../../test/render"; + +const state = vi.hoisted(() => ({ + pages: new Map>>(), +})); +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { + diff: ({ input }: { input: { cursor?: string } }) => state.pages.get(input.cursor ?? null)!, + }, +})); + +import { useGuideDiffFiles } from "./useGuideDiffFiles"; + +function Probe({ wantedPaths }: { wantedPaths: string[] }) { + const diff = useGuideDiffFiles({ + environmentId: EnvironmentId.make("test"), + reference: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + headSha: "new-head", + wantedPaths, + }); + return ( + + {JSON.stringify({ + loading: diff.loading, + files: [...diff.files].map(([path, file]) => [path, file.additionLines.join("")]), + })} + + ); +} + +function page(path: string, value: string, nextCursor: string | null): PullRequestDiffResult { + return { + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+${value}\n`, + truncated: false, + nextCursor, + }; +} + +it.each([ + { name: "contains the requested file", path: "wanted.ts", cursor: null }, + { name: "ends before a newly added file", path: "other.ts", cursor: null }, + { name: "has an obsolete next cursor", path: "other.ts", cursor: "old-next" }, +])("waits for a cached page that $name to settle", async ({ path, cursor }) => { + state.pages.clear(); + await using stack = new AsyncDisposableStack(); + const registry = stack.adopt(AtomRegistry.make(), (value) => value.dispose()); + const first = Atom.make>( + AsyncResult.success(page(path, "STALE", cursor), { waiting: true }), + ); + state.pages.set(null, first); + state.pages.set( + "old-next", + Atom.make>(AsyncResult.initial(true)), + ); + await using screen = await render( + + + , + ); + const result = () => JSON.parse(screen.container.querySelector("output")!.textContent!); + expect(result()).toEqual({ loading: true, files: [] }); + + await act(() => registry.set(first, AsyncResult.success(page("wanted.ts", "FRESH", null)))); + expect(result()).toEqual({ loading: false, files: [["wanted.ts", "FRESH"]] }); +}); diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts index c905488949cff..21765c68d18e8 100644 --- a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts @@ -51,7 +51,7 @@ export function useGuideDiffFiles({ ); const data = query.data; - if (data !== null && !current.done && current.folded !== data) { + if (data !== null && !query.isPending && !current.done && current.folded !== data) { const parsed = getRenderablePatch( data.patch, `pull-request-guide:${key}:${current.cursor ?? "first"}`, diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts index ec3319f010843..59d7e0c83ad12 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { findScopedProject } from "./pullRequestList.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; const cups = EnvironmentId.make("env-cups"); const nucbox = EnvironmentId.make("env-nucbox"); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index c4e917fa7134e..fe22fe5af2838 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -36,7 +36,7 @@ import { import type { PullRequestExpectedHost } from "./PullRequestListFilters"; import type { PullRequestListPreferences, PullRequestListSort } from "./pullRequestListPreferences"; import { assignProjectsToEnvironments } from "./pullRequestProjectAssignment.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; export interface PullRequestsSearch extends PullRequestListPreferences { diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..124a27c7b11f4 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -1,3 +1,5 @@ +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { DEFAULT_UNIFIED_SETTINGS, EnvironmentId, @@ -10,7 +12,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; const environmentId = EnvironmentId.make("laptop"); @@ -38,7 +40,23 @@ const task: ScheduledTask = { runCount: 0, }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const secondaryId = EnvironmentId.make("secondary"); +const permissions = new Map>(); + +function setup() { + state.connected = true; + state.tasksLoaded = true; + state.secondary = false; + permissions.clear(); + permissions.set(environmentId, Atom.make(true)); + permissions.set(secondaryId, Atom.make(true)); + permissions.set("", Atom.make(false)); +} + +function secondaryEnvironment() { + return { ...environment(), environmentId: secondaryId, label: "Secondary" }; +} function environment() { return { @@ -54,8 +72,12 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { const entry = environment(); return { scope: { kind: "all", label: "All", members: [], environmentIds: [environmentId] }, - environments: [entry], - connectedEnvironments: state.connected ? [entry] : [], + environments: state.secondary ? [entry, secondaryEnvironment()] : [entry], + connectedEnvironments: state.connected + ? state.secondary + ? [entry, secondaryEnvironment()] + : [entry] + : [], environment: entry, target: null, targets: [], @@ -68,15 +90,14 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { }); vi.mock(import("../../state/server"), async (original) => { const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const allowed = Atom.make(true); + return { ...actual, serverEnvironment: { ...actual.serverEnvironment, upsertScheduledTask: { ...actual.serverEnvironment.upsertScheduledTask, - permissionAtom: () => allowed, + permissionAtom: (id: string | null) => permissions.get(id ?? "")!, }, }, }; @@ -93,7 +114,8 @@ vi.mock(import("../../state/query"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => environment()) as never, + useEnvironment: ((id: EnvironmentId) => + id === secondaryId ? secondaryEnvironment() : environment()) as never, })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), @@ -105,9 +127,44 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ })); describe("ScheduledTasksSettings", () => { + it("creates on a permitted secondary and reacts to permission changes", async () => { + setup(); + state.secondary = true; + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + registry.set(permissions.get(environmentId)!, false); + await using screen = await render( + + + , + ); + const create = screen.getByRole("button", { name: "New task" }); + await expect.element(create).toBeEnabled(); + await act(() => registry.set(permissions.get(secondaryId)!, false)); + await expect.element(create).toBeDisabled(); + await act(() => registry.set(permissions.get(secondaryId)!, true)); + await expect.element(create).toBeEnabled(); + await create.click(); + await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Secondary"); + }); + + it("keeps the permitted representative as the creation target", async () => { + setup(); + state.secondary = true; + await using screen = await render(); + await screen.getByRole("button", { name: "New task" }).click(); + await expect.element(page.getByLabelText("Runs on")).toHaveTextContent("Laptop"); + }); + + it("disables creation without a connected target", async () => { + setup(); + state.connected = false; + await using screen = await render(); + await expect.element(screen.getByRole("button", { name: "New task" })).toBeDisabled(); + }); + it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -124,7 +181,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" })); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 38deb39fa6382..c54f4158f1631 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -267,9 +267,18 @@ export function ScheduledTasksSettings(target: { replace: true, }); }; - const canCreate = useAtomValue( - serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), + const createPermissions = useAtomValue( + scheduledTaskCreatePermissionsAtom( + environmentIdsKey(connectedEnvironments.map((entry) => entry.environmentId)), + ), ); + const permittedEnvironments = connectedEnvironments.filter( + (_entry, index) => createPermissions[index], + ); + const creationEnvironment = + permittedEnvironments.find( + (entry) => entry.environmentId === defaultEnvironment?.environmentId, + ) ?? permittedEnvironments[0]; return ( - defaultEnvironment && - setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) + creationEnvironment && + setEditor({ environmentId: creationEnvironment.environmentId, task: null }) } > @@ -335,6 +344,11 @@ export function ScheduledTasksSettings(target: { ); } +const scheduledTaskCreatePermissionsAtom = environmentAtomList( + "web-scheduled-task-create-permissions", + serverEnvironment.upsertScheduledTask.permissionAtom, +); + const scheduledTasksListAtom = environmentAtomList("web-scheduled-tasks-list", (environmentId) => serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), ); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 17d394cb3705c..803126a6478b0 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -286,11 +286,10 @@ function ThemeImportBody({ } } if (installed.length > 0) onImportedMany(installed, { updated: false }); - if (failures.length > 0) { - setError(failures.join("; ")); - } else if (conflicting.length > 0) { + setError(failures.length > 0 ? failures.join("; ") : null); + if (conflicting.length > 0) { setConflicts(conflicting); - } else if (installed.length > 0) { + } else if (installed.length > 0 && failures.length === 0) { onClose(); } }; @@ -376,8 +375,8 @@ function ThemeImportBody({ } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); - if (failures.length > 0) setError(failures.join("; ")); - else onClose(); + if (failures.length > 0) setError([error, ...failures].filter(Boolean).join("; ")); + else if (!error) onClose(); }; const handleSubmit = () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 533406a745dd2..5a8f8b5f9dfd8 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -3,6 +3,7 @@ import type { ContextMenuItem } from "../localApiTypes"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; export type ThreadActionMenuId = + | "find" | "new-thread-on-branch" | "filter-by-project" | "turn-into-project" @@ -68,6 +69,7 @@ export function buildDraftActionMenuItems(options: { export interface ThreadActionMenuState { readonly canOperate: boolean; + readonly canFind?: boolean; readonly branch: string | null; readonly projectFilter: { readonly label: string; @@ -96,6 +98,7 @@ export interface ThreadActionMenuState { /** Local navigation, read markers, and copying remain available to read-only clients. */ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { return ![ + "find", "new-thread-on-branch", "project-settings", "mark-unread", @@ -111,6 +114,9 @@ export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { const items: ReadonlyArray> = [ + ...(state.canFind + ? [{ id: "find" as const, label: "Find in thread", icon: "text-search" }] + : []), ...(state.branch ? [ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 45cb931f0d58d..eccb5a9450d15 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -145,6 +145,13 @@ const ICON_PATHS: Record void; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; @@ -55,6 +56,16 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const clampedWidth = clamp(widthState.width); + const commitWidth = (nextStorageKey: string, nextWidth: number) => { + // Resizing remains usable for this session when browser storage is unavailable. + setWidthState({ storageKey: nextStorageKey, width: nextWidth }); + try { + setLocalStorageItem(nextStorageKey, nextWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }; + const drag = useResizeDrag((event) => { const host = event.currentTarget.parentElement; // Disable drag transitions through transition-property; the host renders transition-duration. @@ -72,12 +83,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return nextWidth; }, finish(finalWidth) { - setWidthState({ storageKey: startStorageKey, width: finalWidth }); - try { - setLocalStorageItem(startStorageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } + commitWidth(startStorageKey, finalWidth); }, cleanup(committed) { host?.style.removeProperty("transition-property"); @@ -94,6 +100,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return { width: clampedWidth, + setWidth: (value) => commitWidth(storageKey, clamp(value)), handlers: { ref: drag.ref, onPointerDown: drag.onPointerDown, diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 80400a2e4f541..34633525e7710 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -145,11 +145,12 @@ const target = { threadId: ThreadId.make("thread"), }; const position = { x: 10, y: 20 }; -const createMenu = () => +const createMenu = (options: { readonly onFind?: (threadRef: typeof target) => void } = {}) => useThreadActionMenu({ threadRef: target, projectCwd: "/project", onStartRename: () => recordEffect("rename"), + onFind: options.onFind, }); beforeEach(() => { @@ -173,4 +174,18 @@ describe("thread menu permissions", () => { expect(state.effects).toEqual([]); }, ); + + it("offers find only to a header that can open it, without task permission", async () => { + createMenu().openMenu(position); + expect(state.show.mock.calls[0]![0].some((item) => item.id === "find")).toBe(false); + state.show.mockResolvedValue("find"); + const onFind = vi.fn(() => recordEffect("find")); + createMenu({ onFind }).openMenu(position); + await state.completed.promise; + const items = state.show.mock.calls[1]![0]; + expect(items[0]).toMatchObject({ id: "find", label: "Find in thread" }); + expect(items[0]?.disabled).not.toBe(true); + expect(state.effects).toEqual(["find"]); + expect(onFind).toHaveBeenCalledExactlyOnceWith(target); + }); }); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index e81697d572524..ebbff4ae0f6fd 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -60,9 +60,10 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; readonly projectCwd: string | null; readonly onStartRename: () => void; + readonly onFind?: ((threadRef: ScopedThreadRef) => void) | undefined; // Coder: no guest sharing, so no `canShareWithGuest` input or "share-guest" action. }) { - const { threadRef, projectCwd, onStartRename } = input; + const { threadRef, projectCwd, onStartRename, onFind } = input; const router = useRouter(); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), + canFind: onFind !== undefined, branch: thread.branch ?? null, projectFilter: null, isPinned: thread.pinnedAt != null, @@ -185,6 +187,9 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "find": + onFind?.(threadRef); + return; case "project-settings": { const project = projects.find( (candidate) => diff --git a/apps/web/src/lib/surfaceShortcutKey.ts b/apps/web/src/lib/surfaceShortcutKey.ts new file mode 100644 index 0000000000000..98c4d6660da11 --- /dev/null +++ b/apps/web/src/lib/surfaceShortcutKey.ts @@ -0,0 +1,14 @@ +type SurfaceKeyEvent = Pick & + Partial>; + +export function surfaceShortcutKey(event: SurfaceKeyEvent): string | null { + // AltGr text must stay text even when the browser omits Ctrl and Alt. + if (event.getModifierState?.("AltGraph") && !/^[a-z0-9]$/i.test(event.key)) return null; + const typed = event.key.toLowerCase(); + // Preserve Latin layouts, punctuation and dead keys; non-Latin letters/marks use the physical key. + const physical = + /^[\p{L}\p{M}]+$/u.test(typed) && !/\p{Script=Latin}/u.test(typed) + ? event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase() + : undefined; + return physical ?? typed; +} diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index 810a04755e895..e841e18ce24d2 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -98,20 +98,6 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { return [...subdirectories, ...files]; } -export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { - return files.reduce( - (acc, file) => { - const stat = readStat(file); - if (!stat) return acc; - return { - additions: acc.additions + stat.additions, - deletions: acc.deletions + stat.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); -} - export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { const root: MutableDirectoryNode = { name: "", diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 9c4e38c30e510..df3eb85c3db38 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1307,6 +1307,23 @@ describe("HTML renders in the timeline", () => { expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]); }); + it("hosts an MCP app whose call failed, as a form-deferring write does", () => { + const mcpApp = { + attachmentId: "render-thread-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const call = renderCall("failed", { + t3McpApp: mcpApp, + result: { content: [], isError: true }, + }); + const entries = turn( + call.type === "dynamic_tool" ? { ...call, toolName: "github.create_pull_request" } : call, + ); + expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({ mcpApp }); + }); + it("keeps a render visible when its superseded attempt folds", () => { const attempt: OrchestrationV2RunAttempt = { id: RunAttemptId.make("attempt-superseded"), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 81e96f2609ae7..738a8c8089e54 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -711,10 +711,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( continue; } - const mcpApp = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const mcpApp = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (mcpApp !== undefined) { entries.push({ id: item.id, diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 3bd8a89807e44..60c81478f7436 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -1067,7 +1067,8 @@ export class GhosttyTerminalCore { } } let wide = 0; - if (text.length === 0 && cells.at(-1)?.text.length) { + // A wrap spacer in the final column can follow a spacer tail. + if (text.length === 0 && (cells.at(-1)?.text.length || cells.length === cols - 1)) { this.assertSuccess( "ghostty_render_state_row_cells_get(raw)", this.runtime.call( diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 8816105599b83..5473e3c4ffa1c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -512,6 +512,42 @@ const cell = (text: string): GhosttyCell => ({ }); describe("terminalLinkAtPositionWithRange", () => { + it.each([ + { text: "https://example.com/日本", cols: 80, end: { x: 23, y: 0 } }, + { text: "src/日本/main.ts:3:1", cols: 80, end: { x: 19, y: 0 } }, + { text: "https://example.com/🙂", cols: 80, end: { x: 21, y: 0 } }, + { text: "https://example.com/日本", cols: 23, end: { x: 1, y: 1 } }, + ])("keeps every cell of $text clickable at $cols columns", async ({ text, cols, end }) => { + const core = await GhosttyTerminalCore.create( + cols, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + try { + core.write(text + " next"); + const rows = core.snapshot().rowData; + for (let y = 0; y <= end.y; y += 1) { + const lastColumn = y === end.y ? end.x : cols - 2; + for (let x = 0; x <= lastColumn; x += 1) { + expect(terminalLinkAtPositionWithRange(rows, y, x)).toEqual({ + text, + range: { start: { x: 0, y: 0 }, end }, + }); + } + } + expect(terminalLinkAtPositionWithRange(rows, end.y, end.x + 1)).toBeNull(); + } finally { + core.dispose(); + } + }); + it("activates a wrapped quoted Windows path from its spaces and position suffix", () => { const row = (text: string, isWrapContinuation: boolean): GhosttyRow => ({ cells: Array.from(text, (character) => cell(character)), diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index cb05acdc5e03d..802d39e594075 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -2,7 +2,9 @@ import { isMacPlatform } from "../../lib/utils"; import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { + GHOSTTY_CELL_WIDE, GhosttyTerminalCore, + type GhosttyCell, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme, @@ -201,15 +203,23 @@ function terminalGridCellAt(options: { }; } +function terminalCellText(cell: GhosttyCell): string { + if (cell.wide === GHOSTTY_CELL_WIDE.spacerTail || cell.wide === GHOSTTY_CELL_WIDE.spacerHead) { + return ""; + } + return cell.text || " "; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { - const text = row.cells.map((cell) => cell.text || " ").join(""); + const text = row.cells.map(terminalCellText).join(""); return trimRight ? text.trimEnd() : text; } function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { - offset += row.cells[cellIndex]?.text.length || 1; + const cell = row.cells[cellIndex]; + offset += cell ? terminalCellText(cell).length : 1; } return offset; } @@ -265,7 +275,8 @@ export function terminalLinkAtPositionWithRange( const lastSegment = wrappedLine.segments.at(-1); const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; - const offset = segment.startIndex + terminalColumnOffset(row, column); + const hitColumn = row.cells[column]?.wide === GHOSTTY_CELL_WIDE.spacerTail ? column - 1 : column; + const offset = segment.startIndex + terminalColumnOffset(row, hitColumn); for (const match of extractTerminalLinks(wrappedLine.text)) { if (offset >= match.start && offset < match.end) { if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; @@ -278,6 +289,7 @@ export function terminalLinkAtPositionWithRange( const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; if (!startSegment || !endSegment || !startRow || !endRow) return null; + const endColumn = terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex); return { text: match.text, range: { @@ -286,7 +298,10 @@ export function terminalLinkAtPositionWithRange( y: startSegment.bufferLineNumber - 1, }, end: { - x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + x: + endRow.cells[endColumn + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail + ? endColumn + 1 + : endColumn, y: endSegment.bufferLineNumber - 1, }, }, diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 57e8836f27ff5..1ad8ba86c06ae 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -7,6 +7,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore, } from "./terminalUiStateStore"; +import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; const THREAD_ID = ThreadId.make("thread-1"); const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); @@ -138,6 +139,27 @@ describe("terminalUiStateStore actions", () => { ]); }); + it.each([ + ["terminal-3", "terminal-3", DEFAULT_THREAD_TERMINAL_ID], + [DEFAULT_THREAD_TERMINAL_ID, DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ["terminal-3", DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ])("keeps the split's tab when %s is active and %s closes", (activeId, closedId, nextId) => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.newTerminal(THREAD_REF, "terminal-2"); + store.setActiveTerminal(THREAD_REF, DEFAULT_THREAD_TERMINAL_ID); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.setActiveTerminal(THREAD_REF, activeId); + store.closeTerminal(THREAD_REF, closedId); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.activeTerminalId).toBe(nextId); + expect(terminalUiState.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + it("does not import a closed panel terminal from stale metadata", () => { const store = useTerminalUiStateStore.getState(); store.newTerminal(THREAD_REF, "term-2"); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 6629d8a6d99bf..b442e855ca12d 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -413,9 +413,15 @@ function closeThreadTerminal( } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); + const groupTerminalIds = + normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId)) + ?.terminalIds ?? []; + const groupIndex = groupTerminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId - ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + ? (groupTerminalIds[groupIndex + 1] ?? + groupTerminalIds[groupIndex - 1] ?? + remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? "") : normalized.activeTerminalId; diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index 62a12706175d7..b162f94c58f50 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -581,6 +581,12 @@ listed here is drift to remove rather than fork behavior to keep. "Triage an issue" entry (`CommandPalette.tsx`), the triage draft hero (`DraftHeroHeadline.tsx`), `useHandleNewThread`'s `prompt` option (only triage passes it), client-runtime's `prepareTriage`, and `docs/user/triage.md` are cut. + - Secret providers (#25709, #27044) are not carried: provider credentials stay in the + workspace's own configuration, and the fork adds no credential entry. Web + `settings/SecretsSettings.tsx`, `SecretProviderDialog*`, the `/settings/secrets` route, its + nav and search entries, the `T3CODE_FEATURE_SECRETS` flag, and `OnePasswordIcon` are cut. + Contracts, shared `serverSettings.ts`, and the server's `secretProviders` write check and + `secretProviderSettings` capability stay as upstream's. - The thread dev-servers panel (#22542) is not carried: it lists and stops servers that upstream's preview `PortScanner` discovers, and the browser preview is not carried. `ClaudeAdapterV2.ts`'s query runner gives the CLI's jobs no agent-thread variable and counts @@ -1168,10 +1174,8 @@ listed here is drift to remove rather than fork behavior to keep. and `sourceControlAppAccessToast.ts` are cut (`state/sourceControlActions.ts` suggests no app access after publishing), and client-runtime `state/sourceControl.ts` and `sourceControlClients.ts` have no app-access or sign-in atoms and no `appSignInTargetForRemote`. - - GitLab's definition (`source-control-gitlab/src/shared/definition.ts`) has four deltas: - `isChangeRequestReference` accepts a merge request URL on any HTTP(S) host, because - self-hosted hostnames need not contain "gitlab"; `authorProfileUrl` links comment authors - under the host root; `referenceAutolinks` (`shared/referenceAutolinks.ts`) links `!N` + - GitLab's definition (`source-control-gitlab/src/shared/definition.ts`) has three deltas: + `authorProfileUrl` links comment authors under the host root; `referenceAutolinks` (`shared/referenceAutolinks.ts`) links `!N` merge requests, `#N` issues, and full commit SHAs below the project with GitLab's `/-/` routes, where upstream's GitLab defines none; and `mediaUrl` (`shared/gitLabMedia.ts`) claims Markdown uploads below a project's path, below `/-/project//`, or on the API route, and diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ac26e4e67553f..b6d092c9dffb0 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -330,6 +330,10 @@ "types": "./src/threadReference.ts", "default": "./src/threadReference.ts" }, + "./pull-request-project-filter": { + "types": "./src/pullRequestProjectFilter.ts", + "default": "./src/pullRequestProjectFilter.ts" + }, "./diff-count": { "types": "./src/diffCount.ts", "default": "./src/diffCount.ts" diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 7539861026165..ccc5bf6ef2807 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -199,6 +199,63 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it.each(["already expired", "expires during discovery"])( + "retries an unchanged catalog that %s after the short cooldown", + async (expiry) => { + const start = Date.parse("2026-01-01T00:05:00.000Z"); + vi.useFakeTimers({ now: expiry === "expires during discovery" ? start - 1 : start }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, responses, run, unmount } = setup( + expiry === "expires during discovery" + ? withSnapshot("2026-01-01T00:00:00.000Z", true) + : stale, + [ + () => { + if (expiry === "expires during discovery") vi.setSystemTime(start + 1); + return stale; + }, + ], + ); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(9_999); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + responses.push(() => withSnapshot("2026-01-01T00:05:10.000Z")); + await vi.advanceTimersByTimeAsync(1); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + }, + ); + + it("keeps the TTL guard for a newly scanned catalog when the client clock is ahead", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T01:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z"), [ + () => withSnapshot("2026-01-01T00:01:00.000Z"), + ]); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("bounds retries when discovery repeatedly returns the same expired catalog", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:05:00.000Z") }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, run, unmount } = setup(stale, [() => stale, () => stale, () => stale]); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(3); + unmount(); + }); + it("does not rescan a current snapshot, and rescans once it ages past the TTL", () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index e0e15450669f8..63ded7a47dc5c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -144,7 +144,7 @@ interface ProviderWorkspaceSnapshotRefreshState { readonly requestedAt: number | null; /** A failed scan is not retried before this time. */ readonly retryNotBefore: number | null; - /** Scans in a row that ended without a complete snapshot. */ + /** Failed scans within the current request TTL. */ readonly failedAttempts: number; readonly hadSnapshot: boolean; } @@ -183,8 +183,10 @@ function planProviderWorkspaceSnapshotRefresh( const state = { requestedAt, retryNotBefore, - // A complete snapshot ends the run of failures. - failedAttempts: hasSnapshot ? 0 : previous.failedAttempts, + failedAttempts: + lost || (requestedAt !== null && now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) + ? 0 + : previous.failedAttempts, hadSnapshot: hasSnapshot, }; const refresh = @@ -305,9 +307,18 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { { environmentId, input: { instanceId, cwd } }, { reportFailure: false }, ).then((result) => { + const refreshedProvider = + result._tag === "Success" ? findProvider(result.value.providers) : undefined; + const snapshot = + refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd); + // A new server timestamp proves progress even when the client clock is ahead. + // @effect-diagnostics-next-line globalDate:off + const completedAt = Date.now(); const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot(findProvider(result.value.providers), cwd); + hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && + (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || + snapshot?.checkedAt !== + (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); if (!refreshed) retryLater(); }, retryLater); return plan.state; diff --git a/packages/client-runtime/src/pullRequestLinkInput.test.ts b/packages/client-runtime/src/pullRequestLinkInput.test.ts index 04847071a18b2..95f4dea72831b 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.test.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.test.ts @@ -110,6 +110,13 @@ describe("parsePullRequestReference", () => { expect(parsePullRequestReference("#42")).toBe("42"); }); + it.each([ + "https://code.example.com/group/project/-/merge_requests/42", + "http://git.internal/group/sub/project/-/merge_requests/7", + ])("accepts self-hosted GitLab merge request URL %s", (url) => { + expect(parsePullRequestReference(url)).toBe(url); + }); + it("accepts glab mr checkout commands", () => { expect(parsePullRequestReference("glab mr checkout 42")).toBe("42"); expect(parsePullRequestReference("glab mr checkout #42")).toBe("42"); diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/packages/client-runtime/src/pullRequestProjectFilter.ts similarity index 82% rename from apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts rename to packages/client-runtime/src/pullRequestProjectFilter.ts index 040f3537446cf..03e47dcafab16 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts +++ b/packages/client-runtime/src/pullRequestProjectFilter.ts @@ -1,8 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; - -interface FilterProject extends AssignableProject { +interface FilterProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined; readonly title: string; readonly workspaceRoot: string; } @@ -25,7 +26,7 @@ function distinguishTitles( export function pullRequestFilterProjects( projects: ReadonlyArray, environmentLabels: ReadonlyMap, - selectedProject?: Pick, + selectedProject?: Pick, ) { const byRepository = new Map(); for (const project of projects) { @@ -45,7 +46,7 @@ export function pullRequestFilterProjects( ); const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); - return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + return distinguishTitles(byEnvironment, (project) => project.id).sort((left, right) => left.title.localeCompare(right.title), ); } diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 4501007e20053..ee2fe45d9c756 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -3,6 +3,7 @@ import type { CheckpointRef, CheckpointScopeId, MessageId, + OrchestrationV2CheckpointFileTotals, OrchestrationV2ThreadProjection, RunId, } from "@t3tools/contracts"; @@ -15,12 +16,14 @@ export interface ThreadCheckpointSummary { readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; readonly status: "ready" | "missing" | "error" | "stale"; + /** A prefix of the changed files when `filesTotal` is set; see `checkpointFileTotals`. */ readonly files: ReadonlyArray<{ readonly path: string; readonly kind: string; readonly additions: number; readonly deletions: number; }>; + readonly filesTotal?: OrchestrationV2CheckpointFileTotals; readonly assistantMessageId: MessageId | null; readonly completedAt: string; } @@ -43,9 +46,23 @@ export function deriveThreadCheckpointSummaries( checkpointRef: checkpoint.ref, status: checkpoint.status, files: checkpoint.files, + ...(checkpoint.filesTotal === undefined ? {} : { filesTotal: checkpoint.filesTotal }), assistantMessageId, completedAt: DateTime.formatIso(checkpoint.capturedAt), }, ]; }); } + +export function checkpointFileTotals( + checkpoint: Pick, +): OrchestrationV2CheckpointFileTotals { + if (checkpoint.filesTotal !== undefined) return checkpoint.filesTotal; + let additions = 0; + let deletions = 0; + for (const file of checkpoint.files) { + additions += file.additions; + deletions += file.deletions; + } + return { count: checkpoint.files.length, additions, deletions }; +} diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index a99417534ea7a..adcac7139e82a 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -111,6 +111,24 @@ it("keeps a mark-unread on the open thread until new activity lands", () => { expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); }); +it("keeps mark unread sticky after an expired wake is retired", () => { + using t = setup("thread-unread-retired-wake", { + updatedAt: at(10), + lastVisitedAt: at(0), + latestRun: { completedAt: at(9) }, + wokeAt: at(15), + }); + t.mount(); + expect(t.visits).toEqual([{ threadId: t.ref.threadId, visitedAt: at(15), opened: true }]); + t.update({ lastVisitedAt: at(15) }); + t.update({ lastVisitedAt: at(8), wokeAt: null }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toHaveLength(1); + + t.update({ updatedAt: at(30) }); + expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); +}); + it("waits for the shell and records local visits for servers without tracking", () => { using t = setup("thread-local", null); t.mount(); diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index 08d05909a2dad..c08f47ed01436 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -115,7 +115,8 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { } const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) return; - if (lastDispatchedUpdatedAt === visitedAt) return; + if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs) + return; const completedAtMs = shell.latestRun?.completedAt ? Date.parse(shell.latestRun.completedAt) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 255085cbd6ebc..ae14b33185f51 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.4", + "version": "0.1.5", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 973128da87593..bf321de74d191 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1359,6 +1359,14 @@ export const OrchestrationV2CheckpointFileSummary = Schema.Struct({ }); export type OrchestrationV2CheckpointFileSummary = typeof OrchestrationV2CheckpointFileSummary.Type; +/** Sent only when the wire carries a prefix of `files`; the totals cover the full list. */ +export const OrchestrationV2CheckpointFileTotals = Schema.Struct({ + count: NonNegativeInt, + additions: NonNegativeInt, + deletions: NonNegativeInt, +}); +export type OrchestrationV2CheckpointFileTotals = typeof OrchestrationV2CheckpointFileTotals.Type; + export const OrchestrationV2Checkpoint = Schema.Struct({ id: CheckpointId, threadId: ThreadId, @@ -1371,6 +1379,7 @@ export const OrchestrationV2Checkpoint = Schema.Struct({ ref: CheckpointRef, status: Schema.Literals(["ready", "missing", "error", "stale"]), files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), capturedAt: Schema.DateTimeUtc, }); export type OrchestrationV2Checkpoint = typeof OrchestrationV2Checkpoint.Type; @@ -1669,6 +1678,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ checkpointId: CheckpointId, scopeId: CheckpointScopeId, files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), /** Same as the files of checkpoint `checkpointId`. */ filesOmitted: DedupedBodyMarker, }), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8054ac38500b1..3202ee479136a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -631,6 +631,8 @@ export const ServerConfig = Schema.Struct({ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), threadFind: Schema.optionalKey(Schema.Boolean), threadFindProgressive: Schema.optionalKey(Schema.Boolean), + /** Settings patches may carry `secretProviders`; older servers drop the key without an error. */ + secretProviderSettings: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 25523c2d0fb4a..9d765ccee92c6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -25,6 +25,7 @@ const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); describe("storage cleanup settings", () => { @@ -115,6 +116,55 @@ describe("ServerSettings usage price overrides", () => { }); }); +describe("ServerSettings secret providers", () => { + const onePassword = (account: string) => ({ + kind: "one_password", + label: "Personal", + config: { account }, + }); + + it("keeps an unknown kind or a longer label from invalidating the rest of the settings", () => { + const openbao = { kind: "openbao", label: "V".repeat(65), config: { address: "https://x" } }; + const decoded = decodeServerSettings({ + worktreesDirectory: "/work", + secretProviders: { vault: openbao, personal: onePassword("my.1password.com") }, + }); + expect(decoded.worktreesDirectory).toBe("/work"); + expect(encodeServerSettings(decoded).secretProviders).toEqual({ + vault: openbao, + personal: onePassword("my.1password.com"), + }); + }); + + it("accepts slug ids and null removals but no id that is not a slug", () => { + expect( + decodeServerSettingsPatch({ + secretProviders: { "1password": onePassword("my.1password.com"), work: null }, + }).secretProviders, + ).toEqual({ "1password": onePassword("my.1password.com"), work: null }); + for (const id of ["", "My Vault", "a/b", "-vault"]) { + expect(() => + decodeServerSettingsPatch({ secretProviders: { [id]: onePassword("my.1password.com") } }), + ).toThrow(); + } + expect(() => + decodeServerSettingsPatch({ + secretProviders: { work: { ...onePassword("my.1password.com"), label: "V".repeat(65) } }, + }), + ).toThrow(); + }); + + it("rejects writes of an unknown kind or an account op could misread", () => { + const write = (entry: unknown) => () => + decodeServerSettingsPatch({ secretProviders: { personal: entry } }); + for (const account of ["", "my account", "--help"]) { + expect(write(onePassword(account))).toThrow(); + } + expect(write({ kind: "one_password", label: "Personal" })).toThrow(); + expect(write({ kind: "openbao", label: "Vault", config: { address: "https://x" } })).toThrow(); + }); +}); + describe("custom model settings", () => { const capabilities = { optionDescriptors: [ @@ -323,6 +373,27 @@ it("validates remote device hosts and rejects ambiguous host ids", () => { expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); }); +describe("ServerSettings project overrides across versions", () => { + it("sends back an override key from a newer server when an older client edits the entry", () => { + const projectId = ProjectId.make("project-a"); + // As an older client reads a newer server's settings, with one key it does not know. + const entry = decodeServerSettings({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: { mode: "kept" } }, + }, + }).projectSettingsOverrides[projectId]; + + const sent = encodeServerSettingsPatch({ + projectSettingsOverrides: { [projectId]: { ...entry, defaultAutoPull: false } }, + }); + + expect(decodeServerSettingsPatch(sent).projectSettingsOverrides?.[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: { mode: "kept" }, + }); + }); +}); + describe("resolveProjectProviderInstanceEnabled", () => { const projectId = "project-a" as ProjectId; const instanceId = ProviderInstanceId.make("codex_work"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 536fc235babab..41a431ea514a2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -874,6 +874,76 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +// Final once shipped: older builds drop entries whose key fails here, and erase them on their next save. +// Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. +const SecretProviderSlug = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), +); + +export const SecretProviderId = SecretProviderSlug.pipe(Schema.brand("SecretProviderId")); +export type SecretProviderId = typeof SecretProviderId.Type; + +export const SecretProviderKind = SecretProviderSlug.pipe(Schema.brand("SecretProviderKind")); +export type SecretProviderKind = typeof SecretProviderKind.Type; + +const ONE_PASSWORD_ACCOUNT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** + * Any value `op --account` accepts: shorthand, sign-in address, account ID, or user ID. + * It is passed to the CLI as an argument, so it can never start with `-`. + */ +export const OnePasswordAccount = Schema.String.check( + Schema.isMaxLength(253), + Schema.makeFilter((value: string) => + ONE_PASSWORD_ACCOUNT_PATTERN.test(value) + ? true + : "1Password account must be a shorthand, sign-in address, or ID with no spaces.", + ), +).pipe(Schema.brand("OnePasswordAccount")); +export type OnePasswordAccount = typeof OnePasswordAccount.Type; + +export const SecretProviderLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); + +export const OnePasswordSecretProviderConfig = Schema.Struct({ + account: OnePasswordAccount, +}); +export type OnePasswordSecretProviderConfig = typeof OnePasswordSecretProviderConfig.Type; + +/** Kinds decode their own config; unknown-kind envelopes round-trip verbatim across versions. */ +export const SecretProviderConfig = Schema.Struct({ + kind: SecretProviderKind, + label: TrimmedNonEmptyString, + config: Schema.optionalKey(Schema.Unknown), +}); +export type SecretProviderConfig = typeof SecretProviderConfig.Type; + +/** Kinds this build can resolve. Their `config` holds no credentials, so clients may read it. */ +const SECRET_PROVIDER_KIND_CONFIG_GUARDS: Readonly boolean>> = { + one_password: Schema.is(OnePasswordSecretProviderConfig), +}; + +export function isKnownSecretProviderKind(kind: string): boolean { + return Object.hasOwn(SECRET_PROVIDER_KIND_CONFIG_GUARDS, kind); +} + +// Limits that may change later are enforced on writes only, never on the stored envelope. +// Writes must be a kind this build knows with a config it accepts, so an older server +// rejects a newer kind instead of storing what it cannot check. +const SecretProviderConfigWrite = Schema.Struct({ + ...SecretProviderConfig.fields, + label: SecretProviderLabel, +}).check( + Schema.makeFilter((entry) => { + if (!isKnownSecretProviderKind(entry.kind)) { + return `Unsupported secret provider kind: ${entry.kind}.`; + } + return SECRET_PROVIDER_KIND_CONFIG_GUARDS[entry.kind]!(entry.config) + ? true + : `Invalid ${entry.kind} secret provider config.`; + }), +); + export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; @@ -1082,6 +1152,16 @@ export const ProjectSettingsOverrides = Schema.Struct({ } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; +// Whole-entry writers must round-trip unknown keys; keep the type closed to catch misspellings. +export const keepUnknownOverrideKeys = >( + entry: Entry, +): Schema.Codec< + Entry["Type"], + Entry["Encoded"], + Entry["DecodingServices"], + Entry["EncodingServices"] +> => Schema.StructWithRest(entry, [Schema.Record(Schema.String, Schema.Unknown)]); + /** True if null stores an override; otherwise null removes it. */ export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); @@ -1208,9 +1288,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), - projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - ), + projectSettingsOverrides: Schema.Record( + ProjectId, + keepUnknownOverrideKeys(ProjectSettingsOverrides), + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), /** Fold once so restarts cannot undo later UI resets. */ projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1328,6 +1409,9 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), cursorKeychainUsageEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1569,10 +1653,12 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record( ProjectId, Schema.NullOr( - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + }), + ), ), ), ), @@ -1642,6 +1728,9 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + secretProviders: Schema.optionalKey( + Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), + ), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( @@ -1661,7 +1750,12 @@ export function requiredScopesForServerSettingsPatch( let changesSettings = false; for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue; - if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { + if ( + key === "providers" || + key === "providerInstances" || + key === "usageLimitSources" || + key === "secretProviders" + ) { changesProviders = true; } else { changesSettings = true; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 3262c16d9ec68..106ad1204e01b 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -224,6 +224,7 @@ export interface AcpAdapterV2Flavor { readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; }) => Effect.Effect, EffectAcpErrors.AcpError>; readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + readonly requiresSignIn?: (cause: unknown) => boolean; /** * Runs before attachment paths, T3 instructions and native slash-command detection, * so a rewrite can turn the user's text into a native command. @@ -8537,6 +8538,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( new ProviderAdapter.ProviderAdapterOpenSessionError({ driver, providerSessionId: input.providerSessionId, + ...(flavor.requiresSignIn?.(cause) === true ? { requiresSignIn: true } : {}), cause, }), ), diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts index f582f47984ffb..fdf3c68f97e4d 100644 --- a/packages/provider-acp/src/server/stderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -50,4 +50,17 @@ describe("AcpStderr", () => { expect(excerpt).not.toContain("dXNlcjpwYXNz"); expect(excerpt).not.toContain("ant-api-key-value"); }); + + it("redacts the whole key when its body contains underscores", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "anthropic=sk-ant-api03-AbCdEf_GhIjKlMnOpQr-StUvWx_yz0123456789-AA", + "openai=sk-proj-AbCdEfGhIj_KlMnOpQrStUv-WxYz_0123456789", + ].join("\n"), + "/Users/ada", + {}, + ); + + expect(excerpt).toBe("anthropic=[redacted]\nopenai=[redacted]"); + }); }); diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index f988f2d632d97..92ae01a4c27ef 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -6,7 +6,7 @@ const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; const SECRET_TOKEN_PATTERN = - /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; export function appendAcpStderrTail(current: string, chunk: string): string { const next = `${current}${chunk}`; diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9dc7ec1f62285..e168512736c7c 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -183,6 +183,7 @@ export class ProviderAdapterOpenSessionError extends Schema.TaggedError { assert.notInclude(failure.message, "sk-abcdefghijklmnop"); }); +it("redacts credentials whose key names end in a credential word", () => { + const failure = makeProviderFailure({ + code: '{"refresh_token":"code value"}', + message: + 'oauth refresh failed: client_secret=client-value refresh_token=refresh-value {"client_secret":"json-value"} clientSecret: camel-value X-Auth-Token: header-value max_tokens=4096', + }); + + assert.equal(failure.code, '{"refresh_token":"[REDACTED]"}'); + assert.equal( + failure.message, + 'oauth refresh failed: client_secret=[REDACTED] refresh_token=[REDACTED] {"client_secret":"[REDACTED]"} clientSecret: [REDACTED] X-Auth-Token: [REDACTED] max_tokens=4096', + ); +}); + it("does not split a surrogate pair at the truncation boundary", () => { const failure = makeProviderFailure({ message: `${"a".repeat(MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 2)}🚀tail`, diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index b0ac1ed8b938d..2dd418ea2dfac 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -194,11 +194,11 @@ function redactProviderFailureText(value: string): string { .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl) .replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]") .replace( - /(["'](?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, + /(["'][\w-]*(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, "$1[REDACTED]$2", ) .replace( - /(\b(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + /((? boolean; /** The updater cannot detect installs outside `isCommandPath`, so leave those manual-only. */ readonly requiresCommandPath?: boolean; - readonly env?: NodeJS.ProcessEnv; + readonly env?: + | NodeJS.ProcessEnv + | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv); } | null; } @@ -446,6 +448,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( } const nativeUpdate = definition.nativeUpdate; + const nativeEnv = nativeUpdate?.env; const native = nativeUpdate ? makeProviderMaintenanceCapabilities({ provider: definition.provider, @@ -454,7 +457,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateArgs: nativeUpdate.args, updateLockKey: `${definition.provider}-native`, platform: context.platform, - ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), + ...(nativeEnv + ? { env: typeof nativeEnv === "function" ? nativeEnv(context) : nativeEnv } + : {}), }) : manual; // A native-looking launcher can still delegate to a mise-owned install. diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index f83a0b376233b..be8c4bf98aa54 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -82,6 +82,8 @@ export function makeSubagentChildThread(input: { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, // A guest link belongs to the thread it was made for. guestShare: null, lastVisitedAt: null, diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index e4fdfe3c2dbac..d4006e3bf4ebd 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -54,10 +54,41 @@ describe("parseCliArgs", () => { }); }); - it("ignores bare -- with no flag name", () => { + it("accepts a trailing -- without operands", () => { expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); }); + it.each([ + { args: "--effort low -- --effort high --chrome" }, + { args: ["--effort", "low", "--", "--effort", "high", "--chrome"] }, + ])("ends option parsing at -- for $args", ({ args }) => { + expect(parseCliArgs(args)).toEqual({ + flags: { effort: "low" }, + positionals: ["--effort", "high", "--chrome"], + }); + }); + + it("preserves all operands after --, including empty strings and another --", () => { + expect(parseCliArgs(["--", "", "--", "--model=example", "-x"])).toEqual({ + flags: {}, + positionals: ["", "--", "--model=example", "-x"], + }); + }); + + it("ends option parsing with explicit boolean flags too", () => { + expect(parseCliArgs(["--chrome", "--", "--verbose"], { booleanFlags: ["chrome"] })).toEqual({ + flags: { chrome: null }, + positionals: ["--verbose"], + }); + }); + + it("keeps -- inside an equals-form option value", () => { + expect(parseCliArgs("--append-system-prompt=-- --chrome")).toEqual({ + flags: { "append-system-prompt": "--", chrome: null }, + positionals: [], + }); + }); + it("boolean flag does not consume next token as value", () => { expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( { diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index e6e348797a907..253b7191ed83d 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -77,9 +77,13 @@ export function parseCliArgs( for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; + if (token === "--") { + positionals.push(...tokens.slice(i + 1)); + break; + } + if (token.startsWith("--")) { const rest = token.slice(2); - if (!rest) continue; const eqIndex = rest.indexOf("="); if (eqIndex !== -1) { diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 572441ffe5366..12af0f39336b2 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -232,6 +232,7 @@ export const HTML_RENDER_THEME_GUIDE = [ export const HTML_RENDER_LAYOUT_GUIDE = [ `Frame width is reply width (${HTML_RENDER_COLUMN_WIDTH}px desktop, about 360px phones), on the thread background.`, "Leave html, body, and the outer element with no background color, no outer card, border, banner title, or side padding. This wins over any rule that sets a page background.", + "T3 adds an overridable 8px html gutter for selection rings and shadows. Use fluid widths within it.", "A box that needs its own background gets 16px padding and var(--radius) corners. Give charts fixed pixel heights.", "Let content set the height. Do not use vh units or height:100% on html or body. Both are rejected or loop.", ].join(" "); @@ -353,7 +354,7 @@ export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { } const BASE_CSS = - "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + + "html{box-sizing:border-box;padding:8px;background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" + // Focus outlines paint inside the element, so the frame edge never clips them. ":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}" + diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts index cb920c0737972..0bc6941e494c0 100644 --- a/packages/shared/src/markdownMath.test.ts +++ b/packages/shared/src/markdownMath.test.ts @@ -124,8 +124,18 @@ describe("chat math parsing", () => { "It costs $5 and $10.", "Budget $5-$10 per seat, or $20,000-$30,000 a year.", "Plans are $5/$10 monthly.", + "Pay $5,$10 now.", "Run $browser then $deploy on it.", "Set $HOME before running.", + "Set $HOME,$PATH before running.", + "Set $HOME, $PATH before running.", + "Set $HOME,$_PATH before running.", + "Set $home,$path before running.", + "Set $HOME,${PATH} before running.", + "Set $HOME,$? before running.", + "Set $HOME,$@ before running.", + "Set $HOME,$(pwd) before running.", + "Pay $1,000,$2,000 now.", ]) { expect(formulas(prose), prose).toEqual([]); expect(text(prose), prose).toBe(prose); @@ -143,6 +153,17 @@ describe("chat math parsing", () => { } }); + it("reads formulas that end in a comma", () => { + for (const boundary of ["", " ", "\n", ".", ",", ";", ":", ")", "]", "}"]) { + expect(formulas(`$x,$${boundary}`)).toEqual([["inline", "x,"]]); + } + expect(formulas("Where $x = 1,$ with $$a,$$ and $$ b,$$.")).toEqual([ + ["inline", "x = 1,"], + ["display", "a,"], + ["display", " b,"], + ]); + }); + it("leaves code, escapes, links, and citations alone", () => { for (const [markdown, rendered] of [ ["Use `$x$` in code.", "Use in code."], diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index 99eb8b37a215b..ac786993fa0bf 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -238,6 +238,27 @@ it.effect("lets the writer commit while a read-only transaction is open", () => ), ); +it.effect("serves another read while a read-only transaction is open", () => + withReaderClient((sql) => + Effect.gen(function* () { + const opened = yield* Deferred.make(); + const other = yield* Effect.forkChild( + Deferred.await(opened).pipe(Effect.andThen(countEntries(sql).pipe(SqliteClient.readOnly))), + ); + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* countEntries(sql); + yield* Deferred.succeed(opened, undefined); + // One shared reader would make this join wait on the transaction forever. + assert.equal(yield* Fiber.join(other), 1); + }), + ) + .pipe(SqliteClient.readOnly); + }), + ), +); + // regression: #19226 read-only transactions run on the separate reader connection it.effect("rejects a write in a read-only transaction", () => withReaderClient((sql) => @@ -333,6 +354,25 @@ it.effect("uses the writer when the reader cannot open the database", () => assert.equal(yield* sql.withTransaction(countEntries(sql)).pipe(SqliteClient.readOnly), 1); assert.equal(yield* sql.withTransaction(countEntries(sql)).pipe(SqliteClient.readOnly), 1); + + // A read beside an open read transaction must not start a worker on the next slot. + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const holder = yield* sql + .withTransaction( + countEntries(sql).pipe( + Effect.andThen(Deferred.succeed(held, undefined)), + Effect.andThen(Deferred.await(release)), + ), + ) + .pipe(SqliteClient.readOnly, Effect.forkChild); + yield* Deferred.await(held); + const other = yield* countEntries(sql).pipe(SqliteClient.readOnly, (effect) => + Effect.forkChild(effect, { startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(holder); + assert.equal(yield* Fiber.join(other), 1); assert.equal(startedWorkers.length, startedBefore + 1); }), ), diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index a42943c065ac0..c0edfdb868d1f 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -293,8 +293,14 @@ const failed = (cause: unknown, message: string, operation: string) => // Read-transaction cleanup succeeds after worker exit because it can no longer change anything. const endsReadTransaction = (sql: string) => /^\s*ROLLBACK\b|^\s*COMMIT\b/i.test(sql); +// WAL readers run concurrently; one worker serialized every read behind the slowest transaction. +const READER_POOL_SIZE = 4; + // Bind transactions to one worker; fail after its exit instead of resuming outside the transaction on a replacement. -const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { +const makeReader = Effect.fnUntraced(function* ( + options: SqliteClientConfig, + pool: { unavailable: boolean }, +) { let current: | { readonly worker: NodeWorkerThreads.Worker; @@ -302,7 +308,6 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { readonly started: Promise; } | undefined; - let unavailable = false; let nextRequestId = 0; const start = () => { @@ -439,8 +444,8 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { void reader.worker.terminate(); } // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - if (unavailable) return Effect.succeed(undefined); - unavailable = true; + if (pool.unavailable) return Effect.succeed(undefined); + pool.unavailable = true; return Effect.logWarning("SQLite reader worker unavailable; reads use the main connection", { reason, }).pipe(Effect.as(undefined)); @@ -448,7 +453,7 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { return Effect.suspend((): Effect.Effect => { // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. - if (unavailable) return Effect.succeed(undefined); + if (pool.unavailable) return Effect.succeed(undefined); let reader: NonNullable; try { reader = current ?? start(); @@ -467,7 +472,7 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { }), (startup): Effect.Effect => // A late ready event must not hand out a worker retired by another acquire timeout. - unavailable || current !== reader + pool.unavailable || current !== reader ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - The connection result distinguishes undefined from void. Effect.succeed(undefined) : Option.isSome(startup) && startup.value.type === "ready" @@ -1073,21 +1078,48 @@ const make = Effect.fn("makeWithDatabase")(function* ( const semaphore = yield* Semaphore.make(1); const connection = yield* makeConnection; - const acquireReader = - options.readerWorker && isFileDatabase ? yield* makeReader(options) : undefined; - const readerSemaphore = yield* Semaphore.make(1); + // A reader that cannot start turns off the whole pool, so routing never picks a dead slot. + const readerPool = { unavailable: false }; + const readers = + options.readerWorker && isFileDatabase + ? yield* Effect.forEach(Array.from({ length: READER_POOL_SIZE }), () => + Effect.map( + Effect.all([makeReader(options, readerPool), Semaphore.make(1)]), + ([acquire, lock]) => ({ + acquire, + lock, + claims: 0, + }), + ), + ) + : []; const writer = { name: "writer" as const, lock: semaphore, connection }; let activeWriter: ConnectionOwner | undefined; - const route = Effect.withFiber((fiber) => - acquireReader !== undefined && Context.get(fiber.context, ReadOnly) - ? Effect.map(acquireReader, (readerConnection) => + // Callers must run route uninterruptibly and pass `claimed` a finalizer that runs `release` once. + const route = (claimed: (release: Effect.Effect) => Effect.Effect) => + Effect.withFiber((fiber) => { + if (readers.length === 0 || readerPool.unavailable || !Context.get(fiber.context, ReadOnly)) { + return Effect.succeed(writer); + } + // Least claimed wins and ties go to the lowest slot, so idle slots never start a worker. + let slot = readers[0]!; + for (const candidate of readers) if (candidate.claims < slot.claims) slot = candidate; + slot.claims++; + const reader = slot; + return Effect.andThen( + claimed( + Effect.sync(() => { + reader.claims--; + }), + ), + Effect.map(reader.acquire, (readerConnection) => readerConnection === undefined ? writer - : { name: "reader" as const, lock: readerSemaphore, connection: readerConnection }, - ) - : Effect.succeed(writer), - ); + : { name: "reader" as const, lock: reader.lock, connection: readerConnection }, + ), + ); + }); const acquired = ( target: { readonly name: Acquired["connection"] }, @@ -1101,88 +1133,110 @@ const make = Effect.fn("makeWithDatabase")(function* ( const writerBorrowBatchSize = 16; let writerBorrowsSinceYield = 0; const borrower: Borrower = (use) => - Effect.flatMap(route, (target) => - Effect.withFiber((fiber) => { - const clock = fiber.getRef(Clock.Clock); - const start = clock.monotonicTimeNanosUnsafe(); - const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; - const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; - const useConnection = Effect.suspend(() => { - if (target.name === "writer") activeWriter = owner; - if ( - fiber.getRef(References.TracerEnabled) && - fiber.getRef(Statement.SpanPropagationEnabled) - ) { - annotateAcquired(fiber, acquired(target, clock, start), ownerAtWaitStart); - } - return use(target.connection); - }); - const useOwnedConnection = - target.name === "writer" - ? Effect.onExit(useConnection, () => - Effect.sync(() => { - if (activeWriter === owner) activeWriter = undefined; - }), - ) - : useConnection; - if (!options.checkpointWorker || target.name !== "writer") { - return target.lock.withPermits(1)(useOwnedConnection); - } - const writerUse = Effect.onExit(useOwnedConnection, (exit) => - Effect.sync(() => { - writerBorrowsSinceYield = Exit.hasInterrupts(exit) - ? 0 - : Math.min(writerBorrowsSinceYield + 1, writerBorrowBatchSize); - }), - ); - return Effect.flatMap(target.lock.withPermitsIfAvailable(1)(writerUse), (result) => - Option.isSome(result) - ? Effect.succeed(result.value) - : target.lock.withPermits(1)( - Effect.andThen( - // Failed SQL also consumes the batch; cancellation hands off immediately. - Effect.suspend(() => { - if (writerBorrowsSinceYield < writerBorrowBatchSize) return Effect.void; - writerBorrowsSinceYield = 0; - return Effect.callback((resume) => { - const immediate = setImmediate(() => resume(Effect.void)); - return Effect.sync(() => clearImmediate(immediate)); - }); - }), - writerUse, - ), - ), - ); - }), - ); - const transactionAcquirer = Effect.flatMap(route, (target) => Effect.uninterruptibleMask((restore) => { - const fiber = Fiber.getCurrent()!; - const scope = Context.getUnsafe(fiber.context, Scope.Scope); + let release: Effect.Effect = Effect.void; + const claimed = (next: Effect.Effect) => + Effect.sync(() => { + release = next; + }); + return Effect.flatMap(route(claimed), (target) => restore(borrowTarget(target, use))).pipe( + Effect.ensuring(Effect.suspend(() => release)), + ); + }); + const borrowTarget = ( + target: { + readonly name: Acquired["connection"]; + readonly lock: Semaphore.Semaphore; + readonly connection: Connection; + }, + use: (connection: Connection) => Effect.Effect, + ): Effect.Effect => + Effect.withFiber((fiber) => { const clock = fiber.getRef(Clock.Clock); const start = clock.monotonicTimeNanosUnsafe(); - const pending = fiber.getRef(TransactionAcquire); const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; - const acquire = Effect.tap((pending?.restore ?? restore)(target.lock.take(1)), () => { + const useConnection = Effect.suspend(() => { if (target.name === "writer") activeWriter = owner; - if (pending !== undefined) { - pending.acquired = acquired(target, clock, start); - pending.ownerAtWaitStart = ownerAtWaitStart; + if ( + fiber.getRef(References.TracerEnabled) && + fiber.getRef(Statement.SpanPropagationEnabled) + ) { + annotateAcquired(fiber, acquired(target, clock, start), ownerAtWaitStart); } - return Scope.addFinalizer( - scope, - Effect.sync(() => { - if (target.name === "writer" && activeWriter === owner) activeWriter = undefined; - }).pipe(Effect.andThen(target.lock.release(1))), - ); + return use(target.connection); }); - return Effect.as( - // A scheduler yield after taking the permit must not precede its release finalizer. - Effect.provideService(acquire, Scheduler.PreventSchedulerYield, true), - target.connection, + const useOwnedConnection = + target.name === "writer" + ? Effect.onExit(useConnection, () => + Effect.sync(() => { + if (activeWriter === owner) activeWriter = undefined; + }), + ) + : useConnection; + if (!options.checkpointWorker || target.name !== "writer") { + return target.lock.withPermits(1)(useOwnedConnection); + } + const writerUse = Effect.onExit(useOwnedConnection, (exit) => + Effect.sync(() => { + writerBorrowsSinceYield = Exit.hasInterrupts(exit) + ? 0 + : Math.min(writerBorrowsSinceYield + 1, writerBorrowBatchSize); + }), ); - }), + return Effect.flatMap(target.lock.withPermitsIfAvailable(1)(writerUse), (result) => + Option.isSome(result) + ? Effect.succeed(result.value) + : target.lock.withPermits(1)( + Effect.andThen( + // Failed SQL also consumes the batch; cancellation hands off immediately. + Effect.suspend(() => { + if (writerBorrowsSinceYield < writerBorrowBatchSize) return Effect.void; + writerBorrowsSinceYield = 0; + return Effect.callback((resume) => { + const immediate = setImmediate(() => resume(Effect.void)); + return Effect.sync(() => clearImmediate(immediate)); + }); + }), + writerUse, + ), + ), + ); + }); + const claimedUntilScopeCloses = (release: Effect.Effect) => + Effect.withFiber((fiber) => + Scope.addFinalizer(Context.getUnsafe(fiber.context, Scope.Scope), release), + ); + const transactionAcquirer = Effect.uninterruptible(route(claimedUntilScopeCloses)).pipe( + Effect.flatMap((target) => + Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()!; + const scope = Context.getUnsafe(fiber.context, Scope.Scope); + const clock = fiber.getRef(Clock.Clock); + const start = clock.monotonicTimeNanosUnsafe(); + const pending = fiber.getRef(TransactionAcquire); + const ownerAtWaitStart = target.name === "writer" ? activeWriter : undefined; + const owner = target.name === "writer" ? connectionOwner(fiber) : undefined; + const acquire = Effect.tap((pending?.restore ?? restore)(target.lock.take(1)), () => { + if (target.name === "writer") activeWriter = owner; + if (pending !== undefined) { + pending.acquired = acquired(target, clock, start); + pending.ownerAtWaitStart = ownerAtWaitStart; + } + return Scope.addFinalizer( + scope, + Effect.sync(() => { + if (target.name === "writer" && activeWriter === owner) activeWriter = undefined; + }).pipe(Effect.andThen(target.lock.release(1))), + ); + }); + return Effect.as( + // A scheduler yield after taking the permit must not precede its release finalizer. + Effect.provideService(acquire, Scheduler.PreventSchedulerYield, true), + target.connection, + ); + }), + ), ); const client = yield* Client.make({ diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f9a43b6123a1e..5a02f302826de 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,12 +1,18 @@ import { DEFAULT_SERVER_SETTINGS, + OnePasswordAccount, ProjectId, ProviderDriverKind, ProviderInstanceId, + SecretProviderId, + SecretProviderKind, + ServerSettings, + ServerSettingsPatch, UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; @@ -21,6 +27,9 @@ import { resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; +const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); +const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); + const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { @@ -496,6 +505,37 @@ describe("serverSettings helpers", () => { }); }); + // regression: #18334 a newer client must still see this server drop a key it does not know + it("drops an override key this server does not know from a patch and keeps one it stored", () => { + const projectId = ProjectId.make("project-a"); + const current = decodeServerSettings({ + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: 3 }, + }, + }); + const patch = decodeServerSettingsPatch({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 4, addedByNewerClient: "x" }, + }, + }); + + const updated = applyServerSettingsPatch(current, patch); + expect(updated.projectSettingsOverrides[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: 3, + }); + const reset = applyServerSettingsPatch(updated, { + projectSettingsOverrides: { [projectId]: {} }, + }); + expect(reset.projectSettingsOverrides[projectId]).toEqual({ addedByNewerServer: 3 }); + expect( + applyServerSettingsPatch(reset, { + projectSettingsOverrides: { [projectId]: null }, + }).projectSettingsOverrides[projectId], + ).toBeUndefined(); + }); + it("clears a project's providerInstances override when a patch entry sends an empty map", () => { const instanceId = ProviderInstanceId.make("codex_work"); const projectId = ProjectId.make("project-a"); @@ -544,6 +584,28 @@ describe("serverSettings helpers", () => { expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); }); + it("upserts and removes secretProviders per entry so concurrent edits cannot clobber", () => { + const personal = SecretProviderId.make("personal"); + const work = SecretProviderId.make("work"); + const onePassword = (label: string, account: string) => ({ + kind: SecretProviderKind.make("one_password"), + label, + config: { account: OnePasswordAccount.make(account) }, + }); + const current = { + ...DEFAULT_SERVER_SETTINGS, + secretProviders: { [personal]: onePassword("Personal", "my.1password.com") }, + }; + + const added = applyServerSettingsPatch(current, { + secretProviders: { [work]: onePassword("Work", "team.1password.com") }, + }); + expect(Object.keys(added.secretProviders)).toEqual([personal, work]); + + const removed = applyServerSettingsPatch(added, { secretProviders: { [personal]: null } }); + expect(Object.keys(removed.secretProviders)).toEqual([work]); + }); + it("replaces and removes individual usage prices without clobbering other models", () => { const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e7c856070658a..e936c9f83acba 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -14,7 +14,7 @@ import { type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, - type ProjectSettingsOverrides, + ProjectSettingsOverrides, ProviderDriverKind, ProviderInstanceId, type ServerProvider, @@ -226,6 +226,16 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); + +function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { + return Object.fromEntries( + Object.entries(entry ?? {}).filter( + ([key]) => knownProjectOverrideKeys.has(key) === known, + ), + ); +} + // Older clients omit providerInstances during whole-entry replacement; preserve it to avoid wiping overrides. function mergeProjectSettingsOverridesPatch( current: Readonly>, @@ -239,7 +249,9 @@ function mergeProjectSettingsOverridesPatch( } const storedProviderInstances = current[projectId]?.providerInstances; next.set(projectId, { - ...entry, + // Preserve downgraded storage, but reject new unknown writes so clients can detect unsupported settings. + ...pickOverrideKeys(current[projectId], false), + ...pickOverrideKeys(entry, true), ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined ? {} : { providerInstances: storedProviderInstances }), @@ -357,6 +369,7 @@ export function applyServerSettingsPatch( worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, + secretProviders: secretProvidersPatch, sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, @@ -554,6 +567,9 @@ export function applyServerSettingsPatch( ), } : {}), + ...(secretProvidersPatch !== undefined + ? { secretProviders: mergeSettingsEntries(current.secretProviders, secretProvidersPatch) } + : {}), ...(usagePriceOverridesPatch !== undefined ? { usagePriceOverrides: mergeSettingsEntries( diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index 98cab3b980472..cceb2dcc44a0b 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -75,6 +75,8 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { }; const DEFAULT_PROBE_TIMEOUT_MS = 5_000; +/** An API host that stalls must not hold the whole discovery answer behind it. */ +const API_PROBE_TIMEOUT = "3 seconds"; function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; @@ -238,6 +240,12 @@ export function probeSourceControlProvider(input: { if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); if (input.spec.type === "api") { return input.spec.probeAuth.pipe( + Effect.timeoutOption(API_PROBE_TIMEOUT), + Effect.map( + Option.getOrElse(() => + unknownAuth("The hosting service did not answer in time. Refresh to try again."), + ), + ), Effect.map( (auth) => ({ @@ -255,20 +263,15 @@ export function probeSourceControlProvider(input: { const spec = input.spec; - return probeCli({ - spec, - process: input.process, - cwd: input.cwd, - }).pipe( - Effect.flatMap((item) => { - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return input.process + // The auth check does not depend on the version output, so both spawn at once. + return Effect.all( + [ + probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }), + input.process .run({ operation: "source-control.discovery.auth", command: spec.executable, @@ -280,21 +283,24 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), + Effect.map(spec.parseAuth), Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), + Effect.succeed(unknownAuth(Option.getOrUndefined(detailFromCause(cause)))), ), - ); - }), + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map( + ([item, auth]) => + ({ + ...item, + auth: + item.status === "available" + ? auth + : unknownAuth("Hosting integration command was not found on the server PATH."), + }) satisfies SourceControlProviderDiscoveryItem, + ), ); } diff --git a/packages/source-control-gitlab/src/server/GitLabMediaFetch.ts b/packages/source-control-gitlab/src/server/GitLabMediaFetch.ts index 9fabc0aacb81e..f6e2d4760ec2f 100644 --- a/packages/source-control-gitlab/src/server/GitLabMediaFetch.ts +++ b/packages/source-control-gitlab/src/server/GitLabMediaFetch.ts @@ -6,7 +6,7 @@ import { HttpClientRequest, HttpClientResponse } from "effect/http"; import { gitLabMediaApiEndpoint } from "../shared/gitLabMedia.ts"; /** `glab api` hands over a whole upload at once, so this bounds the workspace's memory per file. */ -export const GITLAB_MEDIA_MAX_BYTES = 64 * 1024 * 1024; +const GITLAB_MEDIA_MAX_BYTES = 64 * 1024 * 1024; const GITLAB_MEDIA_TIMEOUT_MS = 60_000; // An MR with many screenshots would otherwise start a `glab` for each at once. const GITLAB_MEDIA_CONCURRENCY = { key: "GitLabMediaFetch", limit: 4 } as const; diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index a05ee047384db..7f9e94fa90887 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -31,6 +31,28 @@ describe("GitLab change request URLs", () => { expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); }); + it("recognizes only merge request URLs it can parse", () => { + for (const url of [ + "https://gitlab.com/g/p?x=/-/merge_requests/5", + "https://gitlab.com/g/p#/-/merge_requests/5", + "https://gitlab.com?x=/g/p/-/merge_requests/5", + "https://gitlab.com#/g/p/-/merge_requests/5", + "https://gitlab.com/t3code/-/merge_requests/12", + "https://gitlab.com/g//-/merge_requests/5", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(false); + expect(parse(url), url).toBeNull(); + } + for (const url of [ + "https://code.acme.test/a/b/c/-/merge_requests/7?tab=diffs", + "http://git.internal/a/b/c/-/merge_requests/7#note_1", + "https://code.acme.test/a/b/c/-/merge_requests/7/diffs", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(true); + expect(parse(url), url).toMatchObject({ repository: "a/b/c", number: 7 }); + } + }); + it("builds merge request URLs below the repository", () => { expect( definition.changeRequestUrl({ diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 59483b5d24322..cc8ae144e8a4d 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -12,9 +12,8 @@ import { icon } from "./icon.ts"; import { gitLabReferenceAutolinks } from "./referenceAutolinks.ts"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; -// Coder: GitLab is the only hosted provider, so a merge request URL on any host is accepted; -// self-hosted GitLab hostnames need not contain "gitlab". -const CHANGE_REQUEST_REFERENCE = /^https?:\/\/[^/\s]+\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; +const CHANGE_REQUEST_REFERENCE = + /^https?:\/\/[^/?#\s]+\/[^/?#\s]+(?:\/[^/?#\s]+)+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; diff --git a/patches/micromark-extension-llm-math@3.1.0.patch b/patches/micromark-extension-llm-math@3.1.0.patch index 335ce4c11d57e..25da4f5f47755 100644 --- a/patches/micromark-extension-llm-math@3.1.0.patch +++ b/patches/micromark-extension-llm-math@3.1.0.patch @@ -235,6 +235,9 @@ diff --git a/dev/lib/math-tex-text.js b/dev/lib/math-tex-text.js diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js --- a/dev/lib/math-text.js +++ b/dev/lib/math-text.js +@@ -10 +10 @@ +-import {markdownLineEnding} from 'micromark-util-character' ++import {markdownLineEnding, markdownLineEndingOrSpace} from 'micromark-util-character' @@ -42,6 +42,13 @@ let size /** @type {Token} */ @@ -299,7 +302,7 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js if ( code === codes.eof || code === codes.space || -@@ -179,9 +207,26 @@ +@@ -179,9 +207,29 @@ // Done! if (size === sizeOpen) { @@ -314,11 +317,14 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js + if (code !== null && code >= codes.digit0 && code <= codes.digit9) { + return nok(code) + } ++ if (charBeforeClose === codes.comma && !openWhitespace && code !== codes.eof && !markdownLineEndingOrSpace(code) && !/[.,;:)\]}]/.test(String.fromCharCode(code))) { ++ return nok(code) ++ } + // Check if closing sequence has matching whitespace pattern + const closeWhitespace = + charBeforeClose === codes.space || + charBeforeClose === codes.horizontalTab || -+ charBeforeClose === codes.comma ++ (charBeforeClose === codes.comma && openWhitespace) + if (openWhitespace === closeWhitespace) { + effects.exit('mathTextSequence') + effects.exit('mathText') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ce6cc4291f0c..14a97f5378a6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,7 +253,7 @@ patchedDependencies: '@pierre/diffs@1.6.1': cbfbe7f36010a8973f57955ed43a7ee2895db5c15e5b336dbf9e850d342d6d26 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 - micromark-extension-llm-math@3.1.0: 49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9 + micromark-extension-llm-math@3.1.0: 7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 importers: @@ -7890,7 +7890,7 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9): + micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a): dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 @@ -8509,7 +8509,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 mdast-util-math: 3.0.0(patch_hash=afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c) - micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9) + micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a) unified: 11.0.5 transitivePeerDependencies: - supports-color diff --git a/scripts/sync-resolve-deleted.mjs b/scripts/sync-resolve-deleted.mjs index 8505f2a97ebf6..f13b805e5adad 100644 --- a/scripts/sync-resolve-deleted.mjs +++ b/scripts/sync-resolve-deleted.mjs @@ -14,6 +14,7 @@ const DELETED_PATHS = { // The server's auth/ keeps only ServerSecretStore and utils, which a modify/delete cannot name. "auth and pairing": [ "apps/server/src/auth/", + "apps/web/src/components/settings/SessionPermissions.tsx", "apps/server/src/persistence/AuthPairingLinks.ts", "apps/server/src/persistence/AuthSessions.ts", "apps/server/src/startupAccess.ts", @@ -155,7 +156,29 @@ const DELETED_PATHS = { ], // Server CLI: the helper starts over `coder ssh`, never as upstream's `t3` command, and // CoderEnvironment replaces upstream's ServerEnvironment. - "server CLI": ["apps/server/src/cli/", "apps/server/src/environment/ServerEnvironment.ts"], + // Upstream's runtime startup and state tests build its own server; the helper's startup is + // coderServer.ts. + "server CLI": [ + "apps/server/src/cli/", + "apps/server/src/environment/ServerEnvironment.ts", + "apps/server/src/serverRuntimeStartup.autoPull.test.ts", + "apps/server/src/serverRuntimeStartup.test.ts", + "apps/server/src/serverRuntimeState.test.ts", + ], + // Local editors and provider setup: the helper cannot open an editor on the laptop, and + // providers are configured in the workspace without in-app sign-in or setup. + "local editors and provider setup": [ + "apps/server/src/process/externalLauncher.ts", + "apps/server/src/process/externalLauncher.test.ts", + "apps/web/src/components/settings/CodexSetupSection.tsx", + "packages/shared/src/editor.ts", + ], + // Browser tests for cut surfaces: the provider environment panel test drives managed Codex + // accounts and ACP sign-in, and the theme import test the remote catalog and desktop picker. + "browser tests for cut surfaces": [ + "apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx", + "apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx", + ], // Relay: no relay, Tailscale, or SSH transport. relay: [ "apps/server/src/relay/", @@ -164,6 +187,7 @@ const DELETED_PATHS = { "docs/user/remote-access.md", "infra/", "packages/client-runtime/src/relay/", + "packages/client-runtime/src/connection/supervisor.test.ts", "packages/ssh/", "packages/tailscale/", ], @@ -171,11 +195,18 @@ const DELETED_PATHS = { // or trace export. telemetry: [ "apps/server/src/diagnostics/", + "apps/server/src/observability/HeapAttribution.ts", + "apps/server/src/observability/HeapAttribution.test.ts", + "apps/server/src/observability/Observability.ts", "apps/server/src/observability/RpcInstrumentation.ts", "apps/server/src/resourceTelemetry/", "apps/server/src/telemetry/", "apps/server/src/usage/", "apps/web/src/components/usage/", + "apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts", + "apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts", + "apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx", + "apps/web/src/components/settings/UsageProviderSettings.tsx", "apps/web/src/components/chat/ComposerUsageLimits.tsx", "apps/web/src/observability/", "apps/web/src/state/usage.ts", @@ -189,6 +220,7 @@ const DELETED_PATHS = { // archives, or release notes. "updates and releases": [ "apps/server/src/persistence/databaseMigrationHead.ts", + "apps/server/src/serverOwnership.ts", "apps/web/src/components/ServerUpdateAction.tsx", "apps/web/src/components/sidebar/SidebarUpdatePill.tsx", "apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx", @@ -201,6 +233,7 @@ const DELETED_PATHS = { "scripts/build-cli-archive.ts", "scripts/build-npm-launcher.ts", "scripts/generate-release-notes.ts", + "scripts/npm-launcher/", "scripts/licenses/libatomic.LICENSE", "scripts/notify-discord-release.ts", "scripts/smoke-cli-archive.ts", @@ -236,6 +269,18 @@ const DELETED_PATHS = { "apps/web/src/lib/triageTarget.test.ts", "docs/user/triage.md", ], + // Secret providers: no 1Password or other secret provider settings, dialogs, or packages. + // Contracts keep upstream's settings fields. + "secret providers": [ + "apps/web/src/components/settings/SecretProviderDialog.logic.test.ts", + "apps/web/src/components/settings/SecretProviderDialog.logic.ts", + "apps/web/src/components/settings/SecretProviderDialog.tsx", + "apps/web/src/components/settings/SecretsSettings.tsx", + "apps/web/src/routes/_chat.settings.secrets.tsx", + "packages/secret-provider-core/", + "packages/secret-provider-definitions/", + "packages/secret-provider-onepassword/", + ], // Upstream repository tooling: vendored sources, CI, hooks, editor and agent configuration, // and operations docs. The fork's .github/ keeps only SECURITY.md. "upstream repository tooling": [ @@ -258,6 +303,8 @@ const DELETED_PATHS = { "docs/internals/open-source-licenses.md", "docs/user/faq.md", "scripts/check-changed.ts", + "scripts/dev-runner.ts", + "scripts/dev-runner.test.ts", // Upstream's ChatMarkdown browser test, which the fork has not carried since its first commit. "apps/web/src/components/ChatMarkdown.browser.test.tsx", // CodeRabbit's configuration and upstream's MCP tool-use evals.