authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 13:56:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 13:56:39-07:00
log2c0e891740f86205784506a974096cf110b64da7
treeae3d48ea2e70f8f47fc7f6279ba8264d11c19ed6
parentfb52d125b78b2ed1638a719a7546fad9c695c620
signature Signed by PGP key B5690EEEBB952194

feat(settings): add per-project overrides for scopable server settings (#11176)


25 files changed, 1229 insertions(+), 154 deletions(-)

apps/server/src/environment/ServerEnvironment.ts+1
......@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222222 threadSettlement: true,
223223 threadAutoSettlement: true,
224224 threadRestartContinuation: true,
225 projectSettingsOverrides: true,
225226 threadSnooze: true,
226227 environmentThemes: true,
227228 usageLimitSources: true,
apps/server/src/git/GitManager.ts+30-1
......@@ -29,9 +29,16 @@ import {
2929 type VcsStatusRemoteResult,
3030 VcsStatusResult,
3131 ModelSelection,
32 type ProjectId,
3233 SourceControlProviderError,
3334 type SourceControlWritingStyleSettings,
35 type ThreadId,
3436} from "@t3tools/contracts";
37import {
38 hasProjectSettingsOverrides,
39 resolveProjectSettings,
40} from "@t3tools/shared/projectSettings";
41import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
3542import {
3643 detectSourceControlProviderFromGitRemoteUrl,
3744 mergeGitStatusParts,
......@@ -661,6 +668,28 @@ export const make = Effect.gen(function* () {
661668
662669 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
663670 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
671 // Optional: git actions also run from the CLI and tests without orchestration.
672 const projectionQuery = yield* Effect.serviceOption(
673 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
674 );
675 /** Environment settings with the acting project's overrides applied. */
676 const projectSettingsFor = Effect.fnUntraced(function* (input: {
677 readonly cwd: string;
678 readonly threadId?: ThreadId | undefined;
679 }) {
680 const settings = yield* serverSettingsService.getSettings;
681 if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings;
682 const projectId = yield* (
683 input.threadId !== undefined
684 ? projectionQuery.value
685 .getThreadShellById(input.threadId)
686 .pipe(Effect.map(Option.map((thread) => thread.projectId)))
687 : projectionQuery.value
688 .getActiveProjectByWorkspaceRoot(input.cwd)
689 .pipe(Effect.map(Option.map((project) => project.id)))
690 ).pipe(Effect.orElseSucceed(() => Option.none<ProjectId>()));
691 return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings;
692 });
664693 const readRepositoryInstructions = (cwd: string, fileName: string) =>
665694 Effect.gen(function* () {
666695 const root = yield* fileSystem.realPath(cwd);
......@@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () {
26002629 let commitMessageForStep = input.commitMessage;
26012630 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
26022631
2603 const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(
2632 const textGenerationSettings = yield* projectSettingsFor(input).pipe(
26042633 Effect.flatMap((settings) =>
26052634 settings.sourceControlWriterModelSelection === null
26062635 ? Effect.succeed({
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
......@@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
682682 if (context._tag === "Some") {
683683 assert.deepEqual(context.value, {
684684 id: ThreadId.make("thread-1"),
685 projectId: asProjectId("project-1"),
685686 title: "Thread 1",
686687 session: snapshot.threads[0]?.session,
687688 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+4
......@@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({
142142const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
143143const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
144144 id: ThreadId,
145 projectId: ProjectId,
145146 title: Schema.String,
146147 session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
147148});
......@@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12311232 sql`
12321233 SELECT
12331234 threads.thread_id AS id,
1235 threads.project_id AS "projectId",
12341236 threads.title,
12351237 sessions.thread_id AS "threadId",
12361238 sessions.status,
......@@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12511253 Effect.map((rows) =>
12521254 rows.map((row) => ({
12531255 id: row.id,
1256 projectId: row.projectId,
12541257 title: row.title,
12551258 session: row.threadId === null ? null : row,
12561259 })),
......@@ -3164,6 +3167,7 @@ pending_approval_requests AS (
31643167 );
31653168 return Option.map(context, (row) => ({
31663169 id: row.id,
3170 projectId: row.projectId,
31673171 title: row.title,
31683172 session: row.session === null ? null : mapSessionRow(row.session),
31693173 }));
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+19-5
......@@ -54,6 +54,7 @@ import {
5454 resolveSourceControlWriterModelSelection,
5555 ServerSettingsService,
5656} from "../../serverSettings.ts";
57import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
5758import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
5859import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
5960const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
......@@ -329,6 +330,16 @@ const make = Effect.gen(function* () {
329330 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
330331 const textGeneration = yield* TextGeneration;
331332 const serverSettingsService = yield* ServerSettingsService;
333 /** Environment settings with the thread's project overrides applied. */
334 const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
335 const settings = yield* serverSettingsService.getSettings;
336 if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings;
337 const thread = yield* projectionSnapshotQuery
338 .getThreadShellById(threadId)
339 .pipe(Effect.orElseSucceed(() => Option.none()));
340 return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null)
341 .settings;
342 });
332343 const serverCommandId = (tag: string) =>
333344 crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
334345 const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));
......@@ -996,7 +1007,7 @@ const make = Effect.gen(function* () {
9961007 const cwd = input.worktreePath;
9971008 const attachments = input.attachments ?? [];
9981009 yield* Effect.gen(function* () {
999 const settings = yield* serverSettingsService.getSettings;
1010 const settings = yield* projectSettingsForThread(input.threadId);
10001011 const modelSelection =
10011012 settings.sourceControlWriterModelSelection === null
10021013 ? settings.textGenerationModelSelection
......@@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () {
10471058 }) {
10481059 const attachments = input.attachments ?? [];
10491060 yield* Effect.gen(function* () {
1050 const { textGenerationModelSelection: modelSelection } =
1051 yield* serverSettingsService.getSettings;
1061 const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread(
1062 input.threadId,
1063 );
10521064
10531065 const generated = yield* textGeneration
10541066 .generateThreadTitle({
......@@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () {
11171129 thread,
11181130 projects: project ? [project] : [],
11191131 }) ?? process.cwd();
1120 const { textGenerationModelSelection: modelSelection } =
1121 yield* serverSettingsService.getSettings;
1132 const { textGenerationModelSelection: modelSelection } = resolveProjectSettings(
1133 yield* serverSettingsService.getSettings,
1134 thread.projectId,
1135 ).settings;
11221136 const generated = yield* textGeneration.generateThreadTitle({
11231137 cwd,
11241138 message,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9-2
......@@ -51,6 +51,7 @@ import {
5151import { projectActivityPayload } from "../ActivityPayloadProjection.ts";
5252import { forkParked } from "../../serverActivation.ts";
5353import { ServerSettingsService } from "../../serverSettings.ts";
54import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
5455import { canReplaceThreadTitle } from "../threadTitles.ts";
5556
5657const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
......@@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () {
16681669
16691670 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
16701671 serverSettingsService.getSettings,
1671 (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
1672 (settings) =>
1673 resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1674 ? "streaming"
1675 : "buffered",
16721676 );
16731677 if (assistantDeliveryMode === "buffered") {
16741678 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
......@@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () {
17091713 });
17101714 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
17111715 serverSettingsService.getSettings,
1712 (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
1716 (settings) =>
1717 resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1718 ? "streaming"
1719 : "buffered",
17131720 );
17141721 const flushedMessageIds =
17151722 assistantDeliveryMode === "buffered"
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+1-1
......@@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape {
209209 readonly getThreadRuntimeContext: (
210210 threadId: ThreadId,
211211 ) => Effect.Effect<
212 Option.Option<Pick<OrchestrationThreadShell, "id" | "title" | "session">>,
212 Option.Option<Pick<OrchestrationThreadShell, "id" | "projectId" | "title" | "session">>,
213213 ProjectionRepositoryError
214214 >;
215215
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+82
......@@ -302,6 +302,35 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
302302});
303303
304304describe("ThreadSettlementReactor", () => {
305 it("distinguishes a project that inherits the threshold from one that disables it", () => {
306 const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({
307 ...DEFAULT_SERVER_SETTINGS,
308 projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } },
309 });
310 const never = ThreadSettlementReactor.autoSettlementSettingsKey({
311 ...DEFAULT_SERVER_SETTINGS,
312 projectSettingsOverrides: {
313 [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null },
314 },
315 });
316 assert.notStrictEqual(inherits, never);
317 });
318
319 it("ignores project overrides that do not touch settlement", () => {
320 const base = ThreadSettlementReactor.autoSettlementSettingsKey({
321 ...DEFAULT_SERVER_SETTINGS,
322 projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } },
323 });
324 const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({
325 ...DEFAULT_SERVER_SETTINGS,
326 projectSettingsOverrides: {
327 [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" },
328 [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true },
329 },
330 });
331 assert.strictEqual(base, unrelated);
332 });
333
305334 it.effect(
306335 "settles all-terminal links from snapshots and keeps open or unsynced links active",
307336 () =>
......@@ -486,6 +515,59 @@ describe("ThreadSettlementReactor", () => {
486515 ),
487516 );
488517
518 it.effect("a project override settles only that project's inactive threads", () =>
519 Effect.scoped(
520 Effect.gen(function* () {
521 yield* TestClock.setTime(Date.parse(NOW));
522 const overriddenProject = ProjectId.make("overridden-project");
523 const fixture = yield* makeHarness({
524 snapshot: makeSnapshot(
525 [
526 makeThread("inherits-thread"),
527 makeThread("overridden-thread", { projectId: overriddenProject }),
528 ],
529 [makeProject(), makeProject(overriddenProject, "/workspace/overridden")],
530 ),
531 settings: {
532 ...DEFAULT_SERVER_SETTINGS,
533 sidebarAutoSettleAfterDays: null,
534 sidebarAutoSettleOnMerge: false,
535 projectSettingsOverrides: {
536 [overriddenProject]: { sidebarAutoSettleAfterDays: 1 },
537 },
538 },
539 });
540
541 yield* Effect.gen(function* () {
542 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
543 yield* reactor.start();
544 yield* Queue.take(fixture.settingsReads);
545 yield* Deferred.succeed(fixture.activation, undefined);
546 yield* Queue.take(fixture.snapshotReads);
547 yield* reactor.drain;
548 assert.deepStrictEqual(
549 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
550 [ThreadId.make("overridden-thread")],
551 );
552
553 // Clearing the override is a settlement change, so the sweep re-arms.
554 yield* fixture.updateSettings({
555 projectSettingsOverrides: { [overriddenProject]: null },
556 sidebarAutoSettleAfterDays: 1,
557 });
558 yield* Queue.take(fixture.snapshotReads);
559 yield* reactor.drain;
560 // The static snapshot never records the first settlement, so the
561 // second sweep dispatches for both; the inheriting thread is new.
562 assert.include(
563 (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
564 ThreadId.make("inherits-thread"),
565 );
566 }).pipe(Effect.provide(fixture.layer));
567 }),
568 ),
569 );
570
489571 it.effect("starts without clients and skips protected threads before pull request lookup", () =>
490572 Effect.scoped(
491573 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+50-11
......@@ -1,4 +1,5 @@
1import { CommandId } from "@t3tools/contracts";
1import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts";
2import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
23import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
34import * as Cause from "effect/Cause";
45import * as Context from "effect/Context";
......@@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service<
3233 }
3334>()("t3/orchestration/ThreadSettlementReactor") {}
3435
36/** @public Service construction is part of the canonical Effect module API. */
37/** Whether any environment default or project override can settle a thread. */
38function autoSettlementConfigured(settings: ServerSettingsValue): boolean {
39 if (settings.sidebarAutoSettleOnMerge || settings.sidebarAutoSettleAfterDays !== null) {
40 return true;
41 }
42 return Object.values(settings.projectSettingsOverrides).some(
43 (entry) =>
44 entry.sidebarAutoSettleOnMerge === true ||
45 (entry.sidebarAutoSettleAfterDays !== undefined && entry.sidebarAutoSettleAfterDays !== null),
46 );
47}
48
49/** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */
50/** @internal Exported for tests. */
51export function autoSettlementSettingsKey(settings: ServerSettingsValue): string {
52 return JSON.stringify([
53 settings.sidebarAutoSettleOnMerge,
54 settings.sidebarAutoSettleAfterDays,
55 // Only entries that touch settlement, in a stable order, so a project
56 // override on an unrelated key does not queue a sweep. JSON drops
57 // undefined, so inherit (absent) and never (null) need distinct marks.
58 Object.entries(settings.projectSettingsOverrides)
59 .filter(
60 ([, entry]) =>
61 entry.sidebarAutoSettleOnMerge !== undefined ||
62 entry.sidebarAutoSettleAfterDays !== undefined,
63 )
64 .sort(([left], [right]) => left.localeCompare(right))
65 .map(([projectId, entry]) => [
66 projectId,
67 entry.sidebarAutoSettleOnMerge ?? "inherit",
68 entry.sidebarAutoSettleAfterDays === undefined
69 ? "inherit"
70 : entry.sidebarAutoSettleAfterDays,
71 ]),
72 ]);
73}
74
3575/** @public Service construction is part of the canonical Effect module API. */
3676export const make = Effect.gen(function* () {
3777 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
......@@ -46,7 +86,7 @@ export const make = Effect.gen(function* () {
4686 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
4787 ) {
4888 const settings = yield* settingsService.getSettings;
49 if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) {
89 if (!autoSettlementConfigured(settings)) {
5090 return;
5191 }
5292 const snapshot = yield* snapshots.getShellSnapshot();
......@@ -60,7 +100,10 @@ export const make = Effect.gen(function* () {
60100 // dispatch skips it for this snapshot instead of retrying through a lookup.
61101 const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")(
62102 function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {
63 const settings = yield* settingsService.getSettings;
103 const settings = resolveProjectSettings(
104 yield* settingsService.getSettings,
105 thread.projectId,
106 ).settings;
64107 const decisionNow = DateTime.formatIso(yield* DateTime.now);
65108 const settledAt = resolveAutoSettlementAt({
66109 thread,
......@@ -254,8 +297,7 @@ export const make = Effect.gen(function* () {
254297 const settingsChanges = yield* settingsService.subscribeChanges;
255298 const mergedPullRequests = yield* pullRequests.subscribeMerges;
256299 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
257 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
258 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
300 let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);
259301 yield* forkParked(
260302 Effect.gen(function* () {
261303 yield* worker.enqueue(undefined);
......@@ -264,14 +306,11 @@ export const make = Effect.gen(function* () {
264306 );
265307 yield* forkParked(
266308 Stream.runForEach(settingsChanges, (settings) => {
267 if (
268 settings.sidebarAutoSettleAfterDays === lastAfterDays &&
269 settings.sidebarAutoSettleOnMerge === lastOnMerge
270 ) {
309 const key = autoSettlementSettingsKey(settings);
310 if (key === lastSettlementSettings) {
271311 return Effect.void;
272312 }
273 lastAfterDays = settings.sidebarAutoSettleAfterDays;
274 lastOnMerge = settings.sidebarAutoSettleOnMerge;
313 lastSettlementSettings = key;
275314 return worker.enqueue(undefined);
276315 }),
277316 );
apps/server/src/provider/Layers/ProviderService.test.ts+43-4
......@@ -4802,7 +4802,8 @@ describe("agent browser access", () => {
48024802 const startSessionWith = (
48034803 access: boolean | { readonly browser: boolean; readonly device: boolean },
48044804 threadId: ThreadId,
4805 projectOverride?: boolean,
4805 projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean },
4806 options?: { readonly withoutOrchestration?: boolean },
48064807 ) =>
48074808 Effect.gen(function* () {
48084809 const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser;
......@@ -4875,13 +4876,26 @@ describe("agent browser access", () => {
48754876 }).pipe(
48764877 Layer.provide(providerAdapterLayer),
48774878 Layer.provide(directoryLayer),
4878 Layer.provide(projectionLayer),
4879 Layer.provide(options?.withoutOrchestration ? Layer.empty : projectionLayer),
48794880 Layer.provide(
48804881 ServerSettings.ServerSettingsService.layerTest({
48814882 enableAgentBrowserAccess,
48824883 enableAgentDeviceAccess,
4883 projectAgentBrowserAccessOverrides:
4884 projectOverride === undefined ? {} : { [projectId]: projectOverride },
4884 projectSettingsOverrides:
4885 projectOverride === undefined
4886 ? {}
4887 : typeof projectOverride === "boolean"
4888 ? { [projectId]: { enableAgentBrowserAccess: projectOverride } }
4889 : {
4890 [projectId]: {
4891 ...(projectOverride.browser !== undefined
4892 ? { enableAgentBrowserAccess: projectOverride.browser }
4893 : {}),
4894 ...(projectOverride.device !== undefined
4895 ? { enableAgentDeviceAccess: projectOverride.device }
4896 : {}),
4897 },
4898 },
48854899 }),
48864900 ),
48874901 Layer.provide(serverConfigTestLayer),
......@@ -4965,4 +4979,29 @@ describe("agent browser access", () => {
49654979 assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]);
49664980 }).pipe(Effect.provide(NodeServices.layer)),
49674981 );
4982
4983 it.effect("a project device override grants device access when the environment denies it", () =>
4984 Effect.gen(function* () {
4985 const threadId = asThreadId("thread-project-device-on");
4986 const issued = yield* startSessionWith({ browser: false, device: false }, threadId, {
4987 device: true,
4988 });
4989 assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]);
4990 }).pipe(Effect.provide(NodeServices.layer)),
4991 );
4992
4993 // Without orchestration the project cannot be resolved, so an overridden
4994 // capability is withheld; one no project overrides keeps its environment value.
4995 it.effect("withholds only the overridden capability when the project cannot be resolved", () =>
4996 Effect.gen(function* () {
4997 const threadId = asThreadId("thread-no-orchestration-device-override");
4998 const issued = yield* startSessionWith(
4999 { browser: true, device: true },
5000 threadId,
5001 { device: false },
5002 { withoutOrchestration: true },
5003 );
5004 assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]);
5005 }).pipe(Effect.provide(NodeServices.layer)),
5006 );
49685007});
apps/server/src/provider/Layers/ProviderService.ts+65-31
......@@ -28,16 +28,18 @@ import {
2828 ProviderUploadFeedbackInput,
2929 ThreadId,
3030 TurnId,
31 type ProjectId,
3132 type ProviderInstanceId,
3233 type ProviderDriverKind,
3334 type ProviderRuntimeEvent,
3435 type ProviderSession,
36 type ServerSettings as ServerSettingsValue,
3537} from "@t3tools/contracts";
3638import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
3739import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3840import { causeErrorTag } from "@t3tools/shared/observability";
3941import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
40import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";
42import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
4143import * as DateTime from "effect/DateTime";
4244import * as Deferred from "effect/Deferred";
4345import * as Effect from "effect/Effect";
......@@ -864,34 +866,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
864866 * "off" silently becoming "on" would violate the user's stated choice,
865867 * whereas the reverse costs an agent one toolset and is visible immediately.
866868 */
867 const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")(
869 const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")(
868870 function* (threadId: ThreadId) {
869871 const settings = yield* serverSettings.getSettings;
870 if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) {
871 return settings.enableAgentBrowserAccess;
872 }
872 const entries = Object.values(settings.projectSettingsOverrides);
873 const browserOverridden = entries.some(
874 (entry) => entry.enableAgentBrowserAccess !== undefined,
875 );
876 const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined);
877 const environment = {
878 browser: settings.enableAgentBrowserAccess,
879 device: settings.enableAgentDeviceAccess,
880 };
881 if (!browserOverridden && !deviceOverridden) return environment;
873882 // Provider-only runtimes may omit orchestration. An unresolved project
874 // must not bypass an explicit browser override.
875 if (Option.isNone(projectionQuery)) return false;
883 // must not bypass an explicit project override, but a capability no
884 // project overrides keeps its environment value.
885 const denied = {
886 browser: browserOverridden ? false : environment.browser,
887 device: deviceOverridden ? false : environment.device,
888 };
889 if (Option.isNone(projectionQuery)) return denied;
876890 const thread = yield* projectionQuery.value.getThreadShellById(threadId);
877 if (Option.isNone(thread)) return false;
878 return resolveProjectAgentBrowserAccess(settings, thread.value.projectId);
891 if (Option.isNone(thread)) return denied;
892 const resolved = resolveProjectSettings(settings, thread.value.projectId).settings;
893 return {
894 browser: resolved.enableAgentBrowserAccess,
895 device: resolved.enableAgentDeviceAccess,
896 };
879897 },
880898 Effect.catch((cause) =>
881899 Effect.logWarning(
882 "Could not read server settings; withholding agent browser access for this session.",
900 "Could not read server settings; withholding agent browser and device access for this session.",
883901 { cause },
884 ).pipe(Effect.as(false)),
885 ),
886 );
887
888 const agentDeviceAccessEnabled = serverSettings.getSettings.pipe(
889 Effect.map((settings) => settings.enableAgentDeviceAccess),
890 Effect.catch((cause) =>
891 Effect.logWarning(
892 "Could not read server settings; withholding agent device access for this session.",
893 { cause },
894 ).pipe(Effect.as(false)),
902 ).pipe(Effect.as({ browser: false, device: false })),
895903 ),
896904 );
897905
......@@ -899,8 +907,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
899907 threadId: ThreadId,
900908 ) {
901909 const capabilities = new Set<McpInvocationContext.McpCapability>(["pull-requests"]);
902 if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview");
903 if (yield* agentDeviceAccessEnabled) capabilities.add("device");
910 const access = yield* agentAccessSettings(threadId);
911 if (access.browser) capabilities.add("preview");
912 if (access.device) capabilities.add("device");
904913 return capabilities;
905914 });
906915
......@@ -2224,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
22242233 );
22252234
22262235 const runStopAll = Effect.fn("runStopAll")(function* () {
2227 const continueAfterRestart = yield* serverSettings.getSettings.pipe(
2228 Effect.map((settings) => settings.continueThreadsAfterServerUpdate),
2229 Effect.orElseSucceed(() => false),
2236 // Continuation is project-scopable, so decide it per session's project;
2237 // without orchestration the environment value is all there is.
2238 const stopSettings = yield* serverSettings.getSettings.pipe(
2239 Effect.map(Option.some),
2240 Effect.orElseSucceed(() => Option.none<ServerSettingsValue>()),
22302241 );
2242 const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* (
2243 threadId: ThreadId,
2244 ) {
2245 if (Option.isNone(stopSettings)) return false;
2246 const settings = stopSettings.value;
2247 const overridden = Object.values(settings.projectSettingsOverrides).some(
2248 (entry) => entry.continueThreadsAfterServerUpdate !== undefined,
2249 );
2250 if (!overridden || Option.isNone(projectionQuery)) {
2251 return settings.continueThreadsAfterServerUpdate;
2252 }
2253 const thread = yield* projectionQuery.value
2254 .getThreadShellById(threadId)
2255 .pipe(Effect.orElseSucceed(() => Option.none<{ projectId: ProjectId }>()));
2256 if (Option.isNone(thread)) return settings.continueThreadsAfterServerUpdate;
2257 return resolveProjectSettings(settings, thread.value.projectId).settings
2258 .continueThreadsAfterServerUpdate;
2259 });
22312260 const properties = yield* Ref.modify(turnAnalytics, (state) => {
22322261 const completed: Array<Readonly<Record<string, unknown>>> = [];
22332262 for (const [sessionKey, session] of state.sessions) {
......@@ -2253,15 +2282,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
22532282 ),
22542283 ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)));
22552284 yield* Effect.forEach(activeSessions, (session) =>
2256 Effect.flatMap(nowIso, (lastRuntimeEventAt) =>
2257 upsertSessionBinding(session, session.threadId, {
2258 ...(continueAfterRestart && session.status === "running" && session.activeTurnId
2285 Effect.gen(function* () {
2286 const continueAfterRestart =
2287 session.status === "running" && session.activeTurnId
2288 ? yield* continueAfterRestartFor(session.threadId)
2289 : false;
2290 const lastRuntimeEventAt = yield* nowIso;
2291 yield* upsertSessionBinding(session, session.threadId, {
2292 ...(continueAfterRestart && session.activeTurnId
22592293 ? { continueAfterServerUpdate: session.activeTurnId }
22602294 : {}),
22612295 lastRuntimeEvent: "provider.stopAll",
22622296 lastRuntimeEventAt,
2263 }),
2264 ),
2297 });
2298 }),
22652299 ).pipe(Effect.asVoid);
22662300 yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid);
22672301 yield* McpSessionRegistry.revokeAllActiveMcpCredentials();
apps/server/src/serverRuntimeStartup.test.ts+51-19
......@@ -1,5 +1,11 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import {
3 DEFAULT_MODEL,
4 DEFAULT_SERVER_SETTINGS,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
39import { assert, it } from "@effect/vitest";
410import * as Crypto from "effect/Crypto";
511import * as Deferred from "effect/Deferred";
......@@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che
4046 };
4147 }),
4248 } as unknown as GitVcsDriver.GitVcsDriver["Service"];
43 const project = (workspaceRoot: string, autoPull = true) =>
44 ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never;
45
46 yield* ServerRuntimeStartup.autoPullProjects([
47 project("/clean"),
48 project("/current"),
49 project("/dirty"),
50 project("/ahead"),
51 project("/feature"),
52 project("/disabled", false),
53 ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
49 const project = (workspaceRoot: string) =>
50 ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never;
51 const overrides = (entries: Record<string, boolean>) => ({
52 ...DEFAULT_SERVER_SETTINGS,
53 projectSettingsOverrides: Object.fromEntries(
54 Object.entries(entries).map(([root, defaultAutoPull]) => [
55 ProjectId.make(root),
56 { defaultAutoPull },
57 ]),
58 ),
59 });
60
61 yield* ServerRuntimeStartup.autoPullProjects(
62 [
63 project("/clean"),
64 project("/current"),
65 project("/dirty"),
66 project("/ahead"),
67 project("/feature"),
68 project("/disabled"),
69 ],
70 overrides({
71 "/clean": true,
72 "/current": true,
73 "/dirty": true,
74 "/ahead": true,
75 "/feature": true,
76 "/disabled": false,
77 }),
78 ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
5479
5580 assert.deepStrictEqual(pulled, ["/clean"]);
5681
5782 pulled.length = 0;
5883 yield* ServerRuntimeStartup.autoPullProjects(
59 [project("/inherited", false), project("/opted-out"), project("/dirty", false)],
60 {
61 defaultAutoPull: true,
62 projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false },
63 },
84 [project("/inherited"), project("/opted-out"), project("/dirty")],
85 { ...overrides({ "/opted-out": false }), defaultAutoPull: true },
6486 ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
6587 assert.deepStrictEqual(pulled, ["/inherited"]);
6688 }),
......@@ -223,7 +245,17 @@ it.effect.each([
223245 }>
224246 >([]);
225247 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
226 Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })),
248 Effect.provide(
249 ServerSettings.layerTest({
250 defaultModelSelection: machineSelection,
251 projectSettingsOverrides:
252 existing && projectSelection
253 ? {
254 [ProjectId.make("existing-project")]: { defaultModelSelection: projectSelection },
255 }
256 : {},
257 }),
258 ),
227259 Effect.provideService(ServerConfig.ServerConfig, {
228260 cwd: "/tmp/startup-project",
229261 autoBootstrapProjectFromCwd: true,
......@@ -244,7 +276,7 @@ it.effect.each([
244276 id: ProjectId.make("existing-project"),
245277 title: "Startup Project",
246278 workspaceRoot: "/tmp/startup-project",
247 defaultModelSelection: projectSelection,
279 defaultModelSelection: null,
248280 scripts: [],
249281 createdAt: "2026-01-01T00:00:00.000Z",
250282 updatedAt: "2026-01-01T00:00:00.000Z",
apps/server/src/serverRuntimeStartup.ts+15-11
......@@ -3,6 +3,7 @@ import {
33 DEFAULT_MODEL,
44 DEFAULT_PROVIDER_INTERACTION_MODE,
55 DEFAULT_SERVER_SETTINGS,
6 type ServerSettings as ServerSettingsValue,
67 type ModelSelection,
78 type OrchestrationProjectShell,
89 ProjectId,
......@@ -10,7 +11,7 @@ import {
1011 ThreadId,
1112 TurnId,
1213} from "@t3tools/contracts";
13import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
14import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
1415import * as Cause from "effect/Cause";
1516import * as Console from "effect/Console";
1617import * as Context from "effect/Context";
......@@ -229,7 +230,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
229230 nextProjectId = existingProject.value.id;
230231 bootstrapProjectId = nextProjectId;
231232 nextThreadModelSelection =
232 existingProject.value.defaultModelSelection ?? defaultModelSelection;
233 resolveProjectSettings(settings, nextProjectId, existingProject.value).settings
234 .defaultModelSelection ?? defaultModelSelection;
233235 }
234236
235237 yield* Effect.gen(function* () {
......@@ -479,14 +481,19 @@ export const reconcileProviderSessions = Effect.gen(function* () {
479481 const providerService = yield* ProviderService.ProviderService;
480482 const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
481483 const settings = yield* ServerSettings.ServerSettingsService;
482 const continueAfterRestart = yield* settings.getSettings.pipe(
483 Effect.map((value) => value.continueThreadsAfterServerUpdate),
484 const restartSettings = yield* settings.getSettings.pipe(
485 Effect.map(Option.some),
484486 Effect.catch((cause) =>
485487 Effect.logWarning("could not read restart continuation preference", { cause }).pipe(
486 Effect.as(false),
488 Effect.as(Option.none()),
487489 ),
488490 ),
489491 );
492 const continueAfterRestartFor = (projectId: ProjectId) =>
493 Option.isSome(restartSettings)
494 ? resolveProjectSettings(restartSettings.value, projectId).settings
495 .continueThreadsAfterServerUpdate
496 : false;
490497
491498 const liveThreadIds = new Set(
492499 (yield* providerService.listSessions()).map((session) => session.threadId),
......@@ -568,7 +575,7 @@ export const reconcileProviderSessions = Effect.gen(function* () {
568575 // Runtime events advance the projection's turn, but not the directory's
569576 // last admitted turn. Use the projection to identify interrupted work.
570577 const interruptedByRestart =
571 continueAfterRestart &&
578 continueAfterRestartFor(thread.projectId) &&
572579 session.status === "running" &&
573580 session.activeTurnId !== null &&
574581 Option.isSome(binding) &&
......@@ -742,16 +749,13 @@ interface StartupOptions {
742749
743750export const autoPullProjects = Effect.fn("autoPullProjects")(function* (
744751 projects: ReadonlyArray<OrchestrationProjectShell>,
745 settings: Pick<
746 typeof DEFAULT_SERVER_SETTINGS,
747 "defaultAutoPull" | "projectAutoPullOverrides"
748 > = DEFAULT_SERVER_SETTINGS,
752 settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS,
749753) {
750754 const git = yield* GitVcsDriver.GitVcsDriver;
751755 const workspaceRoots = [
752756 ...new Set(
753757 projects
754 .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull))
758 .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull)
755759 .map((project) => project.workspaceRoot),
756760 ),
757761 ];
apps/server/src/serverSettings.test.ts+112
......@@ -1,6 +1,9 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import {
33 DEFAULT_SERVER_SETTINGS,
4 ModelSelection,
5 ProjectId,
6 ProjectScript,
47 ProviderDriverKind,
58 ProviderInstanceId,
69 resolveProviderInstanceEnabled,
......@@ -1279,4 +1282,113 @@ it.layer(NodeServices.layer)("server settings", (it) => {
12791282 assert.include(persisted, '"valueRedacted": true');
12801283 }).pipe(Effect.provide(makeServerSettingsLayer())),
12811284 );
1285
1286 it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
1287 Effect.gen(function* () {
1288 const serverConfig = yield* ServerConfig.ServerConfig;
1289 const fileSystem = yield* FileSystem.FileSystem;
1290 const sql = yield* SqlClient.SqlClient;
1291 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1292 const legacyProject = ProjectId.make("project-legacy");
1293 const scriptedProject = ProjectId.make("project-scripted");
1294 const script: ProjectScript = {
1295 id: "check",
1296 name: "Check",
1297 command: "npm test",
1298 icon: "play",
1299 runOnWorktreeCreate: false,
1300 };
1301 const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
1302 const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model);
1303 const scriptsJson = yield* Schema.encodeEffect(
1304 Schema.fromJsonString(Schema.Array(ProjectScript)),
1305 )([script]);
1306 for (const [projectId, modelColumn, envMode, autoPull, scripts] of [
1307 // The legacy project also carries aggregate scripts, but its stored
1308 // null override reset them; the fold must not bring them back.
1309 [legacyProject, modelJson, "worktree", 1, scriptsJson],
1310 [scriptedProject, null, null, 0, scriptsJson],
1311 ] as const) {
1312 yield* sql`
1313 INSERT INTO projection_projects (
1314 project_id, title, workspace_root, default_model_selection_json,
1315 default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at
1316 )
1317 VALUES (
1318 ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn},
1319 ${envMode}, ${autoPull}, ${scripts},
1320 ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
1321 )
1322 `;
1323 }
1324 yield* fileSystem.writeFileString(
1325 serverConfig.settingsPath,
1326 `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`,
1327 );
1328
1329 const settings = yield* serverSettings.getSettings;
1330 assert.isTrue(settings.projectSettingsFolded);
1331 assert.deepEqual<ServerSettings["projectSettingsOverrides"]>(
1332 settings.projectSettingsOverrides,
1333 {
1334 [legacyProject]: {
1335 enableAgentBrowserAccess: false,
1336 defaultModelSelection: model,
1337 defaultThreadEnvMode: "worktree",
1338 defaultAutoPull: true,
1339 },
1340 [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] },
1341 },
1342 );
1343 // Derived legacy views keep older clients reading the same values.
1344 assert.deepEqual<ServerSettings["projectAutoPullOverrides"]>(
1345 settings.projectAutoPullOverrides,
1346 {
1347 [legacyProject]: true,
1348 [scriptedProject]: true,
1349 },
1350 );
1351 assert.deepEqual<ServerSettings["projectScriptOverrides"]>(settings.projectScriptOverrides, {
1352 [scriptedProject]: [script],
1353 });
1354
1355 // A reset survives the next load: the fold does not run again.
1356 yield* serverSettings.updateSettings({
1357 projectSettingsOverrides: { [legacyProject]: null },
1358 });
1359 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1360 const persisted = yield* decodeServerSettings(
1361 // @effect-diagnostics-next-line preferSchemaOverJson:off
1362 JSON.parse(raw),
1363 );
1364 assert.isTrue(persisted.projectSettingsFolded);
1365 assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]);
1366 }).pipe(Effect.provide(makeServerSettingsLayer())),
1367 );
1368
1369 it.effect("leaves an unreadable settings.json untouched instead of folding over it", () =>
1370 Effect.gen(function* () {
1371 const serverConfig = yield* ServerConfig.ServerConfig;
1372 const fileSystem = yield* FileSystem.FileSystem;
1373 const sql = yield* SqlClient.SqlClient;
1374 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1375 yield* sql`
1376 INSERT INTO projection_projects (
1377 project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at
1378 )
1379 VALUES (
1380 ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"},
1381 ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"}
1382 )
1383 `;
1384 const broken = '{"defaultAutoPull": tru';
1385 yield* fileSystem.writeFileString(serverConfig.settingsPath, broken);
1386
1387 const settings = yield* serverSettings.getSettings;
1388 assert.isFalse(settings.projectSettingsFolded);
1389 assert.deepEqual(settings.projectSettingsOverrides, {});
1390 // The user's file is still there to repair; nothing was written over it.
1391 assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken);
1392 }).pipe(Effect.provide(makeServerSettingsLayer())),
1393 );
12821394});
apps/server/src/serverSettings.ts+152-26
......@@ -15,7 +15,9 @@ import {
1515 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
1616 DEFAULT_MODEL_BY_PROVIDER,
1717 DEFAULT_SERVER_SETTINGS,
18 type ModelSelection,
18 ModelSelection,
19 ProjectScript,
20 type ProjectSettingsOverrides,
1921 type ProviderInstanceConfig,
2022 type ProviderInstanceEnvironmentVariable,
2123 type UsageLimitSourceConfig,
......@@ -51,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
5153import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
5254import {
5355 applyServerSettingsPatch,
56 deriveLegacyProjectOverrides,
5457 isModelSelectionProviderEnabled,
5558} from "@t3tools/shared/serverSettings";
5659import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
......@@ -118,6 +121,7 @@ const normalizeServerSettings = (
118121 encodeServerSettings(settings).pipe(
119122 Effect.flatMap(decodeServerSettings),
120123 Effect.map(foldProviderInstanceEnabledFlags),
124 Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })),
121125 Effect.mapError(
122126 (cause) =>
123127 new ServerSettingsError({
......@@ -353,6 +357,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
353357 "providerHealthRefreshInterval",
354358 "sourceControlWriterModelSelection",
355359 "textGenerationModelSelection",
360 "pullRequestMergeMethod",
356361]);
357362
358363// Preserve both enabled states because provider history cannot recover a new opt-in.
......@@ -400,6 +405,93 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow
400405 return Object.is(current, defaults) ? undefined : current;
401406}
402407
408const decodeProjectScriptsJson = Schema.decodeUnknownOption(
409 Schema.fromJsonString(Schema.Array(ProjectScript)),
410);
411const decodeModelSelectionJson = Schema.decodeUnknownOption(
412 Schema.fromJsonString(Schema.NullOr(ModelSelection)),
413);
414
415interface LegacyProjectSettingsRow {
416 readonly projectId: string;
417 readonly defaultModelSelection: string | null;
418 readonly defaultThreadEnvMode: string | null;
419 readonly autoPull: number;
420 readonly scripts: string;
421}
422
423/**
424 * One-time fold of the legacy per-project fields into `projectSettingsOverrides`:
425 * the three `project*Overrides` maps and the settings columns on the project
426 * aggregate. Keys already present in the generic record win. Marked with
427 * `projectSettingsFolded` so a later reset in the UI survives restarts.
428 */
429function foldLegacyProjectSettings(
430 settings: ServerSettings,
431 rows: ReadonlyArray<LegacyProjectSettingsRow>,
432): ServerSettings {
433 if (settings.projectSettingsFolded) return settings;
434 // Nothing to fold yet (fresh install): leave the marker off so the file
435 // stays sparse, and check again on the next load.
436 if (
437 rows.length === 0 &&
438 Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 &&
439 Object.keys(settings.projectAutoPullOverrides).length === 0 &&
440 Object.keys(settings.projectScriptOverrides).length === 0
441 ) {
442 return settings;
443 }
444 const entries: Record<string, ProjectSettingsOverrides> = {
445 ...settings.projectSettingsOverrides,
446 };
447 const set = <K extends keyof ProjectSettingsOverrides>(
448 projectId: string,
449 key: K,
450 value: ProjectSettingsOverrides[K] | undefined,
451 ) => {
452 if (value === undefined) return;
453 const entry = entries[projectId] ?? {};
454 if (Object.hasOwn(entry, key)) return;
455 entries[projectId] = { ...entry, [key]: value };
456 };
457 for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) {
458 set(projectId, "enableAgentBrowserAccess", value);
459 }
460 for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) {
461 set(projectId, "defaultAutoPull", value);
462 }
463 // A stored null meant "reset to machine defaults", which is now plain
464 // inheritance; the project's own aggregate scripts must not resurface.
465 const resetScripts = new Set<string>();
466 for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) {
467 if (value === null) resetScripts.add(projectId);
468 else set(projectId, "defaultProjectScripts", value);
469 }
470 for (const row of rows) {
471 const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null");
472 if (Option.isSome(model) && model.value !== null) {
473 set(row.projectId, "defaultModelSelection", model.value);
474 }
475 if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") {
476 set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode);
477 }
478 if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true);
479 const scripts = decodeProjectScriptsJson(row.scripts);
480 if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) {
481 set(row.projectId, "defaultProjectScripts", scripts.value);
482 }
483 }
484 const projectSettingsOverrides = Object.fromEntries(
485 Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0),
486 );
487 return {
488 ...settings,
489 projectSettingsOverrides,
490 projectSettingsFolded: true,
491 ...deriveLegacyProjectOverrides({ projectSettingsOverrides }),
492 };
493}
494
403495const make = Effect.gen(function* () {
404496 const { settingsPath } = yield* ServerConfig.ServerConfig;
405497 const fs = yield* FileSystem.FileSystem;
......@@ -439,9 +531,36 @@ const make = Effect.gen(function* () {
439531 ),
440532 );
441533
534 const writeSettingsAtomically = Effect.fnUntraced(
535 function* (settings: ServerSettings) {
536 const sparseSettingsJson = yield* encodeServerSettingsJson(
537 stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},
538 );
539
540 return yield* writeFileStringAtomically({
541 filePath: settingsPath,
542 contents: `${sparseSettingsJson}\n`,
543 }).pipe(
544 Effect.provideService(FileSystem.FileSystem, fs),
545 Effect.provideService(Path.Path, pathService),
546 );
547 },
548 Effect.mapError(
549 (cause) =>
550 new ServerSettingsError({
551 settingsPath,
552 operation: "write-file",
553 cause,
554 }),
555 ),
556 );
557
442558 const loadSettingsFromDisk = Effect.gen(function* () {
443559 let settings = DEFAULT_SERVER_SETTINGS;
444560 let persisted: typeof PersistedOptionalProviderSettings.Type = {};
561 // A file that failed to decode must stay on disk for the user to repair;
562 // the fold below only writes when it started from the file's real contents.
563 let settingsFileTrusted = true;
445564
446565 if (yield* readConfigExists) {
447566 const raw = yield* readRawConfig;
......@@ -452,6 +571,7 @@ const make = Effect.gen(function* () {
452571 }
453572 if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {
454573 const failure = decoded._tag === "Failure" ? decoded : persistedSettings;
574 settingsFileTrusted = false;
455575 if (failure._tag === "Failure") {
456576 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
457577 path: settingsPath,
......@@ -490,9 +610,39 @@ const make = Effect.gen(function* () {
490610 ),
491611 );
492612
493 return foldProviderInstanceEnabledFlags(
613 const legacyProjectRows =
614 settings.projectSettingsFolded || !settingsFileTrusted
615 ? []
616 : yield* sql<LegacyProjectSettingsRow>`
617 SELECT
618 project_id AS "projectId",
619 default_model_selection_json AS "defaultModelSelection",
620 default_thread_env_mode AS "defaultThreadEnvMode",
621 auto_pull AS "autoPull",
622 scripts_json AS "scripts"
623 FROM projection_projects
624 WHERE deleted_at IS NULL
625 `.pipe(
626 Effect.mapError(
627 (cause) =>
628 new ServerSettingsError({
629 settingsPath,
630 operation: "read-project-settings",
631 cause,
632 }),
633 ),
634 );
635
636 const loaded = foldProviderInstanceEnabledFlags(
494637 restoreUsedProviders(settings, persisted, providerHistory),
495638 );
639 const folded = settingsFileTrusted
640 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
641 : loaded;
642 if (folded !== loaded) {
643 yield* writeSettingsAtomically(folded);
644 }
645 return folded;
496646 });
497647
498648 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
......@@ -738,30 +888,6 @@ const make = Effect.gen(function* () {
738888 };
739889 });
740890
741 const writeSettingsAtomically = Effect.fnUntraced(
742 function* (settings: ServerSettings) {
743 const sparseSettingsJson = yield* encodeServerSettingsJson(
744 stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},
745 );
746
747 return yield* writeFileStringAtomically({
748 filePath: settingsPath,
749 contents: `${sparseSettingsJson}\n`,
750 }).pipe(
751 Effect.provideService(FileSystem.FileSystem, fs),
752 Effect.provideService(Path.Path, pathService),
753 );
754 },
755 Effect.mapError(
756 (cause) =>
757 new ServerSettingsError({
758 settingsPath,
759 operation: "write-file",
760 cause,
761 }),
762 ),
763 );
764
765891 const revalidateAndEmit = writeSemaphore.withPermits(1)(
766892 Effect.gen(function* () {
767893 yield* Cache.invalidate(settingsCache, cacheKey);
apps/server/src/vcs/VcsStatusBroadcaster.ts+2-2
......@@ -22,7 +22,7 @@ import type {
2222 VcsStatusStreamEvent,
2323} from "@t3tools/contracts";
2424import { mergeGitStatusParts } from "@t3tools/shared/git";
25import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
25import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2626
2727import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2828import * as GitWorkflowService from "../git/GitWorkflowService.ts";
......@@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect(
160160 const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd);
161161 if (project._tag === "None") return false;
162162 const settings = yield* serverSettings.getSettings;
163 return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull);
163 return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull;
164164 },
165165 Effect.orElseSucceed(() => false),
166166 ),
packages/client-runtime/src/state/sharedSettings.test.ts+12
......@@ -1,6 +1,7 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
33 EnvironmentId,
4 ProjectId,
45 ProviderDriverKind,
56 ProviderInstanceId,
67} from "@t3tools/contracts";
......@@ -43,6 +44,17 @@ describe("supportsSharedSettingsSync", () => {
4344});
4445
4546describe("splitSharedServerPatch", () => {
47 it("keeps project overrides local: project ids belong to one environment", () => {
48 const patch = {
49 projectSettingsOverrides: { [ProjectId.make("project")]: { defaultAutoPull: true } },
50 sidebarAutoSettleOnMerge: false,
51 };
52 expect(splitSharedServerPatch(patch)).toEqual({
53 sharedPatch: { sidebarAutoSettleOnMerge: false },
54 localPatch: { projectSettingsOverrides: patch.projectSettingsOverrides },
55 });
56 });
57
4658 it.each([
4759 {
4860 instanceId: ProviderInstanceId.make("codex"),
packages/contracts/src/environment.ts+2
......@@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
9999 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
100100 /** Server persists the opt-in for continuing interrupted threads after restarts. */
101101 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
102 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
103 projectSettingsOverrides: Schema.optionalKey(Schema.Boolean),
102104 /** Server understands thread.snooze / thread.unsnooze commands. Same
103105 version-skew contract as threadSettlement. */
104106 threadSnooze: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.ts+84
......@@ -928,6 +928,55 @@ export const BackgroundActivitySettings = Schema.Struct({
928928}).pipe(Schema.withDecodingDefault(Effect.succeed({})));
929929export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
930930
931/**
932 * Server settings a project may override. Every other server setting is
933 * environment-wide: providers, keybindings, observability, device hosts,
934 * background activity, theme. UI, search and the write planner derive
935 * eligibility from this list, so adding a key here is the whole opt-in.
936 */
937export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
938 "defaultModelSelection",
939 "defaultThreadEnvMode",
940 "newWorktreesStartFromOrigin",
941 "defaultAutoPull",
942 "defaultProjectScripts",
943 "enableAgentBrowserAccess",
944 "enableAgentDeviceAccess",
945 "textGenerationModelSelection",
946 "sourceControlWriterModelSelection",
947 "sourceControlWritingStyle",
948 "pullRequestMergeMethod",
949 "sidebarAutoSettleOnMerge",
950 "sidebarAutoSettleAfterDays",
951 "continueThreadsAfterServerUpdate",
952 "enableLegacyTokenStreaming",
953] as const;
954export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number];
955
956/**
957 * One project's overrides. An absent key inherits the environment value;
958 * `null` is a real value where the environment type is nullable (no default
959 * model, no dedicated writer model, never auto-settle).
960 */
961export const ProjectSettingsOverrides = Schema.Struct({
962 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
963 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
964 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
965 defaultAutoPull: Schema.optionalKey(Schema.Boolean),
966 defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
967 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
968 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
969 textGenerationModelSelection: Schema.optionalKey(ModelSelection),
970 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
971 sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings),
972 pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
973 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
974 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
975 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
976 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
977} satisfies Record<ProjectScopedServerSettingKey, unknown>);
978export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
979
931980export const ServerSettings = Schema.Struct({
932981 // Legacy token-by-token assistant output. Deliberately a fresh key (was
933982 // `enableAssistantStreaming`): decoding drops the old key, so everyone,
......@@ -968,6 +1017,21 @@ export const ServerSettings = Schema.Struct({
9681017 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
9691018 Schema.withDecodingDefault(Effect.succeed(null)),
9701019 ),
1020 /**
1021 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.
1022 * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,
1023 * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views
1024 * kept for one release so older clients keep reading them.
1025 */
1026 projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe(
1027 Schema.withDecodingDefault(Effect.succeed({})),
1028 ),
1029 /**
1030 * Whether the legacy per-project fields have been folded into
1031 * `projectSettingsOverrides`. The fold runs once so a later reset in the
1032 * settings UI is not undone by the next server start.
1033 */
1034 projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
9711035 /**
9721036 * Whether agents may drive simulators and emulators. Gates the `device_*`
9731037 * MCP tools and the preconfigured `agent-device` CLI the same way
......@@ -1053,6 +1117,14 @@ export const ServerSettings = Schema.Struct({
10531117 sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe(
10541118 Schema.withDecodingDefault(Effect.succeed(null)),
10551119 ),
1120 /**
1121 * The merge method pull requests start with; `null` reuses the method
1122 * last chosen on this device. Server-side so a project can override it
1123 * like any other project setting.
1124 */
1125 pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe(
1126 Schema.withDecodingDefault(Effect.succeed(null)),
1127 ),
10561128
10571129 // Legacy single-instance-per-driver settings. Continues to be the source
10581130 // of truth until `providerInstances` (below) lands per-driver migration
......@@ -1140,6 +1212,7 @@ export const ServerSettingsOperation = Schema.Literals([
11401212 "check-exists",
11411213 "read-file",
11421214 "read-provider-history",
1215 "read-project-settings",
11431216 "read-secret",
11441217 "remove-secret",
11451218 "remove-stale-secret",
......@@ -1257,6 +1330,16 @@ export const ServerSettingsPatch = Schema.Struct({
12571330 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
12581331 ),
12591332 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1333 /**
1334 * Per-project entry replacement: each entry replaces that project's whole
1335 * override set and `null` removes it. Clearing one override means resending
1336 * the entry without that key. Per-key null cannot express "clear" for the
1337 * keys whose value type is itself nullable, and clients always hold the
1338 * current entry from the last settings snapshot.
1339 */
1340 projectSettingsOverrides: Schema.optionalKey(
1341 Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)),
1342 ),
12601343 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
12611344 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
12621345 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
......@@ -1287,6 +1370,7 @@ export const ServerSettingsPatch = Schema.Struct({
12871370 }),
12881371 ),
12891372 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1373 pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
12901374 observability: Schema.optionalKey(
12911375 Schema.Struct({
12921376 otlpTracesUrl: Schema.optionalKey(TrimmedString),
packages/shared/package.json+4
......@@ -103,6 +103,10 @@
103103 "types": "./src/projectScripts.ts",
104104 "import": "./src/projectScripts.ts"
105105 },
106 "./projectSettings": {
107 "types": "./src/projectSettings.ts",
108 "import": "./src/projectSettings.ts"
109 },
106110 "./threadEnvMode": {
107111 "types": "./src/threadEnvMode.ts",
108112 "import": "./src/threadEnvMode.ts"
packages/shared/src/projectScripts.ts+28-10
......@@ -1,23 +1,41 @@
11import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts";
22
3/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */
3type ProjectScriptSettings = Pick<
4 ServerSettings,
5 | "defaultProjectScripts"
6 | "projectScriptOverrides"
7 | "projectSettingsOverrides"
8 | "projectSettingsFolded"
9>;
10
11/**
12 * The project's override wins, then environment defaults. Until the legacy
13 * fields have been folded into `projectSettingsOverrides`, the old map (null
14 * there meant "reset to machine defaults") and the aggregate's own scripts
15 * still count, so a server that has not run the fold yet behaves as before.
16 */
417export function resolveProjectScripts(
5 settings: Pick<ServerSettings, "defaultProjectScripts" | "projectScriptOverrides">,
18 settings: ProjectScriptSettings,
619 project: { id: ProjectId; scripts: readonly ProjectScript[] },
720): readonly ProjectScript[] {
8 const override = settings.projectScriptOverrides[project.id];
9 if (override === null) return settings.defaultProjectScripts;
10 return (
11 override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts)
12 );
21 const override = settings.projectSettingsOverrides[project.id]?.defaultProjectScripts;
22 if (override !== undefined) return override;
23 if (settings.projectSettingsFolded) return settings.defaultProjectScripts;
24 const legacy = settings.projectScriptOverrides[project.id];
25 if (legacy === null) return settings.defaultProjectScripts;
26 return legacy ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts);
1327}
1428
1529export function projectScriptsInheritDefaults(
16 settings: Pick<ServerSettings, "projectScriptOverrides">,
30 settings: ProjectScriptSettings,
1731 project: { id: ProjectId; scripts: readonly ProjectScript[] },
1832): boolean {
19 const override = settings.projectScriptOverrides[project.id];
20 return override === null || (override === undefined && project.scripts.length === 0);
33 if (settings.projectSettingsOverrides[project.id]?.defaultProjectScripts !== undefined) {
34 return false;
35 }
36 if (settings.projectSettingsFolded) return true;
37 const legacy = settings.projectScriptOverrides[project.id];
38 return legacy === null || (legacy === undefined && project.scripts.length === 0);
2139}
2240
2341interface ProjectScriptRuntimeEnvInput {
packages/shared/src/projectSettings.test.ts created+208
......@@ -0,0 +1,208 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 PROJECT_SCOPED_SERVER_SETTING_KEYS,
4 ProjectId,
5 ProviderInstanceId,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";
8import { createModelSelection } from "./model.ts";
9import {
10 clearProjectSettingsOverrides,
11 hasProjectSettingsOverrides,
12 resolveProjectSettings,
13 withProjectSettingsOverrides,
14} from "./projectSettings.ts";
15import { applyServerSettingsPatch } from "./serverSettings.ts";
16
17const projectId = ProjectId.make("project-a");
18const otherProjectId = ProjectId.make("project-b");
19
20describe("resolveProjectSettings", () => {
21 it("inherits every scopable key when the project has no overrides", () => {
22 const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId);
23 expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS);
24 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
25 expect(resolved.sources[key]).toBe("environment");
26 }
27 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null).settings).toBe(
28 DEFAULT_SERVER_SETTINGS,
29 );
30 });
31
32 it("applies overrides per key and reports their source", () => {
33 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
34 defaultAutoPull: true,
35 sidebarAutoSettleAfterDays: 3,
36 projectSettingsOverrides: {
37 [projectId]: { defaultAutoPull: false, sidebarAutoSettleAfterDays: null },
38 },
39 });
40 const resolved = resolveProjectSettings(settings, projectId);
41 expect(resolved.settings.defaultAutoPull).toBe(false);
42 expect(resolved.settings.sidebarAutoSettleAfterDays).toBeNull();
43 expect(resolved.settings.defaultThreadEnvMode).toBe(settings.defaultThreadEnvMode);
44 expect(resolved.sources.defaultAutoPull).toBe("project");
45 expect(resolved.sources.sidebarAutoSettleAfterDays).toBe("project");
46 expect(resolved.sources.defaultThreadEnvMode).toBe("environment");
47 expect(resolveProjectSettings(settings, otherProjectId).settings.defaultAutoPull).toBe(true);
48 });
49
50 it("keeps the environment text generation model when the override's provider is disabled", () => {
51 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
52 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
53 providers: { claudeAgent: { enabled: false } },
54 projectSettingsOverrides: {
55 [projectId]: { textGenerationModelSelection: disabledSelection },
56 },
57 });
58 const resolved = resolveProjectSettings(settings, projectId);
59 expect(resolved.settings.textGenerationModelSelection).toEqual(
60 settings.textGenerationModelSelection,
61 );
62 expect(resolved.sources.textGenerationModelSelection).toBe("environment");
63 });
64
65 it("honours the aggregate's own fields only until the server has folded them", () => {
66 const aggregateModel = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
67 const project = {
68 defaultModelSelection: aggregateModel,
69 defaultThreadEnvMode: "local" as const,
70 };
71 const unfolded = resolveProjectSettings(
72 { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: false },
73 projectId,
74 project,
75 );
76 expect(unfolded.settings.defaultModelSelection).toEqual(aggregateModel);
77 expect(unfolded.settings.defaultThreadEnvMode).toBe("local");
78 expect(unfolded.sources.defaultModelSelection).toBe("project");
79 // A stored override still beats the aggregate before the fold.
80 const overridden = resolveProjectSettings(
81 {
82 ...DEFAULT_SERVER_SETTINGS,
83 projectSettingsFolded: false,
84 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } },
85 },
86 projectId,
87 project,
88 );
89 expect(overridden.settings.defaultThreadEnvMode).toBe("worktree");
90 // After the fold a reset in the record wins over the stale aggregate.
91 const folded = resolveProjectSettings(
92 { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true },
93 projectId,
94 project,
95 );
96 expect(folded.settings.defaultModelSelection).toBeNull();
97 expect(folded.sources.defaultModelSelection).toBe("environment");
98 });
99
100 it("keeps the environment default model when the override's provider is disabled", () => {
101 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
102 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
103 providers: { claudeAgent: { enabled: false } },
104 projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } },
105 });
106 const resolved = resolveProjectSettings(settings, projectId);
107 expect(resolved.settings.defaultModelSelection).toBeNull();
108 expect(resolved.sources.defaultModelSelection).toBe("environment");
109 });
110});
111
112describe("projectSettingsOverrides patches", () => {
113 it("replaces a project's entry, removes it with null, and drops empty entries", () => {
114 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
115 projectSettingsOverrides: {
116 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
117 [otherProjectId]: { defaultAutoPull: false },
118 },
119 });
120 expect(hasProjectSettingsOverrides(first)).toBe(true);
121 const replaced = applyServerSettingsPatch(first, {
122 projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
123 });
124 expect(replaced.projectSettingsOverrides[projectId]).toEqual({
125 enableAgentBrowserAccess: false,
126 });
127 expect(replaced.projectSettingsOverrides[otherProjectId]).toEqual({ defaultAutoPull: false });
128 const emptied = applyServerSettingsPatch(replaced, {
129 projectSettingsOverrides: { [projectId]: {} },
130 });
131 expect(emptied.projectSettingsOverrides[projectId]).toBeUndefined();
132 const removed = applyServerSettingsPatch(replaced, {
133 projectSettingsOverrides: { [projectId]: null },
134 });
135 expect(removed.projectSettingsOverrides).toEqual({
136 [otherProjectId]: { defaultAutoPull: false },
137 });
138 expect(hasProjectSettingsOverrides(DEFAULT_SERVER_SETTINGS)).toBe(false);
139 });
140
141 it("derives the legacy per-key maps from the generic record", () => {
142 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
143 projectSettingsOverrides: {
144 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
145 [otherProjectId]: { defaultProjectScripts: [] },
146 },
147 });
148 expect(settings.projectAutoPullOverrides).toEqual({ [projectId]: true });
149 expect(settings.projectAgentBrowserAccessOverrides).toEqual({ [projectId]: false });
150 expect(settings.projectScriptOverrides).toEqual({ [otherProjectId]: [] });
151 });
152
153 it("translates legacy per-key patches into the generic record", () => {
154 const written = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
155 projectAutoPullOverrides: { [projectId]: true },
156 projectAgentBrowserAccessOverrides: { [projectId]: false, [otherProjectId]: true },
157 });
158 expect(written.projectSettingsOverrides).toEqual({
159 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
160 [otherProjectId]: { enableAgentBrowserAccess: true },
161 });
162 const cleared = applyServerSettingsPatch(written, {
163 projectAgentBrowserAccessOverrides: { [projectId]: null, [otherProjectId]: null },
164 });
165 expect(cleared.projectSettingsOverrides).toEqual({ [projectId]: { defaultAutoPull: true } });
166 });
167
168 it("lets a canonical entry win over a legacy map for the same project", () => {
169 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
170 projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
171 });
172 // The canonical entry omits defaultAutoPull to clear it; the stale legacy
173 // map in the same patch must not put it back.
174 const next = applyServerSettingsPatch(current, {
175 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } },
176 projectAutoPullOverrides: { [projectId]: true, [otherProjectId]: false },
177 });
178 expect(next.projectSettingsOverrides).toEqual({
179 [projectId]: { defaultThreadEnvMode: "local" },
180 [otherProjectId]: { defaultAutoPull: false },
181 });
182 });
183
184 it("builds replacement entries and clears individual keys", () => {
185 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
186 projectSettingsOverrides: {
187 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
188 },
189 });
190 expect(clearProjectSettingsOverrides(settings, projectId, ["defaultAutoPull"])).toEqual({
191 enableAgentBrowserAccess: false,
192 });
193 expect(
194 clearProjectSettingsOverrides(settings, projectId, [
195 "defaultAutoPull",
196 "enableAgentBrowserAccess",
197 ]),
198 ).toBeNull();
199 expect(clearProjectSettingsOverrides(settings, otherProjectId, ["defaultAutoPull"])).toBeNull();
200 expect(withProjectSettingsOverrides(settings, projectId, null)).toEqual({});
201 expect(
202 withProjectSettingsOverrides(settings, otherProjectId, { defaultThreadEnvMode: "worktree" }),
203 ).toEqual({
204 ...settings.projectSettingsOverrides,
205 [otherProjectId]: { defaultThreadEnvMode: "worktree" },
206 });
207 });
208});
packages/shared/src/projectSettings.ts created+124
......@@ -0,0 +1,124 @@
1import {
2 type ModelSelection,
3 PROJECT_SCOPED_SERVER_SETTING_KEYS,
4 type ProjectId,
5 type ProjectScopedServerSettingKey,
6 type ProjectSettingsOverrides,
7 type ServerSettings,
8 type ThreadEnvMode,
9} from "@t3tools/contracts";
10import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
11
12export type ProjectSettingSource = "environment" | "project";
13
14export type ProjectSettingSources = Readonly<
15 Record<ProjectScopedServerSettingKey, ProjectSettingSource>
16>;
17
18export interface ResolvedProjectSettings {
19 /** Environment settings with the project's overrides applied. */
20 readonly settings: ServerSettings;
21 /** Where each scopable key's effective value came from. */
22 readonly sources: ProjectSettingSources;
23 /** The project's raw override entry; `{}` when it has none. */
24 readonly overrides: ProjectSettingsOverrides;
25}
26
27const EMPTY_OVERRIDES: ProjectSettingsOverrides = {};
28
29const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries(
30 PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]),
31) as Record<ProjectScopedServerSettingKey, ProjectSettingSource>;
32
33/** Cheap check so hot paths skip the projectId lookup when nothing is overridden. */
34export function hasProjectSettingsOverrides(
35 settings: Pick<ServerSettings, "projectSettingsOverrides">,
36): boolean {
37 for (const entry of Object.values(settings.projectSettingsOverrides)) {
38 if (Object.keys(entry).length > 0) return true;
39 }
40 return false;
41}
42
43/**
44 * The project aggregate's own model and workspace fields. They remain the
45 * source of truth until the server has folded them into the override record;
46 * after the fold the record alone decides, so a reset there cannot be undone
47 * by a stale aggregate value.
48 */
49export interface LegacyProjectSettingsFields {
50 readonly defaultModelSelection?: ModelSelection | null | undefined;
51 readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined;
52}
53
54/**
55 * Apply one project's overrides on top of environment settings. A model
56 * override whose provider is disabled on this environment falls back to the
57 * environment value, the same guard the environment-level selection gets.
58 */
59export function resolveProjectSettings(
60 settings: ServerSettings,
61 projectId: ProjectId | null,
62 project?: LegacyProjectSettingsFields,
63): ResolvedProjectSettings {
64 const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];
65 const overrides: ProjectSettingsOverrides =
66 project === undefined || settings.projectSettingsFolded
67 ? (stored ?? EMPTY_OVERRIDES)
68 : {
69 ...(project.defaultModelSelection != null
70 ? { defaultModelSelection: project.defaultModelSelection }
71 : {}),
72 ...(project.defaultThreadEnvMode != null
73 ? { defaultThreadEnvMode: project.defaultThreadEnvMode }
74 : {}),
75 ...stored,
76 };
77 if (Object.keys(overrides).length === 0) {
78 return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES };
79 }
80 const sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> = {
81 ...ENVIRONMENT_SOURCES,
82 };
83 const effective: Record<string, unknown> = { ...settings };
84 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
85 if (!Object.hasOwn(overrides, key)) continue;
86 const value = overrides[key];
87 // A model on a disabled provider falls back to the environment, like the
88 // environment-level guards do for these keys.
89 if (
90 (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&
91 value !== undefined &&
92 value !== null &&
93 !isModelSelectionProviderEnabled(settings, value as ModelSelection)
94 ) {
95 continue;
96 }
97 effective[key] = value;
98 sources[key] = "project";
99 }
100 return { settings: effective as ServerSettings, sources, overrides };
101}
102
103/** Replace the project's entry, dropping it entirely when nothing is overridden. */
104export function withProjectSettingsOverrides(
105 settings: Pick<ServerSettings, "projectSettingsOverrides">,
106 projectId: ProjectId,
107 next: ProjectSettingsOverrides | null,
108): ServerSettings["projectSettingsOverrides"] {
109 const { [projectId]: _removed, ...rest } = settings.projectSettingsOverrides;
110 return next === null || Object.keys(next).length === 0 ? rest : { ...rest, [projectId]: next };
111}
112
113/** The project's entry with `keys` removed; `null` when that leaves it empty. */
114export function clearProjectSettingsOverrides(
115 settings: Pick<ServerSettings, "projectSettingsOverrides">,
116 projectId: ProjectId,
117 keys: readonly ProjectScopedServerSettingKey[],
118): ProjectSettingsOverrides | null {
119 const current = settings.projectSettingsOverrides[projectId];
120 if (current === undefined) return null;
121 const next = { ...current };
122 for (const key of keys) delete next[key];
123 return Object.keys(next).length === 0 ? null : next;
124}
packages/shared/src/serverSettings.test.ts+13-5
......@@ -20,6 +20,9 @@ import {
2020 resolveProjectAutoPull,
2121} from "./serverSettings.ts";
2222
23/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */
24const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };
25
2326describe("serverSettings helpers", () => {
2427 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
2528 const host = { id: "mini", label: "Mac mini", target: "mini" };
......@@ -40,14 +43,19 @@ describe("serverSettings helpers", () => {
4043 icon: "play" as const,
4144 runOnWorktreeCreate: false,
4245 };
43 const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
46 const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
47 // Before the one-time fold, scripts stored on the project aggregate still apply.
48 const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
49 defaultProjectScripts: [action],
50 });
51 expect(resolveProjectScripts(unfolded, existing)).toEqual(existing.scripts);
52 expect(projectScriptsInheritDefaults(unfolded, existing)).toBe(false);
53 const defaults = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
4454 defaultProjectScripts: [action],
4555 });
4656 expect(resolveProjectScripts(defaults, project)).toEqual([action]);
4757 expect(projectScriptsInheritDefaults(defaults, project)).toBe(true);
48 const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
49 expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts);
50 expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false);
58 expect(resolveProjectScripts(defaults, existing)).toEqual([action]);
5159 const disabled = applyServerSettingsPatch(defaults, {
5260 projectScriptOverrides: { [project.id]: [] },
5361 });
......@@ -82,7 +90,7 @@ describe("serverSettings helpers", () => {
8290 };
8391 const firstAction = { ...defaultAction, command: "npm run lint" };
8492 const secondAction = { ...defaultAction, command: "npm run build" };
85 const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
93 const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
8694 defaultProjectScripts: [defaultAction],
8795 projectScriptOverrides: { [firstProject.id]: [firstAction] },
8896 });
packages/shared/src/serverSettings.ts+117-26
......@@ -4,6 +4,8 @@ import {
44 resolveProviderInstanceEnabled,
55 type ModelSelection,
66 type ProjectId,
7 type ProjectScopedServerSettingKey,
8 type ProjectSettingsOverrides,
79 type ProviderDriverKind,
810 type ServerProvider,
911 ServerSettings,
......@@ -24,22 +26,33 @@ import {
2426const ServerSettingsJson = fromLenientJson(ServerSettings);
2527const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
2628
29/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */
2730export function resolveProjectAgentBrowserAccess(
28 settings: Pick<ServerSettings, "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides">,
31 settings: Pick<
32 ServerSettings,
33 "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides" | "projectSettingsOverrides"
34 >,
2935 projectId: ProjectId,
3036): boolean {
3137 return (
32 settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess
38 settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ??
39 settings.projectAgentBrowserAccessOverrides[projectId] ??
40 settings.enableAgentBrowserAccess
3341 );
3442}
3543
44/** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */
3645export function resolveProjectAutoPull(
37 settings: Pick<ServerSettings, "defaultAutoPull" | "projectAutoPullOverrides">,
46 settings: Pick<
47 ServerSettings,
48 "defaultAutoPull" | "projectAutoPullOverrides" | "projectSettingsOverrides"
49 >,
3850 projectId: ProjectId,
3951 legacyAutoPull: boolean | undefined,
4052): boolean {
4153 // Existing opt-ins stay enabled until explicitly overridden or reset.
4254 return (
55 settings.projectSettingsOverrides[projectId]?.defaultAutoPull ??
4356 settings.projectAutoPullOverrides[projectId] ??
4457 (legacyAutoPull === true || settings.defaultAutoPull)
4558 );
......@@ -160,10 +173,97 @@ function mergeSettingsEntries<Value>(
160173 return Object.fromEntries(next);
161174}
162175
176/**
177 * Derived views of `projectSettingsOverrides` for clients that still read
178 * the legacy per-key maps. Recomputed on every patch and load so they
179 * cannot drift from the generic record.
180 */
181export function deriveLegacyProjectOverrides(
182 settings: Pick<ServerSettings, "projectSettingsOverrides">,
183): Pick<
184 ServerSettings,
185 "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides" | "projectScriptOverrides"
186> {
187 const projectAgentBrowserAccessOverrides: Record<string, boolean> = {};
188 const projectAutoPullOverrides: Record<string, boolean> = {};
189 const projectScriptOverrides: Record<string, ServerSettings["defaultProjectScripts"] | null> = {};
190 for (const [projectId, entry] of Object.entries(settings.projectSettingsOverrides)) {
191 if (entry.enableAgentBrowserAccess !== undefined) {
192 projectAgentBrowserAccessOverrides[projectId] = entry.enableAgentBrowserAccess;
193 }
194 if (entry.defaultAutoPull !== undefined) {
195 projectAutoPullOverrides[projectId] = entry.defaultAutoPull;
196 }
197 if (entry.defaultProjectScripts !== undefined) {
198 projectScriptOverrides[projectId] = entry.defaultProjectScripts;
199 }
200 }
201 return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides };
202}
203
204/**
205 * Rewrite a patch that still uses the legacy per-key project maps into
206 * entries of `projectSettingsOverrides`, so older clients keep editing the
207 * values the server actually reads. `null` in a legacy map clears that one
208 * override.
209 */
210function translateLegacyProjectOverridePatch(
211 current: Pick<ServerSettings, "projectSettingsOverrides">,
212 patch: ServerSettingsPatch,
213): ServerSettingsPatch {
214 const {
215 projectAgentBrowserAccessOverrides,
216 projectAutoPullOverrides,
217 projectScriptOverrides,
218 ...rest
219 } = patch;
220 if (
221 projectAgentBrowserAccessOverrides === undefined &&
222 projectAutoPullOverrides === undefined &&
223 projectScriptOverrides === undefined
224 ) {
225 return patch;
226 }
227 const currentEntries: Readonly<Record<string, ProjectSettingsOverrides>> =
228 current.projectSettingsOverrides;
229 const entries = new Map<string, ProjectSettingsOverrides | null>(
230 Object.entries(rest.projectSettingsOverrides ?? {}),
231 );
232 // A canonical entry in the same patch is the newer representation; a legacy
233 // map must not resurrect a key that entry deliberately omits.
234 const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {}));
235 const applyKey = <K extends ProjectScopedServerSettingKey>(
236 map: Readonly<Record<string, ProjectSettingsOverrides[K] | null>> | undefined,
237 key: K,
238 ) => {
239 if (map === undefined) return;
240 for (const [projectId, value] of Object.entries(map)) {
241 if (canonicalProjectIds.has(projectId)) continue;
242 const entry: ProjectSettingsOverrides = {
243 ...(entries.get(projectId) ?? currentEntries[projectId] ?? {}),
244 };
245 if (value === null || value === undefined) {
246 delete entry[key];
247 } else {
248 entry[key] = value;
249 }
250 entries.set(projectId, Object.keys(entry).length === 0 ? null : entry);
251 }
252 };
253 applyKey(projectAgentBrowserAccessOverrides, "enableAgentBrowserAccess");
254 applyKey(projectAutoPullOverrides, "defaultAutoPull");
255 applyKey(projectScriptOverrides, "defaultProjectScripts");
256 return {
257 ...rest,
258 projectSettingsOverrides: Object.fromEntries(entries),
259 } as ServerSettingsPatch;
260}
261
163262export function applyServerSettingsPatch(
164263 current: ServerSettings,
165 patch: ServerSettingsPatch,
264 rawPatch: ServerSettingsPatch,
166265): ServerSettings {
266 const patch = translateLegacyProjectOverridePatch(current, rawPatch);
167267 const selectionPatch = patch.textGenerationModelSelection;
168268 const {
169269 automaticGitFetchInterval,
......@@ -173,8 +273,13 @@ export function applyServerSettingsPatch(
173273 // Merged per entry below; its `null` removals must not reach deepMerge.
174274 usageLimitSources: usageLimitSourcesPatch,
175275 usagePriceOverrides: usagePriceOverridesPatch,
176 projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,
177 projectAutoPullOverrides: projectAutoPullOverridesPatch,
276 // Entry replacement: deepMerge would keep keys the client meant to clear.
277 projectSettingsOverrides: projectSettingsOverridesPatch,
278 // Already translated into `projectSettingsOverrides` above; the legacy
279 // maps are derived views and must never be merged directly.
280 projectAgentBrowserAccessOverrides: _legacyBrowserAccess,
281 projectAutoPullOverrides: _legacyAutoPull,
282 projectScriptOverrides: _legacyScripts,
178283 ...patchForMerge
179284 } = patch;
180285 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
......@@ -231,19 +336,12 @@ export function applyServerSettingsPatch(
231336 ...(patch.providerInstances !== undefined
232337 ? { providerInstances: patch.providerInstances }
233338 : {}),
234 ...(projectAgentBrowserAccessOverridesPatch !== undefined
339 ...(projectSettingsOverridesPatch !== undefined
235340 ? {
236 projectAgentBrowserAccessOverrides: mergeSettingsEntries(
237 current.projectAgentBrowserAccessOverrides,
238 projectAgentBrowserAccessOverridesPatch,
239 ),
240 }
241 : {}),
242 ...(projectAutoPullOverridesPatch !== undefined
243 ? {
244 projectAutoPullOverrides: mergeSettingsEntries(
245 current.projectAutoPullOverrides,
246 projectAutoPullOverridesPatch,
341 projectSettingsOverrides: Object.fromEntries(
342 Object.entries(
343 mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch),
344 ).filter(([, entry]) => Object.keys(entry).length > 0),
247345 ),
248346 }
249347 : {}),
......@@ -253,14 +351,6 @@ export function applyServerSettingsPatch(
253351 ...(patch.defaultProjectScripts !== undefined
254352 ? { defaultProjectScripts: patch.defaultProjectScripts }
255353 : {}),
256 ...(patch.projectScriptOverrides !== undefined
257 ? {
258 projectScriptOverrides: {
259 ...current.projectScriptOverrides,
260 ...patch.projectScriptOverrides,
261 },
262 }
263 : {}),
264354 ...(usageLimitSourcesPatch !== undefined
265355 ? {
266356 usageLimitSources: mergeSettingsEntries(
......@@ -291,6 +381,7 @@ export function applyServerSettingsPatch(
291381 );
292382 const nextWithReplacements = {
293383 ...nextWithReplacementsBase,
384 ...deriveLegacyProjectOverrides(nextWithReplacementsBase),
294385 backgroundActivity: normalizedBackgroundActivity,
295386 automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval,
296387 providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval,