From c60b071c65d1b3359795f951e8a96facfb475546 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 18:46:33 -0700 Subject: [PATCH 1/7] feat(settings): add per-project overrides for scopable server settings Project overrides were scattered across the Project aggregate, three ad hoc Record fields in ServerSettings, and a client-settings record. Every other server setting was environment-only, so a settings hierarchy could not be built on top of them. Introduce projectSettingsOverrides: one record per project holding a partial of PROJECT_SCOPED_SERVER_SETTING_KEYS, and resolveProjectSettings() which returns the effective settings plus the source of each key. Legacy fields are folded into the record once on load and kept populated as derived views for one release. Server consumers that act on a thread or checkout (agent access gates, auto-settlement, title/branch generation, restart continuation, legacy token streaming, auto-pull, setup scripts, cwd bootstrap) now read through the resolver so an override takes effect everywhere the environment value did. Model: Claude Fable 5.1. Harness: Claude Code. --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/git/GitManager.ts | 31 +++- .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 4 + .../Layers/ProviderCommandReactor.ts | 24 ++- .../Layers/ProviderRuntimeIngestion.ts | 11 +- .../Services/ProjectionSnapshotQuery.ts | 2 +- .../ThreadSettlementReactor.test.ts | 53 ++++++ .../orchestration/ThreadSettlementReactor.ts | 47 +++-- .../provider/Layers/ProviderService.test.ts | 29 ++- .../src/provider/Layers/ProviderService.ts | 47 ++--- apps/server/src/serverRuntimeStartup.test.ts | 70 ++++++-- apps/server/src/serverRuntimeStartup.ts | 26 +-- apps/server/src/serverSettings.test.ts | 74 ++++++++ apps/server/src/serverSettings.ts | 167 +++++++++++++++--- apps/server/src/vcs/VcsStatusBroadcaster.ts | 4 +- .../src/state/sharedSettings.test.ts | 12 ++ packages/contracts/src/environment.ts | 2 + packages/contracts/src/settings.ts | 73 ++++++++ packages/shared/package.json | 4 + packages/shared/src/projectScripts.ts | 38 ++-- packages/shared/src/projectSettings.test.ts | 146 +++++++++++++++ packages/shared/src/projectSettings.ts | 96 ++++++++++ packages/shared/src/serverSettings.test.ts | 18 +- packages/shared/src/serverSettings.ts | 139 ++++++++++++--- 25 files changed, 975 insertions(+), 144 deletions(-) create mode 100644 packages/shared/src/projectSettings.test.ts create mode 100644 packages/shared/src/projectSettings.ts diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2aab17b27a76..7bf081abc91d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, + projectSettingsOverrides: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f60eb2781872..1a14fb5be5bc 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -29,9 +29,16 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + type ProjectId, SourceControlProviderError, type SourceControlWritingStyleSettings, + type ThreadId, } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, @@ -661,6 +668,28 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + // Optional: git actions also run from the CLI and tests without orchestration. + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); + /** Environment settings with the acting project's overrides applied. */ + const projectSettingsFor = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly threadId?: ThreadId | undefined; + }) { + const settings = yield* serverSettingsService.getSettings; + if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; + const projectId = yield* ( + input.threadId !== undefined + ? projectionQuery.value + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.map((thread) => thread.projectId))) + : projectionQuery.value + .getActiveProjectByWorkspaceRoot(input.cwd) + .pipe(Effect.map(Option.map((project) => project.id))) + ).pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; + }); const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); @@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( + const textGenerationSettings = yield* projectSettingsFor(input).pipe( Effect.flatMap((settings) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5849123c55d6..be66f3cf4b43 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (context._tag === "Some") { assert.deepEqual(context.value, { id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), title: "Thread 1", session: snapshot.threads[0]?.session, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 066c60760ca5..efb7bba8f15b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({ const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ id: ThreadId, + projectId: ProjectId, title: Schema.String, session: Schema.NullOr(ProjectionThreadSessionDbRowSchema), }); @@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT threads.thread_id AS id, + threads.project_id AS "projectId", threads.title, sessions.thread_id AS "threadId", sessions.status, @@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.threadId === null ? null : row, })), @@ -3164,6 +3167,7 @@ pending_approval_requests AS ( ); return Option.map(context, (row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.session === null ? null : mapSessionRow(row.session), })); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9b125922137c..c5d120106a19 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -54,6 +54,7 @@ import { resolveSourceControlWriterModelSelection, ServerSettingsService, } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -329,6 +330,16 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + /** Environment settings with the thread's project overrides applied. */ + const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const settings = yield* serverSettingsService.getSettings; + if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null) + .settings; + }); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -996,7 +1007,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const settings = yield* serverSettingsService.getSettings; + const settings = yield* projectSettingsForThread(input.threadId); const modelSelection = settings.sourceControlWriterModelSelection === null ? settings.textGenerationModelSelection @@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread( + input.threadId, + ); const generated = yield* textGeneration .generateThreadTitle({ @@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }) ?? process.cwd(); - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = resolveProjectSettings( + yield* serverSettingsService.getSettings, + thread.projectId, + ).settings; const generated = yield* textGeneration.generateThreadTitle({ cwd, message, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d34fee4f981..964f60d3a306 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -51,6 +51,7 @@ import { import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () { }); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 35d5bacc239c..fda0ac04556e 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadRuntimeContext: ( threadId: ThreadId, ) => Effect.Effect< - Option.Option>, + Option.Option>, ProjectionRepositoryError >; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 0690eea2d50e..79b833e5fc6e 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -486,6 +486,59 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("a project override settles only that project's inactive threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const overriddenProject = ProjectId.make("overridden-project"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inherits-thread"), + makeThread("overridden-thread", { projectId: overriddenProject }), + ], + [makeProject(), makeProject(overriddenProject, "/workspace/overridden")], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + projectSettingsOverrides: { + [overriddenProject]: { sidebarAutoSettleAfterDays: 1 }, + }, + }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Queue.take(fixture.settingsReads); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("overridden-thread")], + ); + + // Clearing the override is a settlement change, so the sweep re-arms. + yield* fixture.updateSettings({ + projectSettingsOverrides: { [overriddenProject]: null }, + sidebarAutoSettleAfterDays: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + // The static snapshot never records the first settlement, so the + // second sweep dispatches for both; the inheriting thread is new. + assert.include( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + ThreadId.make("inherits-thread"), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("starts without clients and skips protected threads before pull request lookup", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 61fc5d4ab863..a84af807d908 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,4 +1,5 @@ -import { CommandId } from "@t3tools/contracts"; +import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -33,6 +34,31 @@ export class ThreadSettlementReactor extends Context.Service< >()("t3/orchestration/ThreadSettlementReactor") {} /** @public Service construction is part of the canonical Effect module API. */ +/** Whether any environment default or project override can settle a thread. */ +function autoSettlementConfigured(settings: ServerSettingsValue): boolean { + if (settings.sidebarAutoSettleOnMerge || settings.sidebarAutoSettleAfterDays !== null) { + return true; + } + return Object.values(settings.projectSettingsOverrides).some( + (entry) => + entry.sidebarAutoSettleOnMerge === true || + (entry.sidebarAutoSettleAfterDays !== undefined && entry.sidebarAutoSettleAfterDays !== null), + ); +} + +/** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */ +function autoSettlementSettingsKey(settings: ServerSettingsValue): string { + return JSON.stringify([ + settings.sidebarAutoSettleOnMerge, + settings.sidebarAutoSettleAfterDays, + Object.entries(settings.projectSettingsOverrides).map(([projectId, entry]) => [ + projectId, + entry.sidebarAutoSettleOnMerge, + entry.sidebarAutoSettleAfterDays, + ]), + ]); +} + export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -46,7 +72,7 @@ export const make = Effect.gen(function* () { mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, ) { const settings = yield* settingsService.getSettings; - if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) { + if (!autoSettlementConfigured(settings)) { return; } const snapshot = yield* snapshots.getShellSnapshot(); @@ -60,7 +86,10 @@ export const make = Effect.gen(function* () { // dispatch skips it for this snapshot instead of retrying through a lookup. const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { - const settings = yield* settingsService.getSettings; + const settings = resolveProjectSettings( + yield* settingsService.getSettings, + thread.projectId, + ).settings; const decisionNow = DateTime.formatIso(yield* DateTime.now); const settledAt = resolveAutoSettlementAt({ thread, @@ -254,8 +283,7 @@ export const make = Effect.gen(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); - let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; - let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( Effect.gen(function* () { yield* worker.enqueue(undefined); @@ -264,14 +292,11 @@ export const make = Effect.gen(function* () { ); yield* forkParked( Stream.runForEach(settingsChanges, (settings) => { - if ( - settings.sidebarAutoSettleAfterDays === lastAfterDays && - settings.sidebarAutoSettleOnMerge === lastOnMerge - ) { + const key = autoSettlementSettingsKey(settings); + if (key === lastSettlementSettings) { return Effect.void; } - lastAfterDays = settings.sidebarAutoSettleAfterDays; - lastOnMerge = settings.sidebarAutoSettleOnMerge; + lastSettlementSettings = key; return worker.enqueue(undefined); }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 9a3fcb8d65f4..4e37c59d4a11 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4802,7 +4802,7 @@ describe("agent browser access", () => { const startSessionWith = ( access: boolean | { readonly browser: boolean; readonly device: boolean }, threadId: ThreadId, - projectOverride?: boolean, + projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean }, ) => Effect.gen(function* () { const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser; @@ -4880,8 +4880,21 @@ describe("agent browser access", () => { ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, enableAgentDeviceAccess, - projectAgentBrowserAccessOverrides: - projectOverride === undefined ? {} : { [projectId]: projectOverride }, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : typeof projectOverride === "boolean" + ? { [projectId]: { enableAgentBrowserAccess: projectOverride } } + : { + [projectId]: { + ...(projectOverride.browser !== undefined + ? { enableAgentBrowserAccess: projectOverride.browser } + : {}), + ...(projectOverride.device !== undefined + ? { enableAgentDeviceAccess: projectOverride.device } + : {}), + }, + }, }), ), Layer.provide(serverConfigTestLayer), @@ -4965,4 +4978,14 @@ describe("agent browser access", () => { assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("a project device override grants device access when the environment denies it", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-device-on"); + const issued = yield* startSessionWith({ browser: false, device: false }, threadId, { + device: true, + }); + assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d04dcae7f126..48c7584cef4b 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -37,7 +37,7 @@ import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCi import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -864,34 +864,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")( function* (threadId: ThreadId) { const settings = yield* serverSettings.getSettings; - if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { - return settings.enableAgentBrowserAccess; + const overridden = Object.values(settings.projectSettingsOverrides).some( + (entry) => + entry.enableAgentBrowserAccess !== undefined || + entry.enableAgentDeviceAccess !== undefined, + ); + if (!overridden) { + return { + browser: settings.enableAgentBrowserAccess, + device: settings.enableAgentDeviceAccess, + }; } // Provider-only runtimes may omit orchestration. An unresolved project - // must not bypass an explicit browser override. - if (Option.isNone(projectionQuery)) return false; + // must not bypass an explicit project override. + if (Option.isNone(projectionQuery)) return { browser: false, device: false }; const thread = yield* projectionQuery.value.getThreadShellById(threadId); - if (Option.isNone(thread)) return false; - return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + if (Option.isNone(thread)) return { browser: false, device: false }; + const resolved = resolveProjectSettings(settings, thread.value.projectId).settings; + return { + browser: resolved.enableAgentBrowserAccess, + device: resolved.enableAgentDeviceAccess, + }; }, Effect.catch((cause) => Effect.logWarning( - "Could not read server settings; withholding agent browser access for this session.", - { cause }, - ).pipe(Effect.as(false)), - ), - ); - - const agentDeviceAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentDeviceAccess), - Effect.catch((cause) => - Effect.logWarning( - "Could not read server settings; withholding agent device access for this session.", + "Could not read server settings; withholding agent browser and device access for this session.", { cause }, - ).pipe(Effect.as(false)), + ).pipe(Effect.as({ browser: false, device: false })), ), ); @@ -899,8 +901,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, ) { const capabilities = new Set(["pull-requests"]); - if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview"); - if (yield* agentDeviceAccessEnabled) capabilities.add("device"); + const access = yield* agentAccessSettings(threadId); + if (access.browser) capabilities.add("preview"); + if (access.device) capabilities.add("device"); return capabilities; }); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 0426df44bcea..37dbd7394a00 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }; }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; - const project = (workspaceRoot: string, autoPull = true) => - ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; - - yield* ServerRuntimeStartup.autoPullProjects([ - project("/clean"), - project("/current"), - project("/dirty"), - project("/ahead"), - project("/feature"), - project("/disabled", false), - ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + const project = (workspaceRoot: string) => + ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never; + const overrides = (entries: Record) => ({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: Object.fromEntries( + Object.entries(entries).map(([root, defaultAutoPull]) => [ + ProjectId.make(root), + { defaultAutoPull }, + ]), + ), + }); + + yield* ServerRuntimeStartup.autoPullProjects( + [ + project("/clean"), + project("/current"), + project("/dirty"), + project("/ahead"), + project("/feature"), + project("/disabled"), + ], + overrides({ + "/clean": true, + "/current": true, + "/dirty": true, + "/ahead": true, + "/feature": true, + "/disabled": false, + }), + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); pulled.length = 0; yield* ServerRuntimeStartup.autoPullProjects( - [project("/inherited", false), project("/opted-out"), project("/dirty", false)], - { - defaultAutoPull: true, - projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, - }, + [project("/inherited"), project("/opted-out"), project("/dirty")], + { ...overrides({ "/opted-out": false }), defaultAutoPull: true }, ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/inherited"]); }), @@ -223,7 +245,17 @@ it.effect.each([ }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), + Effect.provide( + ServerSettings.layerTest({ + defaultModelSelection: machineSelection, + projectSettingsOverrides: + existing && projectSelection + ? { + [ProjectId.make("existing-project")]: { defaultModelSelection: projectSelection }, + } + : {}, + }), + ), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -244,7 +276,7 @@ it.effect.each([ id: ProjectId.make("existing-project"), title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: projectSelection, + defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 3d04abaa1914..05c4bb562e70 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, + type ServerSettings as ServerSettingsValue, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -10,7 +11,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -229,7 +230,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? defaultModelSelection; + resolveProjectSettings(settings, nextProjectId).settings.defaultModelSelection ?? + defaultModelSelection; } yield* Effect.gen(function* () { @@ -479,14 +481,19 @@ export const reconcileProviderSessions = Effect.gen(function* () { const providerService = yield* ProviderService.ProviderService; const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settings = yield* ServerSettings.ServerSettingsService; - const continueAfterRestart = yield* settings.getSettings.pipe( - Effect.map((value) => value.continueThreadsAfterServerUpdate), + const restartSettings = yield* settings.getSettings.pipe( + Effect.map(Option.some), Effect.catch((cause) => Effect.logWarning("could not read restart continuation preference", { cause }).pipe( - Effect.as(false), + Effect.as(Option.none()), ), ), ); + const continueAfterRestartFor = (projectId: ProjectId) => + Option.isSome(restartSettings) + ? resolveProjectSettings(restartSettings.value, projectId).settings + .continueThreadsAfterServerUpdate + : false; const liveThreadIds = new Set( (yield* providerService.listSessions()).map((session) => session.threadId), @@ -568,7 +575,7 @@ export const reconcileProviderSessions = Effect.gen(function* () { // Runtime events advance the projection's turn, but not the directory's // last admitted turn. Use the projection to identify interrupted work. const interruptedByRestart = - continueAfterRestart && + continueAfterRestartFor(thread.projectId) && session.status === "running" && session.activeTurnId !== null && Option.isSome(binding) && @@ -742,16 +749,13 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, - settings: Pick< - typeof DEFAULT_SERVER_SETTINGS, - "defaultAutoPull" | "projectAutoPullOverrides" - > = DEFAULT_SERVER_SETTINGS, + settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) + .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull) .map((project) => project.workspaceRoot), ), ]; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..9bf6cd0b59f0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -1279,4 +1280,77 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.include(persisted, '"valueRedacted": true'); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("folds legacy project overrides into projectSettingsOverrides once", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const legacyProject = ProjectId.make("project-legacy"); + const scriptedProject = ProjectId.make("project-scripted"); + const script = { + id: "check", + name: "Check", + command: "npm test", + icon: "play", + runOnWorktreeCreate: false, + }; + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + for (const [projectId, modelJson, envMode, autoPull, scripts] of [ + [legacyProject, JSON.stringify(model), "worktree", 1, "[]"], + [scriptedProject, null, null, 0, JSON.stringify([script])], + ] as const) { + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelJson}, + ${envMode}, ${autoPull}, ${scripts}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + } + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + JSON.stringify({ + projectAgentBrowserAccessOverrides: { [legacyProject]: false }, + projectAutoPullOverrides: { [scriptedProject]: true }, + projectScriptOverrides: { [legacyProject]: null }, + }), + ); + + const settings = yield* serverSettings.getSettings; + assert.isTrue(settings.projectSettingsFolded); + assert.deepEqual(settings.projectSettingsOverrides, { + [legacyProject]: { + enableAgentBrowserAccess: false, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + }, + [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, + }); + // Derived legacy views keep older clients reading the same values. + assert.deepEqual(settings.projectAutoPullOverrides, { + [legacyProject]: true, + [scriptedProject]: true, + }); + assert.deepEqual(settings.projectScriptOverrides, { [scriptedProject]: [script] }); + + // A reset survives the next load: the fold does not run again. + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { [legacyProject]: null }, + }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + const persisted = yield* decodeServerSettings( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.parse(raw), + ); + assert.isTrue(persisted.projectSettingsFolded); + assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0b64d445adf8..14d66ff715c8 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,7 +15,9 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - type ModelSelection, + ModelSelection, + ProjectScript, + type ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type UsageLimitSourceConfig, @@ -51,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, + deriveLegacyProjectOverrides, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -118,6 +121,7 @@ const normalizeServerSettings = ( encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), + Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => new ServerSettingsError({ @@ -400,6 +404,90 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow return Object.is(current, defaults) ? undefined : current; } +const decodeProjectScriptsJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(ProjectScript)), +); +const decodeModelSelectionJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.NullOr(ModelSelection)), +); + +interface LegacyProjectSettingsRow { + readonly projectId: string; + readonly defaultModelSelection: string | null; + readonly defaultThreadEnvMode: string | null; + readonly autoPull: number; + readonly scripts: string; +} + +/** + * One-time fold of the legacy per-project fields into `projectSettingsOverrides`: + * the three `project*Overrides` maps and the settings columns on the project + * aggregate. Keys already present in the generic record win. Marked with + * `projectSettingsFolded` so a later reset in the UI survives restarts. + */ +function foldLegacyProjectSettings( + settings: ServerSettings, + rows: ReadonlyArray, +): ServerSettings { + if (settings.projectSettingsFolded) return settings; + // Nothing to fold yet (fresh install): leave the marker off so the file + // stays sparse, and check again on the next load. + if ( + rows.length === 0 && + Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 && + Object.keys(settings.projectAutoPullOverrides).length === 0 && + Object.keys(settings.projectScriptOverrides).length === 0 + ) { + return settings; + } + const entries: Record = { + ...settings.projectSettingsOverrides, + }; + const set = ( + projectId: string, + key: K, + value: ProjectSettingsOverrides[K] | undefined, + ) => { + if (value === undefined) return; + const entry = entries[projectId] ?? {}; + if (Object.hasOwn(entry, key)) return; + entries[projectId] = { ...entry, [key]: value }; + }; + for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) { + set(projectId, "enableAgentBrowserAccess", value); + } + for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { + set(projectId, "defaultAutoPull", value); + } + for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { + // A stored null meant "reset to machine defaults", which is now plain inheritance. + if (value !== null) set(projectId, "defaultProjectScripts", value); + } + for (const row of rows) { + const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null"); + if (Option.isSome(model) && model.value !== null) { + set(row.projectId, "defaultModelSelection", model.value); + } + if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") { + set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode); + } + if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); + const scripts = decodeProjectScriptsJson(row.scripts); + if (Option.isSome(scripts) && scripts.value.length > 0) { + set(row.projectId, "defaultProjectScripts", scripts.value); + } + } + const projectSettingsOverrides = Object.fromEntries( + Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0), + ); + return { + ...settings, + projectSettingsOverrides, + projectSettingsFolded: true, + ...deriveLegacyProjectOverrides({ projectSettingsOverrides }), + }; +} + const make = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; @@ -439,6 +527,30 @@ const make = Effect.gen(function* () { ), ); + const writeSettingsAtomically = Effect.fnUntraced( + function* (settings: ServerSettings) { + const sparseSettingsJson = yield* encodeServerSettingsJson( + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, + ); + + return yield* writeFileStringAtomically({ + filePath: settingsPath, + contents: `${sparseSettingsJson}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + }, + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-file", + cause, + }), + ), + ); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; @@ -490,9 +602,36 @@ const make = Effect.gen(function* () { ), ); - return foldProviderInstanceEnabledFlags( + const legacyProjectRows = settings.projectSettingsFolded + ? [] + : yield* sql` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", + scripts_json AS "scripts" + FROM projection_projects + WHERE deleted_at IS NULL + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-project-settings", + cause, + }), + ), + ); + + const loaded = foldProviderInstanceEnabledFlags( restoreUsedProviders(settings, persisted, providerHistory), ); + const folded = foldLegacyProjectSettings(loaded, legacyProjectRows); + if (folded !== loaded) { + yield* writeSettingsAtomically(folded); + } + return folded; }); const settingsCache = yield* Cache.make({ @@ -738,30 +877,6 @@ const make = Effect.gen(function* () { }; }); - const writeSettingsAtomically = Effect.fnUntraced( - function* (settings: ServerSettings) { - const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, - ); - - return yield* writeFileStringAtomically({ - filePath: settingsPath, - contents: `${sparseSettingsJson}\n`, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathService), - ); - }, - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "write-file", - cause, - }), - ), - ); - const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index b9fc9e7ee3ab..6668cc6a0ff5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,7 +22,7 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect( const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); if (project._tag === "None") return false; const settings = yield* serverSettings.getSettings; - return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull; }, Effect.orElseSucceed(() => false), ), diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index fc5ffe62ebc4..3e9934f6f782 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProjectId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; @@ -43,6 +44,17 @@ describe("supportsSharedSettingsSync", () => { }); describe("splitSharedServerPatch", () => { + it("keeps project overrides local: project ids belong to one environment", () => { + const patch = { + projectSettingsOverrides: { [ProjectId.make("project")]: { defaultAutoPull: true } }, + sidebarAutoSettleOnMerge: false, + }; + expect(splitSharedServerPatch(patch)).toEqual({ + sharedPatch: { sidebarAutoSettleOnMerge: false }, + localPatch: { projectSettingsOverrides: patch.projectSettingsOverrides }, + }); + }); + it.each([ { instanceId: ProviderInstanceId.make("codex"), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9dcc844e713a..a57f30f95e55 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), /** Server persists the opt-in for continuing interrupted threads after restarts. */ threadRestartContinuation: Schema.optionalKey(Schema.Boolean), + /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ + projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dd6136461fc1..cb68903d51d5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -928,6 +928,53 @@ export const BackgroundActivitySettings = Schema.Struct({ }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; +/** + * Server settings a project may override. Every other server setting is + * environment-wide: providers, keybindings, observability, device hosts, + * background activity, theme. UI, search and the write planner derive + * eligibility from this list, so adding a key here is the whole opt-in. + */ +export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ + "defaultModelSelection", + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "defaultAutoPull", + "defaultProjectScripts", + "enableAgentBrowserAccess", + "enableAgentDeviceAccess", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "sourceControlWritingStyle", + "sidebarAutoSettleOnMerge", + "sidebarAutoSettleAfterDays", + "continueThreadsAfterServerUpdate", + "enableLegacyTokenStreaming", +] as const; +export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; + +/** + * One project's overrides. An absent key inherits the environment value; + * `null` is a real value where the environment type is nullable (no default + * model, no dedicated writer model, never auto-settle). + */ +export const ProjectSettingsOverrides = Schema.Struct({ + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), + textGenerationModelSelection: Schema.optionalKey(ModelSelection), + sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), + enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), +} satisfies Record); +export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; + export const ServerSettings = Schema.Struct({ // Legacy token-by-token assistant output. Deliberately a fresh key (was // `enableAssistantStreaming`): decoding drops the old key, so everyone, @@ -968,6 +1015,21 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`. + * The source of truth for project settings; `projectAgentBrowserAccessOverrides`, + * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views + * kept for one release so older clients keep reading them. + */ + projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + /** + * Whether the legacy per-project fields have been folded into + * `projectSettingsOverrides`. The fold runs once so a later reset in the + * settings UI is not undone by the next server start. + */ + projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** * Whether agents may drive simulators and emulators. Gates the `device_*` * MCP tools and the preconfigured `agent-device` CLI the same way @@ -1140,6 +1202,7 @@ export const ServerSettingsOperation = Schema.Literals([ "check-exists", "read-file", "read-provider-history", + "read-project-settings", "read-secret", "remove-secret", "remove-stale-secret", @@ -1257,6 +1320,16 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + /** + * Per-project entry replacement: each entry replaces that project's whole + * override set and `null` removes it. Clearing one override means resending + * the entry without that key. Per-key null cannot express "clear" for the + * keys whose value type is itself nullable, and clients always hold the + * current entry from the last settings snapshot. + */ + projectSettingsOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), + ), enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/package.json b/packages/shared/package.json index b502090e2a9d..bda80d937b92 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./projectSettings": { + "types": "./src/projectSettings.ts", + "import": "./src/projectSettings.ts" + }, "./threadEnvMode": { "types": "./src/threadEnvMode.ts", "import": "./src/threadEnvMode.ts" diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 4d98e36b4d70..5cb988753a39 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,23 +1,41 @@ import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; -/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +type ProjectScriptSettings = Pick< + ServerSettings, + | "defaultProjectScripts" + | "projectScriptOverrides" + | "projectSettingsOverrides" + | "projectSettingsFolded" +>; + +/** + * The project's override wins, then environment defaults. Until the legacy + * fields have been folded into `projectSettingsOverrides`, the old map (null + * there meant "reset to machine defaults") and the aggregate's own scripts + * still count, so a server that has not run the fold yet behaves as before. + */ export function resolveProjectScripts( - settings: Pick, + settings: ProjectScriptSettings, project: { id: ProjectId; scripts: readonly ProjectScript[] }, ): readonly ProjectScript[] { - const override = settings.projectScriptOverrides[project.id]; - if (override === null) return settings.defaultProjectScripts; - return ( - override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) - ); + const override = settings.projectSettingsOverrides[project.id]?.defaultProjectScripts; + if (override !== undefined) return override; + if (settings.projectSettingsFolded) return settings.defaultProjectScripts; + const legacy = settings.projectScriptOverrides[project.id]; + if (legacy === null) return settings.defaultProjectScripts; + return legacy ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts); } export function projectScriptsInheritDefaults( - settings: Pick, + settings: ProjectScriptSettings, project: { id: ProjectId; scripts: readonly ProjectScript[] }, ): boolean { - const override = settings.projectScriptOverrides[project.id]; - return override === null || (override === undefined && project.scripts.length === 0); + if (settings.projectSettingsOverrides[project.id]?.defaultProjectScripts !== undefined) { + return false; + } + if (settings.projectSettingsFolded) return true; + const legacy = settings.projectScriptOverrides[project.id]; + return legacy === null || (legacy === undefined && project.scripts.length === 0); } interface ProjectScriptRuntimeEnvInput { diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts new file mode 100644 index 000000000000..9d69f49b622b --- /dev/null +++ b/packages/shared/src/projectSettings.test.ts @@ -0,0 +1,146 @@ +import { + DEFAULT_SERVER_SETTINGS, + PROJECT_SCOPED_SERVER_SETTING_KEYS, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { createModelSelection } from "./model.ts"; +import { + clearProjectSettingsOverrides, + hasProjectSettingsOverrides, + resolveProjectSettings, + withProjectSettingsOverrides, +} from "./projectSettings.ts"; +import { applyServerSettingsPatch } from "./serverSettings.ts"; + +const projectId = ProjectId.make("project-a"); +const otherProjectId = ProjectId.make("project-b"); + +describe("resolveProjectSettings", () => { + it("inherits every scopable key when the project has no overrides", () => { + const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId); + expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS); + for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { + expect(resolved.sources[key]).toBe("environment"); + } + expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null).settings).toBe( + DEFAULT_SERVER_SETTINGS, + ); + }); + + it("applies overrides per key and reports their source", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + sidebarAutoSettleAfterDays: 3, + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: false, sidebarAutoSettleAfterDays: null }, + }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.defaultAutoPull).toBe(false); + expect(resolved.settings.sidebarAutoSettleAfterDays).toBeNull(); + expect(resolved.settings.defaultThreadEnvMode).toBe(settings.defaultThreadEnvMode); + expect(resolved.sources.defaultAutoPull).toBe("project"); + expect(resolved.sources.sidebarAutoSettleAfterDays).toBe("project"); + expect(resolved.sources.defaultThreadEnvMode).toBe("environment"); + expect(resolveProjectSettings(settings, otherProjectId).settings.defaultAutoPull).toBe(true); + }); + + it("keeps the environment text generation model when the override's provider is disabled", () => { + const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providers: { claudeAgent: { enabled: false } }, + projectSettingsOverrides: { + [projectId]: { textGenerationModelSelection: disabledSelection }, + }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.textGenerationModelSelection).toEqual( + settings.textGenerationModelSelection, + ); + expect(resolved.sources.textGenerationModelSelection).toBe("environment"); + }); +}); + +describe("projectSettingsOverrides patches", () => { + it("replaces a project's entry, removes it with null, and drops empty entries", () => { + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + [otherProjectId]: { defaultAutoPull: false }, + }, + }); + expect(hasProjectSettingsOverrides(first)).toBe(true); + const replaced = applyServerSettingsPatch(first, { + projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } }, + }); + expect(replaced.projectSettingsOverrides[projectId]).toEqual({ + enableAgentBrowserAccess: false, + }); + expect(replaced.projectSettingsOverrides[otherProjectId]).toEqual({ defaultAutoPull: false }); + const emptied = applyServerSettingsPatch(replaced, { + projectSettingsOverrides: { [projectId]: {} }, + }); + expect(emptied.projectSettingsOverrides[projectId]).toBeUndefined(); + const removed = applyServerSettingsPatch(replaced, { + projectSettingsOverrides: { [projectId]: null }, + }); + expect(removed.projectSettingsOverrides).toEqual({ + [otherProjectId]: { defaultAutoPull: false }, + }); + expect(hasProjectSettingsOverrides(DEFAULT_SERVER_SETTINGS)).toBe(false); + }); + + it("derives the legacy per-key maps from the generic record", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + [otherProjectId]: { defaultProjectScripts: [] }, + }, + }); + expect(settings.projectAutoPullOverrides).toEqual({ [projectId]: true }); + expect(settings.projectAgentBrowserAccessOverrides).toEqual({ [projectId]: false }); + expect(settings.projectScriptOverrides).toEqual({ [otherProjectId]: [] }); + }); + + it("translates legacy per-key patches into the generic record", () => { + const written = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAutoPullOverrides: { [projectId]: true }, + projectAgentBrowserAccessOverrides: { [projectId]: false, [otherProjectId]: true }, + }); + expect(written.projectSettingsOverrides).toEqual({ + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + [otherProjectId]: { enableAgentBrowserAccess: true }, + }); + const cleared = applyServerSettingsPatch(written, { + projectAgentBrowserAccessOverrides: { [projectId]: null, [otherProjectId]: null }, + }); + expect(cleared.projectSettingsOverrides).toEqual({ [projectId]: { defaultAutoPull: true } }); + }); + + it("builds replacement entries and clears individual keys", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + }, + }); + expect(clearProjectSettingsOverrides(settings, projectId, ["defaultAutoPull"])).toEqual({ + enableAgentBrowserAccess: false, + }); + expect( + clearProjectSettingsOverrides(settings, projectId, [ + "defaultAutoPull", + "enableAgentBrowserAccess", + ]), + ).toBeNull(); + expect(clearProjectSettingsOverrides(settings, otherProjectId, ["defaultAutoPull"])).toBeNull(); + expect(withProjectSettingsOverrides(settings, projectId, null)).toEqual({}); + expect( + withProjectSettingsOverrides(settings, otherProjectId, { defaultThreadEnvMode: "worktree" }), + ).toEqual({ + ...settings.projectSettingsOverrides, + [otherProjectId]: { defaultThreadEnvMode: "worktree" }, + }); + }); +}); diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts new file mode 100644 index 000000000000..9ae7ae2722e3 --- /dev/null +++ b/packages/shared/src/projectSettings.ts @@ -0,0 +1,96 @@ +import { + PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectId, + type ProjectScopedServerSettingKey, + type ProjectSettingsOverrides, + type ServerSettings, +} from "@t3tools/contracts"; +import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; + +export type ProjectSettingSource = "environment" | "project"; + +export type ProjectSettingSources = Readonly< + Record +>; + +export interface ResolvedProjectSettings { + /** Environment settings with the project's overrides applied. */ + readonly settings: ServerSettings; + /** Where each scopable key's effective value came from. */ + readonly sources: ProjectSettingSources; + /** The project's raw override entry; `{}` when it has none. */ + readonly overrides: ProjectSettingsOverrides; +} + +const EMPTY_OVERRIDES: ProjectSettingsOverrides = {}; + +const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries( + PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]), +) as Record; + +/** Cheap check so hot paths skip the projectId lookup when nothing is overridden. */ +export function hasProjectSettingsOverrides( + settings: Pick, +): boolean { + for (const entry of Object.values(settings.projectSettingsOverrides)) { + if (Object.keys(entry).length > 0) return true; + } + return false; +} + +/** + * Apply one project's overrides on top of environment settings. A model + * override whose provider is disabled on this environment falls back to the + * environment value, the same guard the environment-level selection gets. + */ +export function resolveProjectSettings( + settings: ServerSettings, + projectId: ProjectId | null, +): ResolvedProjectSettings { + const overrides = projectId === null ? undefined : settings.projectSettingsOverrides[projectId]; + if (overrides === undefined || Object.keys(overrides).length === 0) { + return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES }; + } + const sources: Record = { + ...ENVIRONMENT_SOURCES, + }; + const effective: Record = { ...settings }; + for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { + if (!Object.hasOwn(overrides, key)) continue; + const value = overrides[key]; + if ( + key === "textGenerationModelSelection" && + value !== undefined && + value !== null && + !isModelSelectionProviderEnabled(settings, value as ServerSettings[typeof key]) + ) { + continue; + } + effective[key] = value; + sources[key] = "project"; + } + return { settings: effective as ServerSettings, sources, overrides }; +} + +/** Replace the project's entry, dropping it entirely when nothing is overridden. */ +export function withProjectSettingsOverrides( + settings: Pick, + projectId: ProjectId, + next: ProjectSettingsOverrides | null, +): ServerSettings["projectSettingsOverrides"] { + const { [projectId]: _removed, ...rest } = settings.projectSettingsOverrides; + return next === null || Object.keys(next).length === 0 ? rest : { ...rest, [projectId]: next }; +} + +/** The project's entry with `keys` removed; `null` when that leaves it empty. */ +export function clearProjectSettingsOverrides( + settings: Pick, + projectId: ProjectId, + keys: readonly ProjectScopedServerSettingKey[], +): ProjectSettingsOverrides | null { + const current = settings.projectSettingsOverrides[projectId]; + if (current === undefined) return null; + const next = { ...current }; + for (const key of keys) delete next[key]; + return Object.keys(next).length === 0 ? null : next; +} diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index cc783fe64bf3..2658db3346e6 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -20,6 +20,9 @@ import { resolveProjectAutoPull, } from "./serverSettings.ts"; +/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ +const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; + describe("serverSettings helpers", () => { it("replaces SSH host lists when saving, editing, and removing hosts", () => { const host = { id: "mini", label: "Mac mini", target: "mini" }; @@ -40,14 +43,19 @@ describe("serverSettings helpers", () => { icon: "play" as const, runOnWorktreeCreate: false, }; - const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + // Before the one-time fold, scripts stored on the project aggregate still apply. + const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(unfolded, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(unfolded, existing)).toBe(false); + const defaults = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, { defaultProjectScripts: [action], }); expect(resolveProjectScripts(defaults, project)).toEqual([action]); expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); - const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; - expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); - expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + expect(resolveProjectScripts(defaults, existing)).toEqual([action]); const disabled = applyServerSettingsPatch(defaults, { projectScriptOverrides: { [project.id]: [] }, }); @@ -82,7 +90,7 @@ describe("serverSettings helpers", () => { }; const firstAction = { ...defaultAction, command: "npm run lint" }; const secondAction = { ...defaultAction, command: "npm run build" }; - const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, { defaultProjectScripts: [defaultAction], projectScriptOverrides: { [firstProject.id]: [firstAction] }, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index f969e4412c30..fbe296f315d7 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -4,6 +4,8 @@ import { resolveProviderInstanceEnabled, type ModelSelection, type ProjectId, + type ProjectScopedServerSettingKey, + type ProjectSettingsOverrides, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -24,22 +26,33 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */ export function resolveProjectAgentBrowserAccess( - settings: Pick, + settings: Pick< + ServerSettings, + "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides" | "projectSettingsOverrides" + >, projectId: ProjectId, ): boolean { return ( - settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ?? + settings.projectAgentBrowserAccessOverrides[projectId] ?? + settings.enableAgentBrowserAccess ); } +/** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */ export function resolveProjectAutoPull( - settings: Pick, + settings: Pick< + ServerSettings, + "defaultAutoPull" | "projectAutoPullOverrides" | "projectSettingsOverrides" + >, projectId: ProjectId, legacyAutoPull: boolean | undefined, ): boolean { // Existing opt-ins stay enabled until explicitly overridden or reset. return ( + settings.projectSettingsOverrides[projectId]?.defaultAutoPull ?? settings.projectAutoPullOverrides[projectId] ?? (legacyAutoPull === true || settings.defaultAutoPull) ); @@ -160,10 +173,93 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +/** + * Derived views of `projectSettingsOverrides` for clients that still read + * the legacy per-key maps. Recomputed on every patch and load so they + * cannot drift from the generic record. + */ +export function deriveLegacyProjectOverrides( + settings: Pick, +): Pick< + ServerSettings, + "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides" | "projectScriptOverrides" +> { + const projectAgentBrowserAccessOverrides: Record = {}; + const projectAutoPullOverrides: Record = {}; + const projectScriptOverrides: Record = {}; + for (const [projectId, entry] of Object.entries(settings.projectSettingsOverrides)) { + if (entry.enableAgentBrowserAccess !== undefined) { + projectAgentBrowserAccessOverrides[projectId] = entry.enableAgentBrowserAccess; + } + if (entry.defaultAutoPull !== undefined) { + projectAutoPullOverrides[projectId] = entry.defaultAutoPull; + } + if (entry.defaultProjectScripts !== undefined) { + projectScriptOverrides[projectId] = entry.defaultProjectScripts; + } + } + return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides }; +} + +/** + * Rewrite a patch that still uses the legacy per-key project maps into + * entries of `projectSettingsOverrides`, so older clients keep editing the + * values the server actually reads. `null` in a legacy map clears that one + * override. + */ +function translateLegacyProjectOverridePatch( + current: Pick, + patch: ServerSettingsPatch, +): ServerSettingsPatch { + const { + projectAgentBrowserAccessOverrides, + projectAutoPullOverrides, + projectScriptOverrides, + ...rest + } = patch; + if ( + projectAgentBrowserAccessOverrides === undefined && + projectAutoPullOverrides === undefined && + projectScriptOverrides === undefined + ) { + return patch; + } + const currentEntries: Readonly> = + current.projectSettingsOverrides; + const entries = new Map( + Object.entries(rest.projectSettingsOverrides ?? {}), + ); + const applyKey = ( + map: Readonly> | undefined, + key: K, + ) => { + if (map === undefined) return; + for (const [projectId, value] of Object.entries(map)) { + const entry: ProjectSettingsOverrides = { + ...(entries.get(projectId) ?? currentEntries[projectId] ?? {}), + }; + if (value === null || value === undefined) { + delete entry[key]; + } else { + entry[key] = value; + } + entries.set(projectId, Object.keys(entry).length === 0 ? null : entry); + } + }; + applyKey(projectAgentBrowserAccessOverrides, "enableAgentBrowserAccess"); + applyKey(projectAutoPullOverrides, "defaultAutoPull"); + applyKey(projectScriptOverrides, "defaultProjectScripts"); + return { + ...rest, + projectSettingsOverrides: Object.fromEntries(entries), + } as ServerSettingsPatch; +} + export function applyServerSettingsPatch( current: ServerSettings, - patch: ServerSettingsPatch, + rawPatch: ServerSettingsPatch, ): ServerSettings { + const patch = translateLegacyProjectOverridePatch(current, rawPatch); const selectionPatch = patch.textGenerationModelSelection; const { automaticGitFetchInterval, @@ -173,8 +269,13 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, - projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, - projectAutoPullOverrides: projectAutoPullOverridesPatch, + // Entry replacement: deepMerge would keep keys the client meant to clear. + projectSettingsOverrides: projectSettingsOverridesPatch, + // Already translated into `projectSettingsOverrides` above; the legacy + // maps are derived views and must never be merged directly. + projectAgentBrowserAccessOverrides: _legacyBrowserAccess, + projectAutoPullOverrides: _legacyAutoPull, + projectScriptOverrides: _legacyScripts, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -231,19 +332,12 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), - ...(projectAgentBrowserAccessOverridesPatch !== undefined + ...(projectSettingsOverridesPatch !== undefined ? { - projectAgentBrowserAccessOverrides: mergeSettingsEntries( - current.projectAgentBrowserAccessOverrides, - projectAgentBrowserAccessOverridesPatch, - ), - } - : {}), - ...(projectAutoPullOverridesPatch !== undefined - ? { - projectAutoPullOverrides: mergeSettingsEntries( - current.projectAutoPullOverrides, - projectAutoPullOverridesPatch, + projectSettingsOverrides: Object.fromEntries( + Object.entries( + mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch), + ).filter(([, entry]) => Object.keys(entry).length > 0), ), } : {}), @@ -253,14 +347,6 @@ export function applyServerSettingsPatch( ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), - ...(patch.projectScriptOverrides !== undefined - ? { - projectScriptOverrides: { - ...current.projectScriptOverrides, - ...patch.projectScriptOverrides, - }, - } - : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( @@ -291,6 +377,7 @@ export function applyServerSettingsPatch( ); const nextWithReplacements = { ...nextWithReplacementsBase, + ...deriveLegacyProjectOverrides(nextWithReplacementsBase), backgroundActivity: normalizedBackgroundActivity, automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval, From e84d87a4a3f763e4e71fc3f31bf751a6d9cf7e39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 00:54:20 -0700 Subject: [PATCH 2/7] feat(settings): add pullRequestMergeMethod as a project-scopable server setting The default merge method lived only as a per-project map in client settings, so it could not be set for an environment and overridden per project like everything else. It becomes a nullable server key (null keeps using the method last chosen on this device) in the project-scoped set. Also unbreaks the fold test's typecheck: encode JSON through Schema and type the assertions. --- apps/server/src/serverSettings.test.ts | 54 +++++++++++++++----------- apps/server/src/serverSettings.ts | 1 + packages/contracts/src/settings.ts | 11 ++++++ 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9bf6cd0b59f0..f3d83fc07e28 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ModelSelection, ProjectId, + ProjectScript, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -1289,7 +1291,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const legacyProject = ProjectId.make("project-legacy"); const scriptedProject = ProjectId.make("project-scripted"); - const script = { + const script: ProjectScript = { id: "check", name: "Check", command: "npm test", @@ -1297,9 +1299,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { runOnWorktreeCreate: false, }; const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); - for (const [projectId, modelJson, envMode, autoPull, scripts] of [ - [legacyProject, JSON.stringify(model), "worktree", 1, "[]"], - [scriptedProject, null, null, 0, JSON.stringify([script])], + const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model); + const scriptsJson = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(ProjectScript)), + )([script]); + for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ + [legacyProject, modelJson, "worktree", 1, "[]"], + [scriptedProject, null, null, 0, scriptsJson], ] as const) { yield* sql` INSERT INTO projection_projects ( @@ -1307,7 +1313,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at ) VALUES ( - ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelJson}, + ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn}, ${envMode}, ${autoPull}, ${scripts}, ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} ) @@ -1315,30 +1321,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { } yield* fileSystem.writeFileString( serverConfig.settingsPath, - JSON.stringify({ - projectAgentBrowserAccessOverrides: { [legacyProject]: false }, - projectAutoPullOverrides: { [scriptedProject]: true }, - projectScriptOverrides: { [legacyProject]: null }, - }), + `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`, ); const settings = yield* serverSettings.getSettings; assert.isTrue(settings.projectSettingsFolded); - assert.deepEqual(settings.projectSettingsOverrides, { - [legacyProject]: { - enableAgentBrowserAccess: false, - defaultModelSelection: model, - defaultThreadEnvMode: "worktree", - defaultAutoPull: true, + assert.deepEqual( + settings.projectSettingsOverrides, + { + [legacyProject]: { + enableAgentBrowserAccess: false, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + }, + [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, }, - [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, - }); + ); // Derived legacy views keep older clients reading the same values. - assert.deepEqual(settings.projectAutoPullOverrides, { - [legacyProject]: true, - [scriptedProject]: true, + assert.deepEqual( + settings.projectAutoPullOverrides, + { + [legacyProject]: true, + [scriptedProject]: true, + }, + ); + assert.deepEqual(settings.projectScriptOverrides, { + [scriptedProject]: [script], }); - assert.deepEqual(settings.projectScriptOverrides, { [scriptedProject]: [script] }); // A reset survives the next load: the fold does not run again. yield* serverSettings.updateSettings({ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 14d66ff715c8..769870cc6a10 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -357,6 +357,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "providerHealthRefreshInterval", "sourceControlWriterModelSelection", "textGenerationModelSelection", + "pullRequestMergeMethod", ]); // Preserve both enabled states because provider history cannot recover a new opt-in. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cb68903d51d5..90b176c3095a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -945,6 +945,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "textGenerationModelSelection", "sourceControlWriterModelSelection", "sourceControlWritingStyle", + "pullRequestMergeMethod", "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", @@ -968,6 +969,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ textGenerationModelSelection: Schema.optionalKey(ModelSelection), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), @@ -1115,6 +1117,14 @@ export const ServerSettings = Schema.Struct({ sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * The merge method pull requests start with; `null` reuses the method + * last chosen on this device. Server-side so a project can override it + * like any other project setting. + */ + pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration @@ -1360,6 +1370,7 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), From acbdb5c78a155ee7893bbe18e66e70b442bdad80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 10:19:01 -0700 Subject: [PATCH 3/7] chore(server): restore the public marker on ThreadSettlementReactor.make The rebase dropped the knip marker that main carries for the canonical Effect module API, so knip:check flagged make as unused. --- apps/server/src/orchestration/ThreadSettlementReactor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index a84af807d908..7bf70f46c7ff 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -59,6 +59,7 @@ function autoSettlementSettingsKey(settings: ServerSettingsValue): string { ]); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; From 554fe2a25f7fbeccca402c9737096f38890d3935 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 10:25:47 -0700 Subject: [PATCH 4/7] fix(settings): harden the project override layer against review findings - Never fold over an unreadable settings.json: the fold and its write run only when the file decoded, so a malformed file stays for the user to repair instead of being replaced with defaults. - A canonical projectSettingsOverrides entry wins over a legacy map for the same project in one patch, so omitting a key to clear it sticks. - A project defaultModelSelection on a disabled provider falls back to the environment, like the text generation model already did. - Agent access without orchestration withholds only the capability some project overrides; a device-only override no longer revokes browser access. - Server stop resolves continueThreadsAfterServerUpdate per session project, so a project override marks its running turns for continuation. - The settlement key encodes an absent threshold differently from null, so switching a project from inherit to never sweeps immediately. --- .../ThreadSettlementReactor.test.ts | 14 ++++ .../orchestration/ThreadSettlementReactor.ts | 8 +- .../provider/Layers/ProviderService.test.ts | 18 ++++- .../src/provider/Layers/ProviderService.ts | 73 +++++++++++++------ apps/server/src/serverSettings.test.ts | 26 +++++++ apps/server/src/serverSettings.ts | 33 +++++---- packages/shared/src/projectSettings.test.ts | 27 +++++++ packages/shared/src/projectSettings.ts | 7 +- packages/shared/src/serverSettings.ts | 4 + 9 files changed, 170 insertions(+), 40 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 79b833e5fc6e..124ec9a08d78 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -302,6 +302,20 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it("distinguishes a project that inherits the threshold from one that disables it", () => { + const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } }, + }); + const never = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null }, + }, + }); + assert.notStrictEqual(inherits, never); + }); + it.effect( "settles all-terminal links from snapshots and keeps open or unsynced links active", () => diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 7bf70f46c7ff..3bf91617b4e7 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -47,14 +47,16 @@ function autoSettlementConfigured(settings: ServerSettingsValue): boolean { } /** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */ -function autoSettlementSettingsKey(settings: ServerSettingsValue): string { +/** @internal Exported for tests. */ +export function autoSettlementSettingsKey(settings: ServerSettingsValue): string { return JSON.stringify([ settings.sidebarAutoSettleOnMerge, settings.sidebarAutoSettleAfterDays, + // JSON drops undefined, so inherit (absent) and never (null) need distinct marks. Object.entries(settings.projectSettingsOverrides).map(([projectId, entry]) => [ projectId, - entry.sidebarAutoSettleOnMerge, - entry.sidebarAutoSettleAfterDays, + entry.sidebarAutoSettleOnMerge ?? "inherit", + entry.sidebarAutoSettleAfterDays === undefined ? "inherit" : entry.sidebarAutoSettleAfterDays, ]), ]); } diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 4e37c59d4a11..17f49355a904 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4803,6 +4803,7 @@ describe("agent browser access", () => { access: boolean | { readonly browser: boolean; readonly device: boolean }, threadId: ThreadId, projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean }, + options?: { readonly withoutOrchestration?: boolean }, ) => Effect.gen(function* () { const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser; @@ -4875,7 +4876,7 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(projectionLayer), + Layer.provide(options?.withoutOrchestration ? Layer.empty : projectionLayer), Layer.provide( ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, @@ -4988,4 +4989,19 @@ describe("agent browser access", () => { assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + // Without orchestration the project cannot be resolved, so an overridden + // capability is withheld; one no project overrides keeps its environment value. + it.effect("withholds only the overridden capability when the project cannot be resolved", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-no-orchestration-device-override"); + const issued = yield* startSessionWith( + { browser: true, device: true }, + threadId, + { device: false }, + { withoutOrchestration: true }, + ); + assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 48c7584cef4b..7ffbb113d549 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -28,10 +28,12 @@ import { ProviderUploadFeedbackInput, ThreadId, TurnId, + type ProjectId, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, + type ServerSettings as ServerSettingsValue, } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -867,22 +869,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")( function* (threadId: ThreadId) { const settings = yield* serverSettings.getSettings; - const overridden = Object.values(settings.projectSettingsOverrides).some( - (entry) => - entry.enableAgentBrowserAccess !== undefined || - entry.enableAgentDeviceAccess !== undefined, + const entries = Object.values(settings.projectSettingsOverrides); + const browserOverridden = entries.some( + (entry) => entry.enableAgentBrowserAccess !== undefined, ); - if (!overridden) { - return { - browser: settings.enableAgentBrowserAccess, - device: settings.enableAgentDeviceAccess, - }; - } + const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined); + const environment = { + browser: settings.enableAgentBrowserAccess, + device: settings.enableAgentDeviceAccess, + }; + if (!browserOverridden && !deviceOverridden) return environment; // Provider-only runtimes may omit orchestration. An unresolved project - // must not bypass an explicit project override. - if (Option.isNone(projectionQuery)) return { browser: false, device: false }; + // must not bypass an explicit project override, but a capability no + // project overrides keeps its environment value. + const denied = { + browser: browserOverridden ? false : environment.browser, + device: deviceOverridden ? false : environment.device, + }; + if (Option.isNone(projectionQuery)) return denied; const thread = yield* projectionQuery.value.getThreadShellById(threadId); - if (Option.isNone(thread)) return { browser: false, device: false }; + if (Option.isNone(thread)) return denied; const resolved = resolveProjectSettings(settings, thread.value.projectId).settings; return { browser: resolved.enableAgentBrowserAccess, @@ -2227,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const runStopAll = Effect.fn("runStopAll")(function* () { - const continueAfterRestart = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.continueThreadsAfterServerUpdate), - Effect.orElseSucceed(() => false), + // Continuation is project-scopable, so decide it per session's project; + // without orchestration the environment value is all there is. + const stopSettings = yield* serverSettings.getSettings.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), ); + const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* ( + threadId: ThreadId, + ) { + if (Option.isNone(stopSettings)) return false; + const settings = stopSettings.value; + const overridden = Object.values(settings.projectSettingsOverrides).some( + (entry) => entry.continueThreadsAfterServerUpdate !== undefined, + ); + if (!overridden || Option.isNone(projectionQuery)) { + return settings.continueThreadsAfterServerUpdate; + } + const thread = yield* projectionQuery.value + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none<{ projectId: ProjectId }>())); + if (Option.isNone(thread)) return settings.continueThreadsAfterServerUpdate; + return resolveProjectSettings(settings, thread.value.projectId).settings + .continueThreadsAfterServerUpdate; + }); const properties = yield* Ref.modify(turnAnalytics, (state) => { const completed: Array>> = []; for (const [sessionKey, session] of state.sessions) { @@ -2256,15 +2282,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { - ...(continueAfterRestart && session.status === "running" && session.activeTurnId + Effect.gen(function* () { + const continueAfterRestart = + session.status === "running" && session.activeTurnId + ? yield* continueAfterRestartFor(session.threadId) + : false; + const lastRuntimeEventAt = yield* nowIso; + yield* upsertSessionBinding(session, session.threadId, { + ...(continueAfterRestart && session.activeTurnId ? { continueAfterServerUpdate: session.activeTurnId } : {}), lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, - }), - ), + }); + }), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f3d83fc07e28..1d8fa09481b3 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1363,4 +1363,30 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("leaves an unreadable settings.json untouched instead of folding over it", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + const broken = '{"defaultAutoPull": tru'; + yield* fileSystem.writeFileString(serverConfig.settingsPath, broken); + + const settings = yield* serverSettings.getSettings; + assert.isFalse(settings.projectSettingsFolded); + assert.deepEqual(settings.projectSettingsOverrides, {}); + // The user's file is still there to repair; nothing was written over it. + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 769870cc6a10..97df0f027688 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -555,6 +555,9 @@ const make = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + // A file that failed to decode must stay on disk for the user to repair; + // the fold below only writes when it started from the file's real contents. + let settingsFileTrusted = true; if (yield* readConfigExists) { const raw = yield* readRawConfig; @@ -565,6 +568,7 @@ const make = Effect.gen(function* () { } if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + settingsFileTrusted = false; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, @@ -603,9 +607,10 @@ const make = Effect.gen(function* () { ), ); - const legacyProjectRows = settings.projectSettingsFolded - ? [] - : yield* sql` + const legacyProjectRows = + settings.projectSettingsFolded || !settingsFileTrusted + ? [] + : yield* sql` SELECT project_id AS "projectId", default_model_selection_json AS "defaultModelSelection", @@ -615,20 +620,22 @@ const make = Effect.gen(function* () { FROM projection_projects WHERE deleted_at IS NULL `.pipe( - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "read-project-settings", - cause, - }), - ), - ); + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-project-settings", + cause, + }), + ), + ); const loaded = foldProviderInstanceEnabledFlags( restoreUsedProviders(settings, persisted, providerHistory), ); - const folded = foldLegacyProjectSettings(loaded, legacyProjectRows); + const folded = settingsFileTrusted + ? foldLegacyProjectSettings(loaded, legacyProjectRows) + : loaded; if (folded !== loaded) { yield* writeSettingsAtomically(folded); } diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 9d69f49b622b..94e82089e267 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -61,6 +61,17 @@ describe("resolveProjectSettings", () => { ); expect(resolved.sources.textGenerationModelSelection).toBe("environment"); }); + + it("keeps the environment default model when the override's provider is disabled", () => { + const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providers: { claudeAgent: { enabled: false } }, + projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.defaultModelSelection).toBeNull(); + expect(resolved.sources.defaultModelSelection).toBe("environment"); + }); }); describe("projectSettingsOverrides patches", () => { @@ -119,6 +130,22 @@ describe("projectSettingsOverrides patches", () => { expect(cleared.projectSettingsOverrides).toEqual({ [projectId]: { defaultAutoPull: true } }); }); + it("lets a canonical entry win over a legacy map for the same project", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } }, + }); + // The canonical entry omits defaultAutoPull to clear it; the stale legacy + // map in the same patch must not put it back. + const next = applyServerSettingsPatch(current, { + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } }, + projectAutoPullOverrides: { [projectId]: true, [otherProjectId]: false }, + }); + expect(next.projectSettingsOverrides).toEqual({ + [projectId]: { defaultThreadEnvMode: "local" }, + [otherProjectId]: { defaultAutoPull: false }, + }); + }); + it("builds replacement entries and clears individual keys", () => { const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { projectSettingsOverrides: { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 9ae7ae2722e3..9a6f18a8975f 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -1,4 +1,5 @@ import { + type ModelSelection, PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectId, type ProjectScopedServerSettingKey, @@ -58,11 +59,13 @@ export function resolveProjectSettings( for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { if (!Object.hasOwn(overrides, key)) continue; const value = overrides[key]; + // A model on a disabled provider falls back to the environment, like the + // environment-level guards do for these keys. if ( - key === "textGenerationModelSelection" && + (key === "textGenerationModelSelection" || key === "defaultModelSelection") && value !== undefined && value !== null && - !isModelSelectionProviderEnabled(settings, value as ServerSettings[typeof key]) + !isModelSelectionProviderEnabled(settings, value as ModelSelection) ) { continue; } diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index fbe296f315d7..bb209fd812bd 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -229,12 +229,16 @@ function translateLegacyProjectOverridePatch( const entries = new Map( Object.entries(rest.projectSettingsOverrides ?? {}), ); + // A canonical entry in the same patch is the newer representation; a legacy + // map must not resurrect a key that entry deliberately omits. + const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {})); const applyKey = ( map: Readonly> | undefined, key: K, ) => { if (map === undefined) return; for (const [projectId, value] of Object.entries(map)) { + if (canonicalProjectIds.has(projectId)) continue; const entry: ProjectSettingsOverrides = { ...(entries.get(projectId) ?? currentEntries[projectId] ?? {}), }; From 445b96ef7ac46c0d7b0a9e5bb796285175c9f8f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 10:39:14 -0700 Subject: [PATCH 5/7] fix(server): key settlement sweeps on settlement overrides only A project override on an unrelated key (model, workspace, scripts) changed the settlement key and queued a sweep with its pull request lookups. The key now keeps only entries that touch settlement, in project id order. --- .../ThreadSettlementReactor.test.ts | 15 ++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 23 ++++++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 124ec9a08d78..c443ec75e0d5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -316,6 +316,21 @@ describe("ThreadSettlementReactor", () => { assert.notStrictEqual(inherits, never); }); + it("ignores project overrides that do not touch settlement", () => { + const base = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, + }); + const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, + [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, + }, + }); + assert.strictEqual(base, unrelated); + }); + it.effect( "settles all-terminal links from snapshots and keeps open or unsynced links active", () => diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 3bf91617b4e7..b9041d2976ca 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -52,12 +52,23 @@ export function autoSettlementSettingsKey(settings: ServerSettingsValue): string return JSON.stringify([ settings.sidebarAutoSettleOnMerge, settings.sidebarAutoSettleAfterDays, - // JSON drops undefined, so inherit (absent) and never (null) need distinct marks. - Object.entries(settings.projectSettingsOverrides).map(([projectId, entry]) => [ - projectId, - entry.sidebarAutoSettleOnMerge ?? "inherit", - entry.sidebarAutoSettleAfterDays === undefined ? "inherit" : entry.sidebarAutoSettleAfterDays, - ]), + // Only entries that touch settlement, in a stable order, so a project + // override on an unrelated key does not queue a sweep. JSON drops + // undefined, so inherit (absent) and never (null) need distinct marks. + Object.entries(settings.projectSettingsOverrides) + .filter( + ([, entry]) => + entry.sidebarAutoSettleOnMerge !== undefined || + entry.sidebarAutoSettleAfterDays !== undefined, + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([projectId, entry]) => [ + projectId, + entry.sidebarAutoSettleOnMerge ?? "inherit", + entry.sidebarAutoSettleAfterDays === undefined + ? "inherit" + : entry.sidebarAutoSettleAfterDays, + ]), ]); } From 559eab96fc201f974a06209a49289e3beae647cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 11:00:37 -0700 Subject: [PATCH 6/7] fix(settings): keep a reset script override and an aggregate model through the fold A project whose scripts had been reset to inherit (a stored null override) regained its aggregate scripts during the fold; the reset now wins. The auto-bootstrap thread honours a project's aggregate default model, still written by older clients, after the override and before the environment. --- apps/server/src/serverRuntimeStartup.ts | 3 +++ apps/server/src/serverSettings.test.ts | 4 +++- apps/server/src/serverSettings.ts | 9 ++++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 05c4bb562e70..f594b9ba970d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -229,8 +229,11 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { } else { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; + // The aggregate field is still written by older clients; honour it + // after the override so their choice keeps applying until they update. nextThreadModelSelection = resolveProjectSettings(settings, nextProjectId).settings.defaultModelSelection ?? + existingProject.value.defaultModelSelection ?? defaultModelSelection; } diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 1d8fa09481b3..208d75fb6517 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1304,7 +1304,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { Schema.fromJsonString(Schema.Array(ProjectScript)), )([script]); for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ - [legacyProject, modelJson, "worktree", 1, "[]"], + // The legacy project also carries aggregate scripts, but its stored + // null override reset them; the fold must not bring them back. + [legacyProject, modelJson, "worktree", 1, scriptsJson], [scriptedProject, null, null, 0, scriptsJson], ] as const) { yield* sql` diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 97df0f027688..50f8649eaacb 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -460,9 +460,12 @@ function foldLegacyProjectSettings( for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { set(projectId, "defaultAutoPull", value); } + // A stored null meant "reset to machine defaults", which is now plain + // inheritance; the project's own aggregate scripts must not resurface. + const resetScripts = new Set(); for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { - // A stored null meant "reset to machine defaults", which is now plain inheritance. - if (value !== null) set(projectId, "defaultProjectScripts", value); + if (value === null) resetScripts.add(projectId); + else set(projectId, "defaultProjectScripts", value); } for (const row of rows) { const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null"); @@ -474,7 +477,7 @@ function foldLegacyProjectSettings( } if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); const scripts = decodeProjectScriptsJson(row.scripts); - if (Option.isSome(scripts) && scripts.value.length > 0) { + if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) { set(row.projectId, "defaultProjectScripts", scripts.value); } } From a370162a0d91b73050aec7f860486fab7949389f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 11:04:43 -0700 Subject: [PATCH 7/7] fix(settings): let the resolver own the aggregate model and workspace fallback Clients fell back to the project aggregate's defaultModelSelection and defaultThreadEnvMode whenever the override record had no value, so after the fold a reset override was undone by the stale aggregate on the next thread. resolveProjectSettings takes the project record and honours those fields only until projectSettingsFolded is set; callers read one answer. --- apps/server/src/serverRuntimeStartup.ts | 7 ++--- packages/shared/src/projectSettings.test.ts | 35 +++++++++++++++++++++ packages/shared/src/projectSettings.ts | 29 +++++++++++++++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index f594b9ba970d..90d6c3c576a0 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -229,12 +229,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { } else { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; - // The aggregate field is still written by older clients; honour it - // after the override so their choice keeps applying until they update. nextThreadModelSelection = - resolveProjectSettings(settings, nextProjectId).settings.defaultModelSelection ?? - existingProject.value.defaultModelSelection ?? - defaultModelSelection; + resolveProjectSettings(settings, nextProjectId, existingProject.value).settings + .defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 94e82089e267..950867d36dc7 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -62,6 +62,41 @@ describe("resolveProjectSettings", () => { expect(resolved.sources.textGenerationModelSelection).toBe("environment"); }); + it("honours the aggregate's own fields only until the server has folded them", () => { + const aggregateModel = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + const project = { + defaultModelSelection: aggregateModel, + defaultThreadEnvMode: "local" as const, + }; + const unfolded = resolveProjectSettings( + { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: false }, + projectId, + project, + ); + expect(unfolded.settings.defaultModelSelection).toEqual(aggregateModel); + expect(unfolded.settings.defaultThreadEnvMode).toBe("local"); + expect(unfolded.sources.defaultModelSelection).toBe("project"); + // A stored override still beats the aggregate before the fold. + const overridden = resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsFolded: false, + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } }, + }, + projectId, + project, + ); + expect(overridden.settings.defaultThreadEnvMode).toBe("worktree"); + // After the fold a reset in the record wins over the stale aggregate. + const folded = resolveProjectSettings( + { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }, + projectId, + project, + ); + expect(folded.settings.defaultModelSelection).toBeNull(); + expect(folded.sources.defaultModelSelection).toBe("environment"); + }); + it("keeps the environment default model when the override's provider is disabled", () => { const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 9a6f18a8975f..742e1cb8d140 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -5,6 +5,7 @@ import { type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, type ServerSettings, + type ThreadEnvMode, } from "@t3tools/contracts"; import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; @@ -39,6 +40,17 @@ export function hasProjectSettingsOverrides( return false; } +/** + * The project aggregate's own model and workspace fields. They remain the + * source of truth until the server has folded them into the override record; + * after the fold the record alone decides, so a reset there cannot be undone + * by a stale aggregate value. + */ +export interface LegacyProjectSettingsFields { + readonly defaultModelSelection?: ModelSelection | null | undefined; + readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined; +} + /** * Apply one project's overrides on top of environment settings. A model * override whose provider is disabled on this environment falls back to the @@ -47,9 +59,22 @@ export function hasProjectSettingsOverrides( export function resolveProjectSettings( settings: ServerSettings, projectId: ProjectId | null, + project?: LegacyProjectSettingsFields, ): ResolvedProjectSettings { - const overrides = projectId === null ? undefined : settings.projectSettingsOverrides[projectId]; - if (overrides === undefined || Object.keys(overrides).length === 0) { + const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId]; + const overrides: ProjectSettingsOverrides = + project === undefined || settings.projectSettingsFolded + ? (stored ?? EMPTY_OVERRIDES) + : { + ...(project.defaultModelSelection != null + ? { defaultModelSelection: project.defaultModelSelection } + : {}), + ...(project.defaultThreadEnvMode != null + ? { defaultThreadEnvMode: project.defaultThreadEnvMode } + : {}), + ...stored, + }; + if (Object.keys(overrides).length === 0) { return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES }; } const sources: Record = {