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* () {...@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222 threadSettlement: true,222 threadSettlement: true,
223 threadAutoSettlement: true,223 threadAutoSettlement: true,
224 threadRestartContinuation: true,224 threadRestartContinuation: true,
225 projectSettingsOverrides: true,
225 threadSnooze: true,226 threadSnooze: true,
226 environmentThemes: true,227 environmentThemes: true,
227 usageLimitSources: true,228 usageLimitSources: true,
apps/server/src/git/GitManager.ts+30-1
...@@ -29,9 +29,16 @@ import {...@@ -29,9 +29,16 @@ import {
29 type VcsStatusRemoteResult,29 type VcsStatusRemoteResult,
30 VcsStatusResult,30 VcsStatusResult,
31 ModelSelection,31 ModelSelection,
32 type ProjectId,
32 SourceControlProviderError,33 SourceControlProviderError,
33 type SourceControlWritingStyleSettings,34 type SourceControlWritingStyleSettings,
35 type ThreadId,
34} from "@t3tools/contracts";36} from "@t3tools/contracts";
37import {
38 hasProjectSettingsOverrides,
39 resolveProjectSettings,
40} from "@t3tools/shared/projectSettings";
41import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
35import {42import {
36 detectSourceControlProviderFromGitRemoteUrl,43 detectSourceControlProviderFromGitRemoteUrl,
37 mergeGitStatusParts,44 mergeGitStatusParts,
...@@ -661,6 +668,28 @@ export const make = Effect.gen(function* () {...@@ -661,6 +668,28 @@ export const make = Effect.gen(function* () {
661668
662 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });669 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
663 const serverSettingsService = yield* ServerSettings.ServerSettingsService;670 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 });
664 const readRepositoryInstructions = (cwd: string, fileName: string) =>693 const readRepositoryInstructions = (cwd: string, fileName: string) =>
665 Effect.gen(function* () {694 Effect.gen(function* () {
666 const root = yield* fileSystem.realPath(cwd);695 const root = yield* fileSystem.realPath(cwd);
...@@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () {...@@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () {
2600 let commitMessageForStep = input.commitMessage;2629 let commitMessageForStep = input.commitMessage;
2601 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;2630 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
26022631
2603 const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(2632 const textGenerationSettings = yield* projectSettingsFor(input).pipe(
2604 Effect.flatMap((settings) =>2633 Effect.flatMap((settings) =>
2605 settings.sourceControlWriterModelSelection === null2634 settings.sourceControlWriterModelSelection === null
2606 ? Effect.succeed({2635 ? Effect.succeed({
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
...@@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
682 if (context._tag === "Some") {682 if (context._tag === "Some") {
683 assert.deepEqual(context.value, {683 assert.deepEqual(context.value, {
684 id: ThreadId.make("thread-1"),684 id: ThreadId.make("thread-1"),
685 projectId: asProjectId("project-1"),
685 title: "Thread 1",686 title: "Thread 1",
686 session: snapshot.threads[0]?.session,687 session: snapshot.threads[0]?.session,
687 });688 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+4
...@@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({...@@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({
142const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;142const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
143const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({143const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
144 id: ThreadId,144 id: ThreadId,
145 projectId: ProjectId,
145 title: Schema.String,146 title: Schema.String,
146 session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),147 session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
147});148});
...@@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1231 sql`1232 sql`
1232 SELECT1233 SELECT
1233 threads.thread_id AS id,1234 threads.thread_id AS id,
1235 threads.project_id AS "projectId",
1234 threads.title,1236 threads.title,
1235 sessions.thread_id AS "threadId",1237 sessions.thread_id AS "threadId",
1236 sessions.status,1238 sessions.status,
...@@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1251 Effect.map((rows) =>1253 Effect.map((rows) =>
1252 rows.map((row) => ({1254 rows.map((row) => ({
1253 id: row.id,1255 id: row.id,
1256 projectId: row.projectId,
1254 title: row.title,1257 title: row.title,
1255 session: row.threadId === null ? null : row,1258 session: row.threadId === null ? null : row,
1256 })),1259 })),
...@@ -3164,6 +3167,7 @@ pending_approval_requests AS (...@@ -3164,6 +3167,7 @@ pending_approval_requests AS (
3164 );3167 );
3165 return Option.map(context, (row) => ({3168 return Option.map(context, (row) => ({
3166 id: row.id,3169 id: row.id,
3170 projectId: row.projectId,
3167 title: row.title,3171 title: row.title,
3168 session: row.session === null ? null : mapSessionRow(row.session),3172 session: row.session === null ? null : mapSessionRow(row.session),
3169 }));3173 }));
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+19-5
...@@ -54,6 +54,7 @@ import {...@@ -54,6 +54,7 @@ import {
54 resolveSourceControlWriterModelSelection,54 resolveSourceControlWriterModelSelection,
55 ServerSettingsService,55 ServerSettingsService,
56} from "../../serverSettings.ts";56} from "../../serverSettings.ts";
57import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
57import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";58import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
58import { GitWorkflowService } from "../../git/GitWorkflowService.ts";59import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
59const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);60const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
...@@ -329,6 +330,16 @@ const make = Effect.gen(function* () {...@@ -329,6 +330,16 @@ const make = Effect.gen(function* () {
329 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;330 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
330 const textGeneration = yield* TextGeneration;331 const textGeneration = yield* TextGeneration;
331 const serverSettingsService = yield* ServerSettingsService;332 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 });
332 const serverCommandId = (tag: string) =>343 const serverCommandId = (tag: string) =>
333 crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));344 crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
334 const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));345 const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));
...@@ -996,7 +1007,7 @@ const make = Effect.gen(function* () {...@@ -996,7 +1007,7 @@ const make = Effect.gen(function* () {
996 const cwd = input.worktreePath;1007 const cwd = input.worktreePath;
997 const attachments = input.attachments ?? [];1008 const attachments = input.attachments ?? [];
998 yield* Effect.gen(function* () {1009 yield* Effect.gen(function* () {
999 const settings = yield* serverSettingsService.getSettings;1010 const settings = yield* projectSettingsForThread(input.threadId);
1000 const modelSelection =1011 const modelSelection =
1001 settings.sourceControlWriterModelSelection === null1012 settings.sourceControlWriterModelSelection === null
1002 ? settings.textGenerationModelSelection1013 ? settings.textGenerationModelSelection
...@@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () {...@@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () {
1047 }) {1058 }) {
1048 const attachments = input.attachments ?? [];1059 const attachments = input.attachments ?? [];
1049 yield* Effect.gen(function* () {1060 yield* Effect.gen(function* () {
1050 const { textGenerationModelSelection: modelSelection } =1061 const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread(
1051 yield* serverSettingsService.getSettings;1062 input.threadId,
1063 );
10521064
1053 const generated = yield* textGeneration1065 const generated = yield* textGeneration
1054 .generateThreadTitle({1066 .generateThreadTitle({
...@@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () {...@@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () {
1117 thread,1129 thread,
1118 projects: project ? [project] : [],1130 projects: project ? [project] : [],
1119 }) ?? process.cwd();1131 }) ?? process.cwd();
1120 const { textGenerationModelSelection: modelSelection } =1132 const { textGenerationModelSelection: modelSelection } = resolveProjectSettings(
1121 yield* serverSettingsService.getSettings;1133 yield* serverSettingsService.getSettings,
1134 thread.projectId,
1135 ).settings;
1122 const generated = yield* textGeneration.generateThreadTitle({1136 const generated = yield* textGeneration.generateThreadTitle({
1123 cwd,1137 cwd,
1124 message,1138 message,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9-2
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ import {
51import { projectActivityPayload } from "../ActivityPayloadProjection.ts";51import { projectActivityPayload } from "../ActivityPayloadProjection.ts";
52import { forkParked } from "../../serverActivation.ts";52import { forkParked } from "../../serverActivation.ts";
53import { ServerSettingsService } from "../../serverSettings.ts";53import { ServerSettingsService } from "../../serverSettings.ts";
54import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
54import { canReplaceThreadTitle } from "../threadTitles.ts";55import { canReplaceThreadTitle } from "../threadTitles.ts";
5556
56const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;57const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
...@@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () {...@@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () {
16681669
1669 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(1670 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1670 serverSettingsService.getSettings,1671 serverSettingsService.getSettings,
1671 (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),1672 (settings) =>
1673 resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1674 ? "streaming"
1675 : "buffered",
1672 );1676 );
1673 if (assistantDeliveryMode === "buffered") {1677 if (assistantDeliveryMode === "buffered") {
1674 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);1678 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
...@@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () {...@@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () {
1709 });1713 });
1710 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(1714 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
1711 serverSettingsService.getSettings,1715 serverSettingsService.getSettings,
1712 (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),1716 (settings) =>
1717 resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1718 ? "streaming"
1719 : "buffered",
1713 );1720 );
1714 const flushedMessageIds =1721 const flushedMessageIds =
1715 assistantDeliveryMode === "buffered"1722 assistantDeliveryMode === "buffered"
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+1-1
...@@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape {...@@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape {
209 readonly getThreadRuntimeContext: (209 readonly getThreadRuntimeContext: (
210 threadId: ThreadId,210 threadId: ThreadId,
211 ) => Effect.Effect<211 ) => Effect.Effect<
212 Option.Option<Pick<OrchestrationThreadShell, "id" | "title" | "session">>,212 Option.Option<Pick<OrchestrationThreadShell, "id" | "projectId" | "title" | "session">>,
213 ProjectionRepositoryError213 ProjectionRepositoryError
214 >;214 >;
215215
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+82
...@@ -302,6 +302,35 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (...@@ -302,6 +302,35 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
302});302});
303303
304describe("ThreadSettlementReactor", () => {304describe("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
305 it.effect(334 it.effect(
306 "settles all-terminal links from snapshots and keeps open or unsynced links active",335 "settles all-terminal links from snapshots and keeps open or unsynced links active",
307 () =>336 () =>
...@@ -486,6 +515,59 @@ describe("ThreadSettlementReactor", () => {...@@ -486,6 +515,59 @@ describe("ThreadSettlementReactor", () => {
486 ),515 ),
487 );516 );
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
489 it.effect("starts without clients and skips protected threads before pull request lookup", () =>571 it.effect("starts without clients and skips protected threads before pull request lookup", () =>
490 Effect.scoped(572 Effect.scoped(
491 Effect.gen(function* () {573 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+50-11
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { CommandId } from "@t3tools/contracts";1import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts";
2import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";3import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";5import * as Context from "effect/Context";
...@@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service<...@@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service<
32 }33 }
33>()("t3/orchestration/ThreadSettlementReactor") {}34>()("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
35/** @public Service construction is part of the canonical Effect module API. */75/** @public Service construction is part of the canonical Effect module API. */
36export const make = Effect.gen(function* () {76export const make = Effect.gen(function* () {
37 const engine = yield* OrchestrationEngine.OrchestrationEngineService;77 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
...@@ -46,7 +86,7 @@ export const make = Effect.gen(function* () {...@@ -46,7 +86,7 @@ export const make = Effect.gen(function* () {
46 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,86 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
47 ) {87 ) {
48 const settings = yield* settingsService.getSettings;88 const settings = yield* settingsService.getSettings;
49 if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) {89 if (!autoSettlementConfigured(settings)) {
50 return;90 return;
51 }91 }
52 const snapshot = yield* snapshots.getShellSnapshot();92 const snapshot = yield* snapshots.getShellSnapshot();
...@@ -60,7 +100,10 @@ export const make = Effect.gen(function* () {...@@ -60,7 +100,10 @@ export const make = Effect.gen(function* () {
60 // dispatch skips it for this snapshot instead of retrying through a lookup.100 // dispatch skips it for this snapshot instead of retrying through a lookup.
61 const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")(101 const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")(
62 function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {102 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;
64 const decisionNow = DateTime.formatIso(yield* DateTime.now);107 const decisionNow = DateTime.formatIso(yield* DateTime.now);
65 const settledAt = resolveAutoSettlementAt({108 const settledAt = resolveAutoSettlementAt({
66 thread,109 thread,
...@@ -254,8 +297,7 @@ export const make = Effect.gen(function* () {...@@ -254,8 +297,7 @@ export const make = Effect.gen(function* () {
254 const settingsChanges = yield* settingsService.subscribeChanges;297 const settingsChanges = yield* settingsService.subscribeChanges;
255 const mergedPullRequests = yield* pullRequests.subscribeMerges;298 const mergedPullRequests = yield* pullRequests.subscribeMerges;
256 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);299 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
257 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;300 let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);
258 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
259 yield* forkParked(301 yield* forkParked(
260 Effect.gen(function* () {302 Effect.gen(function* () {
261 yield* worker.enqueue(undefined);303 yield* worker.enqueue(undefined);
...@@ -264,14 +306,11 @@ export const make = Effect.gen(function* () {...@@ -264,14 +306,11 @@ export const make = Effect.gen(function* () {
264 );306 );
265 yield* forkParked(307 yield* forkParked(
266 Stream.runForEach(settingsChanges, (settings) => {308 Stream.runForEach(settingsChanges, (settings) => {
267 if (309 const key = autoSettlementSettingsKey(settings);
268 settings.sidebarAutoSettleAfterDays === lastAfterDays &&310 if (key === lastSettlementSettings) {
269 settings.sidebarAutoSettleOnMerge === lastOnMerge
270 ) {
271 return Effect.void;311 return Effect.void;
272 }312 }
273 lastAfterDays = settings.sidebarAutoSettleAfterDays;313 lastSettlementSettings = key;
274 lastOnMerge = settings.sidebarAutoSettleOnMerge;
275 return worker.enqueue(undefined);314 return worker.enqueue(undefined);
276 }),315 }),
277 );316 );
apps/server/src/provider/Layers/ProviderService.test.ts+43-4
...@@ -4802,7 +4802,8 @@ describe("agent browser access", () => {...@@ -4802,7 +4802,8 @@ describe("agent browser access", () => {
4802 const startSessionWith = (4802 const startSessionWith = (
4803 access: boolean | { readonly browser: boolean; readonly device: boolean },4803 access: boolean | { readonly browser: boolean; readonly device: boolean },
4804 threadId: ThreadId,4804 threadId: ThreadId,
4805 projectOverride?: boolean,4805 projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean },
4806 options?: { readonly withoutOrchestration?: boolean },
4806 ) =>4807 ) =>
4807 Effect.gen(function* () {4808 Effect.gen(function* () {
4808 const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser;4809 const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser;
...@@ -4875,13 +4876,26 @@ describe("agent browser access", () => {...@@ -4875,13 +4876,26 @@ describe("agent browser access", () => {
4875 }).pipe(4876 }).pipe(
4876 Layer.provide(providerAdapterLayer),4877 Layer.provide(providerAdapterLayer),
4877 Layer.provide(directoryLayer),4878 Layer.provide(directoryLayer),
4878 Layer.provide(projectionLayer),4879 Layer.provide(options?.withoutOrchestration ? Layer.empty : projectionLayer),
4879 Layer.provide(4880 Layer.provide(
4880 ServerSettings.ServerSettingsService.layerTest({4881 ServerSettings.ServerSettingsService.layerTest({
4881 enableAgentBrowserAccess,4882 enableAgentBrowserAccess,
4882 enableAgentDeviceAccess,4883 enableAgentDeviceAccess,
4883 projectAgentBrowserAccessOverrides:4884 projectSettingsOverrides:
4884 projectOverride === undefined ? {} : { [projectId]: projectOverride },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 },
4885 }),4899 }),
4886 ),4900 ),
4887 Layer.provide(serverConfigTestLayer),4901 Layer.provide(serverConfigTestLayer),
...@@ -4965,4 +4979,29 @@ describe("agent browser access", () => {...@@ -4965,4 +4979,29 @@ describe("agent browser access", () => {
4965 assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]);4979 assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]);
4966 }).pipe(Effect.provide(NodeServices.layer)),4980 }).pipe(Effect.provide(NodeServices.layer)),
4967 );4981 );
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 );
4968});5007});
apps/server/src/provider/Layers/ProviderService.ts+65-31
...@@ -28,16 +28,18 @@ import {...@@ -28,16 +28,18 @@ import {
28 ProviderUploadFeedbackInput,28 ProviderUploadFeedbackInput,
29 ThreadId,29 ThreadId,
30 TurnId,30 TurnId,
31 type ProjectId,
31 type ProviderInstanceId,32 type ProviderInstanceId,
32 type ProviderDriverKind,33 type ProviderDriverKind,
33 type ProviderRuntimeEvent,34 type ProviderRuntimeEvent,
34 type ProviderSession,35 type ProviderSession,
36 type ServerSettings as ServerSettingsValue,
35} from "@t3tools/contracts";37} from "@t3tools/contracts";
36import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";38import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
37import { HostProcessPlatform } from "@t3tools/shared/hostProcess";39import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
38import { causeErrorTag } from "@t3tools/shared/observability";40import { causeErrorTag } from "@t3tools/shared/observability";
39import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";41import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
40import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";42import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
41import * as DateTime from "effect/DateTime";43import * as DateTime from "effect/DateTime";
42import * as Deferred from "effect/Deferred";44import * as Deferred from "effect/Deferred";
43import * as Effect from "effect/Effect";45import * as Effect from "effect/Effect";
...@@ -864,34 +866,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -864,34 +866,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
864 * "off" silently becoming "on" would violate the user's stated choice,866 * "off" silently becoming "on" would violate the user's stated choice,
865 * whereas the reverse costs an agent one toolset and is visible immediately.867 * whereas the reverse costs an agent one toolset and is visible immediately.
866 */868 */
867 const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")(869 const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")(
868 function* (threadId: ThreadId) {870 function* (threadId: ThreadId) {
869 const settings = yield* serverSettings.getSettings;871 const settings = yield* serverSettings.getSettings;
870 if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) {872 const entries = Object.values(settings.projectSettingsOverrides);
871 return settings.enableAgentBrowserAccess;873 const browserOverridden = entries.some(
872 }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;
873 // Provider-only runtimes may omit orchestration. An unresolved project882 // Provider-only runtimes may omit orchestration. An unresolved project
874 // must not bypass an explicit browser override.883 // must not bypass an explicit project override, but a capability no
875 if (Option.isNone(projectionQuery)) return false;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;
876 const thread = yield* projectionQuery.value.getThreadShellById(threadId);890 const thread = yield* projectionQuery.value.getThreadShellById(threadId);
877 if (Option.isNone(thread)) return false;891 if (Option.isNone(thread)) return denied;
878 return resolveProjectAgentBrowserAccess(settings, thread.value.projectId);892 const resolved = resolveProjectSettings(settings, thread.value.projectId).settings;
893 return {
894 browser: resolved.enableAgentBrowserAccess,
895 device: resolved.enableAgentDeviceAccess,
896 };
879 },897 },
880 Effect.catch((cause) =>898 Effect.catch((cause) =>
881 Effect.logWarning(899 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.",
883 { cause },901 { cause },
884 ).pipe(Effect.as(false)),902 ).pipe(Effect.as({ browser: false, device: 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)),
895 ),903 ),
896 );904 );
897905
...@@ -899,8 +907,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -899,8 +907,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
899 threadId: ThreadId,907 threadId: ThreadId,
900 ) {908 ) {
901 const capabilities = new Set<McpInvocationContext.McpCapability>(["pull-requests"]);909 const capabilities = new Set<McpInvocationContext.McpCapability>(["pull-requests"]);
902 if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview");910 const access = yield* agentAccessSettings(threadId);
903 if (yield* agentDeviceAccessEnabled) capabilities.add("device");911 if (access.browser) capabilities.add("preview");
912 if (access.device) capabilities.add("device");
904 return capabilities;913 return capabilities;
905 });914 });
906915
...@@ -2224,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -2224,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
2224 );2233 );
22252234
2226 const runStopAll = Effect.fn("runStopAll")(function* () {2235 const runStopAll = Effect.fn("runStopAll")(function* () {
2227 const continueAfterRestart = yield* serverSettings.getSettings.pipe(2236 // Continuation is project-scopable, so decide it per session's project;
2228 Effect.map((settings) => settings.continueThreadsAfterServerUpdate),2237 // without orchestration the environment value is all there is.
2229 Effect.orElseSucceed(() => false),2238 const stopSettings = yield* serverSettings.getSettings.pipe(
2239 Effect.map(Option.some),
2240 Effect.orElseSucceed(() => Option.none<ServerSettingsValue>()),
2230 );2241 );
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 });
2231 const properties = yield* Ref.modify(turnAnalytics, (state) => {2260 const properties = yield* Ref.modify(turnAnalytics, (state) => {
2232 const completed: Array<Readonly<Record<string, unknown>>> = [];2261 const completed: Array<Readonly<Record<string, unknown>>> = [];
2233 for (const [sessionKey, session] of state.sessions) {2262 for (const [sessionKey, session] of state.sessions) {
...@@ -2253,15 +2282,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -2253,15 +2282,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
2253 ),2282 ),
2254 ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)));2283 ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions)));
2255 yield* Effect.forEach(activeSessions, (session) =>2284 yield* Effect.forEach(activeSessions, (session) =>
2256 Effect.flatMap(nowIso, (lastRuntimeEventAt) =>2285 Effect.gen(function* () {
2257 upsertSessionBinding(session, session.threadId, {2286 const continueAfterRestart =
2258 ...(continueAfterRestart && session.status === "running" && session.activeTurnId2287 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
2259 ? { continueAfterServerUpdate: session.activeTurnId }2293 ? { continueAfterServerUpdate: session.activeTurnId }
2260 : {}),2294 : {}),
2261 lastRuntimeEvent: "provider.stopAll",2295 lastRuntimeEvent: "provider.stopAll",
2262 lastRuntimeEventAt,2296 lastRuntimeEventAt,
2263 }),2297 });
2264 ),2298 }),
2265 ).pipe(Effect.asVoid);2299 ).pipe(Effect.asVoid);
2266 yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid);2300 yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid);
2267 yield* McpSessionRegistry.revokeAllActiveMcpCredentials();2301 yield* McpSessionRegistry.revokeAllActiveMcpCredentials();
apps/server/src/serverRuntimeStartup.test.ts+51-19
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * 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";
3import { assert, it } from "@effect/vitest";9import { assert, it } from "@effect/vitest";
4import * as Crypto from "effect/Crypto";10import * as Crypto from "effect/Crypto";
5import * as Deferred from "effect/Deferred";11import * as Deferred from "effect/Deferred";
...@@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che...@@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che
40 };46 };
41 }),47 }),
42 } as unknown as GitVcsDriver.GitVcsDriver["Service"];48 } as unknown as GitVcsDriver.GitVcsDriver["Service"];
43 const project = (workspaceRoot: string, autoPull = true) =>49 const project = (workspaceRoot: string) =>
44 ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never;50 ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never;
4551 const overrides = (entries: Record<string, boolean>) => ({
46 yield* ServerRuntimeStartup.autoPullProjects([52 ...DEFAULT_SERVER_SETTINGS,
47 project("/clean"),53 projectSettingsOverrides: Object.fromEntries(
48 project("/current"),54 Object.entries(entries).map(([root, defaultAutoPull]) => [
49 project("/dirty"),55 ProjectId.make(root),
50 project("/ahead"),56 { defaultAutoPull },
51 project("/feature"),57 ]),
52 project("/disabled", false),58 ),
53 ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));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
55 assert.deepStrictEqual(pulled, ["/clean"]);80 assert.deepStrictEqual(pulled, ["/clean"]);
5681
57 pulled.length = 0;82 pulled.length = 0;
58 yield* ServerRuntimeStartup.autoPullProjects(83 yield* ServerRuntimeStartup.autoPullProjects(
59 [project("/inherited", false), project("/opted-out"), project("/dirty", false)],84 [project("/inherited"), project("/opted-out"), project("/dirty")],
60 {85 { ...overrides({ "/opted-out": false }), defaultAutoPull: true },
61 defaultAutoPull: true,
62 projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false },
63 },
64 ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));86 ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
65 assert.deepStrictEqual(pulled, ["/inherited"]);87 assert.deepStrictEqual(pulled, ["/inherited"]);
66 }),88 }),
...@@ -223,7 +245,17 @@ it.effect.each([...@@ -223,7 +245,17 @@ it.effect.each([
223 }>245 }>
224 >([]);246 >([]);
225 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(247 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 ),
227 Effect.provideService(ServerConfig.ServerConfig, {259 Effect.provideService(ServerConfig.ServerConfig, {
228 cwd: "/tmp/startup-project",260 cwd: "/tmp/startup-project",
229 autoBootstrapProjectFromCwd: true,261 autoBootstrapProjectFromCwd: true,
...@@ -244,7 +276,7 @@ it.effect.each([...@@ -244,7 +276,7 @@ it.effect.each([
244 id: ProjectId.make("existing-project"),276 id: ProjectId.make("existing-project"),
245 title: "Startup Project",277 title: "Startup Project",
246 workspaceRoot: "/tmp/startup-project",278 workspaceRoot: "/tmp/startup-project",
247 defaultModelSelection: projectSelection,279 defaultModelSelection: null,
248 scripts: [],280 scripts: [],
249 createdAt: "2026-01-01T00:00:00.000Z",281 createdAt: "2026-01-01T00:00:00.000Z",
250 updatedAt: "2026-01-01T00:00:00.000Z",282 updatedAt: "2026-01-01T00:00:00.000Z",
apps/server/src/serverRuntimeStartup.ts+15-11
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 DEFAULT_MODEL,3 DEFAULT_MODEL,
4 DEFAULT_PROVIDER_INTERACTION_MODE,4 DEFAULT_PROVIDER_INTERACTION_MODE,
5 DEFAULT_SERVER_SETTINGS,5 DEFAULT_SERVER_SETTINGS,
6 type ServerSettings as ServerSettingsValue,
6 type ModelSelection,7 type ModelSelection,
7 type OrchestrationProjectShell,8 type OrchestrationProjectShell,
8 ProjectId,9 ProjectId,
...@@ -10,7 +11,7 @@ import {...@@ -10,7 +11,7 @@ import {
10 ThreadId,11 ThreadId,
11 TurnId,12 TurnId,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";14import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
14import * as Cause from "effect/Cause";15import * as Cause from "effect/Cause";
15import * as Console from "effect/Console";16import * as Console from "effect/Console";
16import * as Context from "effect/Context";17import * as Context from "effect/Context";
...@@ -229,7 +230,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -229,7 +230,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
229 nextProjectId = existingProject.value.id;230 nextProjectId = existingProject.value.id;
230 bootstrapProjectId = nextProjectId;231 bootstrapProjectId = nextProjectId;
231 nextThreadModelSelection =232 nextThreadModelSelection =
232 existingProject.value.defaultModelSelection ?? defaultModelSelection;233 resolveProjectSettings(settings, nextProjectId, existingProject.value).settings
234 .defaultModelSelection ?? defaultModelSelection;
233 }235 }
234236
235 yield* Effect.gen(function* () {237 yield* Effect.gen(function* () {
...@@ -479,14 +481,19 @@ export const reconcileProviderSessions = Effect.gen(function* () {...@@ -479,14 +481,19 @@ export const reconcileProviderSessions = Effect.gen(function* () {
479 const providerService = yield* ProviderService.ProviderService;481 const providerService = yield* ProviderService.ProviderService;
480 const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;482 const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
481 const settings = yield* ServerSettings.ServerSettingsService;483 const settings = yield* ServerSettings.ServerSettingsService;
482 const continueAfterRestart = yield* settings.getSettings.pipe(484 const restartSettings = yield* settings.getSettings.pipe(
483 Effect.map((value) => value.continueThreadsAfterServerUpdate),485 Effect.map(Option.some),
484 Effect.catch((cause) =>486 Effect.catch((cause) =>
485 Effect.logWarning("could not read restart continuation preference", { cause }).pipe(487 Effect.logWarning("could not read restart continuation preference", { cause }).pipe(
486 Effect.as(false),488 Effect.as(Option.none()),
487 ),489 ),
488 ),490 ),
489 );491 );
492 const continueAfterRestartFor = (projectId: ProjectId) =>
493 Option.isSome(restartSettings)
494 ? resolveProjectSettings(restartSettings.value, projectId).settings
495 .continueThreadsAfterServerUpdate
496 : false;
490497
491 const liveThreadIds = new Set(498 const liveThreadIds = new Set(
492 (yield* providerService.listSessions()).map((session) => session.threadId),499 (yield* providerService.listSessions()).map((session) => session.threadId),
...@@ -568,7 +575,7 @@ export const reconcileProviderSessions = Effect.gen(function* () {...@@ -568,7 +575,7 @@ export const reconcileProviderSessions = Effect.gen(function* () {
568 // Runtime events advance the projection's turn, but not the directory's575 // Runtime events advance the projection's turn, but not the directory's
569 // last admitted turn. Use the projection to identify interrupted work.576 // last admitted turn. Use the projection to identify interrupted work.
570 const interruptedByRestart =577 const interruptedByRestart =
571 continueAfterRestart &&578 continueAfterRestartFor(thread.projectId) &&
572 session.status === "running" &&579 session.status === "running" &&
573 session.activeTurnId !== null &&580 session.activeTurnId !== null &&
574 Option.isSome(binding) &&581 Option.isSome(binding) &&
...@@ -742,16 +749,13 @@ interface StartupOptions {...@@ -742,16 +749,13 @@ interface StartupOptions {
742749
743export const autoPullProjects = Effect.fn("autoPullProjects")(function* (750export const autoPullProjects = Effect.fn("autoPullProjects")(function* (
744 projects: ReadonlyArray<OrchestrationProjectShell>,751 projects: ReadonlyArray<OrchestrationProjectShell>,
745 settings: Pick<752 settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS,
746 typeof DEFAULT_SERVER_SETTINGS,
747 "defaultAutoPull" | "projectAutoPullOverrides"
748 > = DEFAULT_SERVER_SETTINGS,
749) {753) {
750 const git = yield* GitVcsDriver.GitVcsDriver;754 const git = yield* GitVcsDriver.GitVcsDriver;
751 const workspaceRoots = [755 const workspaceRoots = [
752 ...new Set(756 ...new Set(
753 projects757 projects
754 .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull))758 .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull)
755 .map((project) => project.workspaceRoot),759 .map((project) => project.workspaceRoot),
756 ),760 ),
757 ];761 ];
apps/server/src/serverSettings.test.ts+112
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {2import {
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 ModelSelection,
5 ProjectId,
6 ProjectScript,
4 ProviderDriverKind,7 ProviderDriverKind,
5 ProviderInstanceId,8 ProviderInstanceId,
6 resolveProviderInstanceEnabled,9 resolveProviderInstanceEnabled,
...@@ -1279,4 +1282,113 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1279,4 +1282,113 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1279 assert.include(persisted, '"valueRedacted": true');1282 assert.include(persisted, '"valueRedacted": true');
1280 }).pipe(Effect.provide(makeServerSettingsLayer())),1283 }).pipe(Effect.provide(makeServerSettingsLayer())),
1281 );1284 );
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 );
1282});1394});
apps/server/src/serverSettings.ts+152-26
...@@ -15,7 +15,9 @@ import {...@@ -15,7 +15,9 @@ import {
15 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,15 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
16 DEFAULT_MODEL_BY_PROVIDER,16 DEFAULT_MODEL_BY_PROVIDER,
17 DEFAULT_SERVER_SETTINGS,17 DEFAULT_SERVER_SETTINGS,
18 type ModelSelection,18 ModelSelection,
19 ProjectScript,
20 type ProjectSettingsOverrides,
19 type ProviderInstanceConfig,21 type ProviderInstanceConfig,
20 type ProviderInstanceEnvironmentVariable,22 type ProviderInstanceEnvironmentVariable,
21 type UsageLimitSourceConfig,23 type UsageLimitSourceConfig,
...@@ -51,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";...@@ -51,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
51import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";53import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
52import {54import {
53 applyServerSettingsPatch,55 applyServerSettingsPatch,
56 deriveLegacyProjectOverrides,
54 isModelSelectionProviderEnabled,57 isModelSelectionProviderEnabled,
55} from "@t3tools/shared/serverSettings";58} from "@t3tools/shared/serverSettings";
56import * as ServerSecretStore from "./auth/ServerSecretStore.ts";59import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
...@@ -118,6 +121,7 @@ const normalizeServerSettings = (...@@ -118,6 +121,7 @@ const normalizeServerSettings = (
118 encodeServerSettings(settings).pipe(121 encodeServerSettings(settings).pipe(
119 Effect.flatMap(decodeServerSettings),122 Effect.flatMap(decodeServerSettings),
120 Effect.map(foldProviderInstanceEnabledFlags),123 Effect.map(foldProviderInstanceEnabledFlags),
124 Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })),
121 Effect.mapError(125 Effect.mapError(
122 (cause) =>126 (cause) =>
123 new ServerSettingsError({127 new ServerSettingsError({
...@@ -353,6 +357,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([...@@ -353,6 +357,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
353 "providerHealthRefreshInterval",357 "providerHealthRefreshInterval",
354 "sourceControlWriterModelSelection",358 "sourceControlWriterModelSelection",
355 "textGenerationModelSelection",359 "textGenerationModelSelection",
360 "pullRequestMergeMethod",
356]);361]);
357362
358// Preserve both enabled states because provider history cannot recover a new opt-in.363// Preserve both enabled states because provider history cannot recover a new opt-in.
...@@ -400,6 +405,93 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow...@@ -400,6 +405,93 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow
400 return Object.is(current, defaults) ? undefined : current;405 return Object.is(current, defaults) ? undefined : current;
401}406}
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
403const make = Effect.gen(function* () {495const make = Effect.gen(function* () {
404 const { settingsPath } = yield* ServerConfig.ServerConfig;496 const { settingsPath } = yield* ServerConfig.ServerConfig;
405 const fs = yield* FileSystem.FileSystem;497 const fs = yield* FileSystem.FileSystem;
...@@ -439,9 +531,36 @@ const make = Effect.gen(function* () {...@@ -439,9 +531,36 @@ const make = Effect.gen(function* () {
439 ),531 ),
440 );532 );
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
442 const loadSettingsFromDisk = Effect.gen(function* () {558 const loadSettingsFromDisk = Effect.gen(function* () {
443 let settings = DEFAULT_SERVER_SETTINGS;559 let settings = DEFAULT_SERVER_SETTINGS;
444 let persisted: typeof PersistedOptionalProviderSettings.Type = {};560 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
446 if (yield* readConfigExists) {565 if (yield* readConfigExists) {
447 const raw = yield* readRawConfig;566 const raw = yield* readRawConfig;
...@@ -452,6 +571,7 @@ const make = Effect.gen(function* () {...@@ -452,6 +571,7 @@ const make = Effect.gen(function* () {
452 }571 }
453 if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {572 if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {
454 const failure = decoded._tag === "Failure" ? decoded : persistedSettings;573 const failure = decoded._tag === "Failure" ? decoded : persistedSettings;
574 settingsFileTrusted = false;
455 if (failure._tag === "Failure") {575 if (failure._tag === "Failure") {
456 yield* Effect.logWarning("failed to parse settings.json, using defaults", {576 yield* Effect.logWarning("failed to parse settings.json, using defaults", {
457 path: settingsPath,577 path: settingsPath,
...@@ -490,9 +610,39 @@ const make = Effect.gen(function* () {...@@ -490,9 +610,39 @@ const make = Effect.gen(function* () {
490 ),610 ),
491 );611 );
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(
494 restoreUsedProviders(settings, persisted, providerHistory),637 restoreUsedProviders(settings, persisted, providerHistory),
495 );638 );
639 const folded = settingsFileTrusted
640 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
641 : loaded;
642 if (folded !== loaded) {
643 yield* writeSettingsAtomically(folded);
644 }
645 return folded;
496 });646 });
497647
498 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({648 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
...@@ -738,30 +888,6 @@ const make = Effect.gen(function* () {...@@ -738,30 +888,6 @@ const make = Effect.gen(function* () {
738 };888 };
739 });889 });
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
765 const revalidateAndEmit = writeSemaphore.withPermits(1)(891 const revalidateAndEmit = writeSemaphore.withPermits(1)(
766 Effect.gen(function* () {892 Effect.gen(function* () {
767 yield* Cache.invalidate(settingsCache, cacheKey);893 yield* Cache.invalidate(settingsCache, cacheKey);
apps/server/src/vcs/VcsStatusBroadcaster.ts+2-2
...@@ -22,7 +22,7 @@ import type {...@@ -22,7 +22,7 @@ import type {
22 VcsStatusStreamEvent,22 VcsStatusStreamEvent,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import { mergeGitStatusParts } from "@t3tools/shared/git";24import { mergeGitStatusParts } from "@t3tools/shared/git";
25import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";25import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
2626
27import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";27import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
28import * as GitWorkflowService from "../git/GitWorkflowService.ts";28import * as GitWorkflowService from "../git/GitWorkflowService.ts";
...@@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect(...@@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect(
160 const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd);160 const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd);
161 if (project._tag === "None") return false;161 if (project._tag === "None") return false;
162 const settings = yield* serverSettings.getSettings;162 const settings = yield* serverSettings.getSettings;
163 return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull);163 return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull;
164 },164 },
165 Effect.orElseSucceed(() => false),165 Effect.orElseSucceed(() => false),
166 ),166 ),
packages/client-runtime/src/state/sharedSettings.test.ts+12
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,3 EnvironmentId,
4 ProjectId,
4 ProviderDriverKind,5 ProviderDriverKind,
5 ProviderInstanceId,6 ProviderInstanceId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -43,6 +44,17 @@ describe("supportsSharedSettingsSync", () => {...@@ -43,6 +44,17 @@ describe("supportsSharedSettingsSync", () => {
43});44});
4445
45describe("splitSharedServerPatch", () => {46describe("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
46 it.each([58 it.each([
47 {59 {
48 instanceId: ProviderInstanceId.make("codex"),60 instanceId: ProviderInstanceId.make("codex"),
packages/contracts/src/environment.ts+2
...@@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
99 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),99 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
100 /** Server persists the opt-in for continuing interrupted threads after restarts. */100 /** Server persists the opt-in for continuing interrupted threads after restarts. */
101 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),101 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
102 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
103 projectSettingsOverrides: Schema.optionalKey(Schema.Boolean),
102 /** Server understands thread.snooze / thread.unsnooze commands. Same104 /** Server understands thread.snooze / thread.unsnooze commands. Same
103 version-skew contract as threadSettlement. */105 version-skew contract as threadSettlement. */
104 threadSnooze: Schema.optionalKey(Schema.Boolean),106 threadSnooze: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/settings.ts+84
...@@ -928,6 +928,55 @@ export const BackgroundActivitySettings = Schema.Struct({...@@ -928,6 +928,55 @@ export const BackgroundActivitySettings = Schema.Struct({
928}).pipe(Schema.withDecodingDefault(Effect.succeed({})));928}).pipe(Schema.withDecodingDefault(Effect.succeed({})));
929export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;929export 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
931export const ServerSettings = Schema.Struct({980export const ServerSettings = Schema.Struct({
932 // Legacy token-by-token assistant output. Deliberately a fresh key (was981 // Legacy token-by-token assistant output. Deliberately a fresh key (was
933 // `enableAssistantStreaming`): decoding drops the old key, so everyone,982 // `enableAssistantStreaming`): decoding drops the old key, so everyone,
...@@ -968,6 +1017,21 @@ export const ServerSettings = Schema.Struct({...@@ -968,6 +1017,21 @@ export const ServerSettings = Schema.Struct({
968 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(1017 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
969 Schema.withDecodingDefault(Effect.succeed(null)),1018 Schema.withDecodingDefault(Effect.succeed(null)),
970 ),1019 ),
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))),
971 /**1035 /**
972 * Whether agents may drive simulators and emulators. Gates the `device_*`1036 * Whether agents may drive simulators and emulators. Gates the `device_*`
973 * MCP tools and the preconfigured `agent-device` CLI the same way1037 * MCP tools and the preconfigured `agent-device` CLI the same way
...@@ -1053,6 +1117,14 @@ export const ServerSettings = Schema.Struct({...@@ -1053,6 +1117,14 @@ export const ServerSettings = Schema.Struct({
1053 sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe(1117 sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe(
1054 Schema.withDecodingDefault(Effect.succeed(null)),1118 Schema.withDecodingDefault(Effect.succeed(null)),
1055 ),1119 ),
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
1057 // Legacy single-instance-per-driver settings. Continues to be the source1129 // Legacy single-instance-per-driver settings. Continues to be the source
1058 // of truth until `providerInstances` (below) lands per-driver migration1130 // of truth until `providerInstances` (below) lands per-driver migration
...@@ -1140,6 +1212,7 @@ export const ServerSettingsOperation = Schema.Literals([...@@ -1140,6 +1212,7 @@ export const ServerSettingsOperation = Schema.Literals([
1140 "check-exists",1212 "check-exists",
1141 "read-file",1213 "read-file",
1142 "read-provider-history",1214 "read-provider-history",
1215 "read-project-settings",
1143 "read-secret",1216 "read-secret",
1144 "remove-secret",1217 "remove-secret",
1145 "remove-stale-secret",1218 "remove-stale-secret",
...@@ -1257,6 +1330,16 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1257,6 +1330,16 @@ export const ServerSettingsPatch = Schema.Struct({
1257 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),1330 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
1258 ),1331 ),
1259 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),1332 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 ),
1260 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),1343 enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean),
1261 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),1344 enableDeviceSupport: Schema.optionalKey(Schema.Boolean),
1262 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),1345 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
...@@ -1287,6 +1370,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1287,6 +1370,7 @@ export const ServerSettingsPatch = Schema.Struct({
1287 }),1370 }),
1288 ),1371 ),
1289 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),1372 sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1373 pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
1290 observability: Schema.optionalKey(1374 observability: Schema.optionalKey(
1291 Schema.Struct({1375 Schema.Struct({
1292 otlpTracesUrl: Schema.optionalKey(TrimmedString),1376 otlpTracesUrl: Schema.optionalKey(TrimmedString),
packages/shared/package.json+4
...@@ -103,6 +103,10 @@...@@ -103,6 +103,10 @@
103 "types": "./src/projectScripts.ts",103 "types": "./src/projectScripts.ts",
104 "import": "./src/projectScripts.ts"104 "import": "./src/projectScripts.ts"
105 },105 },
106 "./projectSettings": {
107 "types": "./src/projectSettings.ts",
108 "import": "./src/projectSettings.ts"
109 },
106 "./threadEnvMode": {110 "./threadEnvMode": {
107 "types": "./src/threadEnvMode.ts",111 "types": "./src/threadEnvMode.ts",
108 "import": "./src/threadEnvMode.ts"112 "import": "./src/threadEnvMode.ts"
packages/shared/src/projectScripts.ts+28-10
...@@ -1,23 +1,41 @@...@@ -1,23 +1,41 @@
1import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts";1import 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 */
4export function resolveProjectScripts(17export function resolveProjectScripts(
5 settings: Pick<ServerSettings, "defaultProjectScripts" | "projectScriptOverrides">,18 settings: ProjectScriptSettings,
6 project: { id: ProjectId; scripts: readonly ProjectScript[] },19 project: { id: ProjectId; scripts: readonly ProjectScript[] },
7): readonly ProjectScript[] {20): readonly ProjectScript[] {
8 const override = settings.projectScriptOverrides[project.id];21 const override = settings.projectSettingsOverrides[project.id]?.defaultProjectScripts;
9 if (override === null) return settings.defaultProjectScripts;22 if (override !== undefined) return override;
10 return (23 if (settings.projectSettingsFolded) return settings.defaultProjectScripts;
11 override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts)24 const legacy = settings.projectScriptOverrides[project.id];
12 );25 if (legacy === null) return settings.defaultProjectScripts;
26 return legacy ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts);
13}27}
1428
15export function projectScriptsInheritDefaults(29export function projectScriptsInheritDefaults(
16 settings: Pick<ServerSettings, "projectScriptOverrides">,30 settings: ProjectScriptSettings,
17 project: { id: ProjectId; scripts: readonly ProjectScript[] },31 project: { id: ProjectId; scripts: readonly ProjectScript[] },
18): boolean {32): boolean {
19 const override = settings.projectScriptOverrides[project.id];33 if (settings.projectSettingsOverrides[project.id]?.defaultProjectScripts !== undefined) {
20 return override === null || (override === undefined && project.scripts.length === 0);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);
21}39}
2240
23interface ProjectScriptRuntimeEnvInput {41interface 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 {...@@ -20,6 +20,9 @@ import {
20 resolveProjectAutoPull,20 resolveProjectAutoPull,
21} from "./serverSettings.ts";21} 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
23describe("serverSettings helpers", () => {26describe("serverSettings helpers", () => {
24 it("replaces SSH host lists when saving, editing, and removing hosts", () => {27 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
25 const host = { id: "mini", label: "Mac mini", target: "mini" };28 const host = { id: "mini", label: "Mac mini", target: "mini" };
...@@ -40,14 +43,19 @@ describe("serverSettings helpers", () => {...@@ -40,14 +43,19 @@ describe("serverSettings helpers", () => {
40 icon: "play" as const,43 icon: "play" as const,
41 runOnWorktreeCreate: false,44 runOnWorktreeCreate: false,
42 };45 };
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, {
44 defaultProjectScripts: [action],54 defaultProjectScripts: [action],
45 });55 });
46 expect(resolveProjectScripts(defaults, project)).toEqual([action]);56 expect(resolveProjectScripts(defaults, project)).toEqual([action]);
47 expect(projectScriptsInheritDefaults(defaults, project)).toBe(true);57 expect(projectScriptsInheritDefaults(defaults, project)).toBe(true);
48 const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };58 expect(resolveProjectScripts(defaults, existing)).toEqual([action]);
49 expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts);
50 expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false);
51 const disabled = applyServerSettingsPatch(defaults, {59 const disabled = applyServerSettingsPatch(defaults, {
52 projectScriptOverrides: { [project.id]: [] },60 projectScriptOverrides: { [project.id]: [] },
53 });61 });
...@@ -82,7 +90,7 @@ describe("serverSettings helpers", () => {...@@ -82,7 +90,7 @@ describe("serverSettings helpers", () => {
82 };90 };
83 const firstAction = { ...defaultAction, command: "npm run lint" };91 const firstAction = { ...defaultAction, command: "npm run lint" };
84 const secondAction = { ...defaultAction, command: "npm run build" };92 const secondAction = { ...defaultAction, command: "npm run build" };
85 const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {93 const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
86 defaultProjectScripts: [defaultAction],94 defaultProjectScripts: [defaultAction],
87 projectScriptOverrides: { [firstProject.id]: [firstAction] },95 projectScriptOverrides: { [firstProject.id]: [firstAction] },
88 });96 });
packages/shared/src/serverSettings.ts+117-26
...@@ -4,6 +4,8 @@ import {...@@ -4,6 +4,8 @@ import {
4 resolveProviderInstanceEnabled,4 resolveProviderInstanceEnabled,
5 type ModelSelection,5 type ModelSelection,
6 type ProjectId,6 type ProjectId,
7 type ProjectScopedServerSettingKey,
8 type ProjectSettingsOverrides,
7 type ProviderDriverKind,9 type ProviderDriverKind,
8 type ServerProvider,10 type ServerProvider,
9 ServerSettings,11 ServerSettings,
...@@ -24,22 +26,33 @@ import {...@@ -24,22 +26,33 @@ import {
24const ServerSettingsJson = fromLenientJson(ServerSettings);26const ServerSettingsJson = fromLenientJson(ServerSettings);
25const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);27const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
2628
29/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */
27export function resolveProjectAgentBrowserAccess(30export function resolveProjectAgentBrowserAccess(
28 settings: Pick<ServerSettings, "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides">,31 settings: Pick<
32 ServerSettings,
33 "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides" | "projectSettingsOverrides"
34 >,
29 projectId: ProjectId,35 projectId: ProjectId,
30): boolean {36): boolean {
31 return (37 return (
32 settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess38 settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ??
39 settings.projectAgentBrowserAccessOverrides[projectId] ??
40 settings.enableAgentBrowserAccess
33 );41 );
34}42}
3543
44/** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */
36export function resolveProjectAutoPull(45export function resolveProjectAutoPull(
37 settings: Pick<ServerSettings, "defaultAutoPull" | "projectAutoPullOverrides">,46 settings: Pick<
47 ServerSettings,
48 "defaultAutoPull" | "projectAutoPullOverrides" | "projectSettingsOverrides"
49 >,
38 projectId: ProjectId,50 projectId: ProjectId,
39 legacyAutoPull: boolean | undefined,51 legacyAutoPull: boolean | undefined,
40): boolean {52): boolean {
41 // Existing opt-ins stay enabled until explicitly overridden or reset.53 // Existing opt-ins stay enabled until explicitly overridden or reset.
42 return (54 return (
55 settings.projectSettingsOverrides[projectId]?.defaultAutoPull ??
43 settings.projectAutoPullOverrides[projectId] ??56 settings.projectAutoPullOverrides[projectId] ??
44 (legacyAutoPull === true || settings.defaultAutoPull)57 (legacyAutoPull === true || settings.defaultAutoPull)
45 );58 );
...@@ -160,10 +173,97 @@ function mergeSettingsEntries<Value>(...@@ -160,10 +173,97 @@ function mergeSettingsEntries<Value>(
160 return Object.fromEntries(next);173 return Object.fromEntries(next);
161}174}
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
163export function applyServerSettingsPatch(262export function applyServerSettingsPatch(
164 current: ServerSettings,263 current: ServerSettings,
165 patch: ServerSettingsPatch,264 rawPatch: ServerSettingsPatch,
166): ServerSettings {265): ServerSettings {
266 const patch = translateLegacyProjectOverridePatch(current, rawPatch);
167 const selectionPatch = patch.textGenerationModelSelection;267 const selectionPatch = patch.textGenerationModelSelection;
168 const {268 const {
169 automaticGitFetchInterval,269 automaticGitFetchInterval,
...@@ -173,8 +273,13 @@ export function applyServerSettingsPatch(...@@ -173,8 +273,13 @@ export function applyServerSettingsPatch(
173 // Merged per entry below; its `null` removals must not reach deepMerge.273 // Merged per entry below; its `null` removals must not reach deepMerge.
174 usageLimitSources: usageLimitSourcesPatch,274 usageLimitSources: usageLimitSourcesPatch,
175 usagePriceOverrides: usagePriceOverridesPatch,275 usagePriceOverrides: usagePriceOverridesPatch,
176 projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,276 // Entry replacement: deepMerge would keep keys the client meant to clear.
177 projectAutoPullOverrides: projectAutoPullOverridesPatch,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,
178 ...patchForMerge283 ...patchForMerge
179 } = patch;284 } = patch;
180 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);285 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
...@@ -231,19 +336,12 @@ export function applyServerSettingsPatch(...@@ -231,19 +336,12 @@ export function applyServerSettingsPatch(
231 ...(patch.providerInstances !== undefined336 ...(patch.providerInstances !== undefined
232 ? { providerInstances: patch.providerInstances }337 ? { providerInstances: patch.providerInstances }
233 : {}),338 : {}),
234 ...(projectAgentBrowserAccessOverridesPatch !== undefined339 ...(projectSettingsOverridesPatch !== undefined
235 ? {340 ? {
236 projectAgentBrowserAccessOverrides: mergeSettingsEntries(341 projectSettingsOverrides: Object.fromEntries(
237 current.projectAgentBrowserAccessOverrides,342 Object.entries(
238 projectAgentBrowserAccessOverridesPatch,343 mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch),
239 ),344 ).filter(([, entry]) => Object.keys(entry).length > 0),
240 }
241 : {}),
242 ...(projectAutoPullOverridesPatch !== undefined
243 ? {
244 projectAutoPullOverrides: mergeSettingsEntries(
245 current.projectAutoPullOverrides,
246 projectAutoPullOverridesPatch,
247 ),345 ),
248 }346 }
249 : {}),347 : {}),
...@@ -253,14 +351,6 @@ export function applyServerSettingsPatch(...@@ -253,14 +351,6 @@ export function applyServerSettingsPatch(
253 ...(patch.defaultProjectScripts !== undefined351 ...(patch.defaultProjectScripts !== undefined
254 ? { defaultProjectScripts: patch.defaultProjectScripts }352 ? { defaultProjectScripts: patch.defaultProjectScripts }
255 : {}),353 : {}),
256 ...(patch.projectScriptOverrides !== undefined
257 ? {
258 projectScriptOverrides: {
259 ...current.projectScriptOverrides,
260 ...patch.projectScriptOverrides,
261 },
262 }
263 : {}),
264 ...(usageLimitSourcesPatch !== undefined354 ...(usageLimitSourcesPatch !== undefined
265 ? {355 ? {
266 usageLimitSources: mergeSettingsEntries(356 usageLimitSources: mergeSettingsEntries(
...@@ -291,6 +381,7 @@ export function applyServerSettingsPatch(...@@ -291,6 +381,7 @@ export function applyServerSettingsPatch(
291 );381 );
292 const nextWithReplacements = {382 const nextWithReplacements = {
293 ...nextWithReplacementsBase,383 ...nextWithReplacementsBase,
384 ...deriveLegacyProjectOverrides(nextWithReplacementsBase),
294 backgroundActivity: normalizedBackgroundActivity,385 backgroundActivity: normalizedBackgroundActivity,
295 automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval,386 automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval,
296 providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval,387 providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval,