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..c443ec75e0d5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -302,6 +302,35 @@ 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("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", () => @@ -486,6 +515,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..b9041d2976ca 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"; @@ -32,6 +33,45 @@ 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. */ +/** @internal Exported for tests. */ +export function autoSettlementSettingsKey(settings: ServerSettingsValue): string { + return JSON.stringify([ + settings.sidebarAutoSettleOnMerge, + settings.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, + ]), + ]); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -46,7 +86,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 +100,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 +297,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 +306,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..17f49355a904 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4802,7 +4802,8 @@ 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 }, + options?: { readonly withoutOrchestration?: boolean }, ) => Effect.gen(function* () { const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser; @@ -4875,13 +4876,26 @@ 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, 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 +4979,29 @@ 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)), + ); + + // 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 d04dcae7f126..7ffbb113d549 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -28,16 +28,18 @@ 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"; 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 +866,40 @@ 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 entries = Object.values(settings.projectSettingsOverrides); + const browserOverridden = entries.some( + (entry) => entry.enableAgentBrowserAccess !== undefined, + ); + 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 browser override. - if (Option.isNone(projectionQuery)) return 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 false; - return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + if (Option.isNone(thread)) return denied; + 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.", + "Could not read server settings; withholding agent browser and device 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.", - { cause }, - ).pipe(Effect.as(false)), + ).pipe(Effect.as({ browser: false, device: false })), ), ); @@ -899,8 +907,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; }); @@ -2224,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) { @@ -2253,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/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..90d6c3c576a0 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, existingProject.value).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..208d75fb6517 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ModelSelection, + ProjectId, + ProjectScript, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -1279,4 +1282,113 @@ 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: ProjectScript = { + id: "check", + name: "Check", + command: "npm test", + icon: "play", + runOnWorktreeCreate: false, + }; + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + 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 [ + // 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` + 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}`}, ${modelColumn}, + ${envMode}, ${autoPull}, ${scripts}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + } + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + `{"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())), + ); + + 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 0b64d445adf8..50f8649eaacb 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({ @@ -353,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. @@ -400,6 +405,93 @@ 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); + } + // 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)) { + if (value === null) resetScripts.add(projectId); + else 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 && !resetScripts.has(row.projectId)) { + 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,9 +531,36 @@ 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 = {}; + // 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; @@ -452,6 +571,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, @@ -490,9 +610,39 @@ const make = Effect.gen(function* () { ), ); - return foldProviderInstanceEnabledFlags( + const legacyProjectRows = + settings.projectSettingsFolded || !settingsFileTrusted + ? [] + : 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 = settingsFileTrusted + ? foldLegacyProjectSettings(loaded, legacyProjectRows) + : loaded; + if (folded !== loaded) { + yield* writeSettingsAtomically(folded); + } + return folded; }); const settingsCache = yield* Cache.make({ @@ -738,30 +888,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..90b176c3095a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -928,6 +928,55 @@ 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", + "pullRequestMergeMethod", + "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), + pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), + 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 +1017,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 @@ -1053,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 @@ -1140,6 +1212,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 +1330,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), @@ -1287,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), 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..950867d36dc7 --- /dev/null +++ b/packages/shared/src/projectSettings.test.ts @@ -0,0 +1,208 @@ +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"); + }); + + 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, { + 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", () => { + 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("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: { + [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..742e1cb8d140 --- /dev/null +++ b/packages/shared/src/projectSettings.ts @@ -0,0 +1,124 @@ +import { + type ModelSelection, + PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectId, + type ProjectScopedServerSettingKey, + type ProjectSettingsOverrides, + type ServerSettings, + type ThreadEnvMode, +} 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; +} + +/** + * 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 + * environment value, the same guard the environment-level selection gets. + */ +export function resolveProjectSettings( + settings: ServerSettings, + projectId: ProjectId | null, + project?: LegacyProjectSettingsFields, +): ResolvedProjectSettings { + 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 = { + ...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]; + // A model on a disabled provider falls back to the environment, like the + // environment-level guards do for these keys. + if ( + (key === "textGenerationModelSelection" || key === "defaultModelSelection") && + value !== undefined && + value !== null && + !isModelSelectionProviderEnabled(settings, value as ModelSelection) + ) { + 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..bb209fd812bd 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,97 @@ 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 ?? {}), + ); + // 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] ?? {}), + }; + 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 +273,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 +336,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 +351,6 @@ export function applyServerSettingsPatch( ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), - ...(patch.projectScriptOverrides !== undefined - ? { - projectScriptOverrides: { - ...current.projectScriptOverrides, - ...patch.projectScriptOverrides, - }, - } - : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( @@ -291,6 +381,7 @@ export function applyServerSettingsPatch( ); const nextWithReplacements = { ...nextWithReplacementsBase, + ...deriveLegacyProjectOverrides(nextWithReplacementsBase), backgroundActivity: normalizedBackgroundActivity, automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval,