Skip to content

Commit fe5d695

Browse files
feat(settings): add per-project overrides for scopable server settings
Project overrides were scattered across the Project aggregate, three ad hoc Record<ProjectId, ...> fields in ServerSettings, and a client-settings record. Every other server setting was environment-only, so a settings hierarchy could not be built on top of them. Introduce projectSettingsOverrides: one record per project holding a partial of PROJECT_SCOPED_SERVER_SETTING_KEYS, and resolveProjectSettings() which returns the effective settings plus the source of each key. Legacy fields are folded into the record once on load and kept populated as derived views for one release. Server consumers that act on a thread or checkout (agent access gates, auto-settlement, title/branch generation, restart continuation, legacy token streaming, auto-pull, setup scripts, cwd bootstrap) now read through the resolver so an override takes effect everywhere the environment value did. Model: Claude Fable 5.1. Harness: Claude Code.
1 parent c52b8d9 commit fe5d695

25 files changed

Lines changed: 975 additions & 144 deletions

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
222222
threadSettlement: true,
223223
threadAutoSettlement: true,
224224
threadRestartContinuation: true,
225+
projectSettingsOverrides: true,
225226
threadSnooze: true,
226227
environmentThemes: true,
227228
usageLimitSources: true,

‎apps/server/src/git/GitManager.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,16 @@ import {
2929
type VcsStatusRemoteResult,
3030
VcsStatusResult,
3131
ModelSelection,
32+
type ProjectId,
3233
SourceControlProviderError,
3334
type SourceControlWritingStyleSettings,
35+
type ThreadId,
3436
} from "@t3tools/contracts";
37+
import {
38+
hasProjectSettingsOverrides,
39+
resolveProjectSettings,
40+
} from "@t3tools/shared/projectSettings";
41+
import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
3542
import {
3643
detectSourceControlProviderFromGitRemoteUrl,
3744
mergeGitStatusParts,
@@ -661,6 +668,28 @@ export const make = Effect.gen(function* () {
661668

662669
const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
663670
const serverSettingsService = yield* ServerSettings.ServerSettingsService;
671+
// Optional: git actions also run from the CLI and tests without orchestration.
672+
const projectionQuery = yield* Effect.serviceOption(
673+
ProjectionSnapshotQuery.ProjectionSnapshotQuery,
674+
);
675+
/** Environment settings with the acting project's overrides applied. */
676+
const projectSettingsFor = Effect.fnUntraced(function* (input: {
677+
readonly cwd: string;
678+
readonly threadId?: ThreadId | undefined;
679+
}) {
680+
const settings = yield* serverSettingsService.getSettings;
681+
if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings;
682+
const projectId = yield* (
683+
input.threadId !== undefined
684+
? projectionQuery.value
685+
.getThreadShellById(input.threadId)
686+
.pipe(Effect.map(Option.map((thread) => thread.projectId)))
687+
: projectionQuery.value
688+
.getActiveProjectByWorkspaceRoot(input.cwd)
689+
.pipe(Effect.map(Option.map((project) => project.id)))
690+
).pipe(Effect.orElseSucceed(() => Option.none<ProjectId>()));
691+
return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings;
692+
});
664693
const readRepositoryInstructions = (cwd: string, fileName: string) =>
665694
Effect.gen(function* () {
666695
const root = yield* fileSystem.realPath(cwd);
@@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () {
26002629
let commitMessageForStep = input.commitMessage;
26012630
let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
26022631

2603-
const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(
2632+
const textGenerationSettings = yield* projectSettingsFor(input).pipe(
26042633
Effect.flatMap((settings) =>
26052634
settings.sourceControlWriterModelSelection === null
26062635
? Effect.succeed({

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
682682
if (context._tag === "Some") {
683683
assert.deepEqual(context.value, {
684684
id: ThreadId.make("thread-1"),
685+
projectId: asProjectId("project-1"),
685686
title: "Thread 1",
686687
session: snapshot.threads[0]?.session,
687688
});

‎apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({
142142
const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
143143
const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
144144
id: ThreadId,
145+
projectId: ProjectId,
145146
title: Schema.String,
146147
session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
147148
});
@@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12311232
sql`
12321233
SELECT
12331234
threads.thread_id AS id,
1235+
threads.project_id AS "projectId",
12341236
threads.title,
12351237
sessions.thread_id AS "threadId",
12361238
sessions.status,
@@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12511253
Effect.map((rows) =>
12521254
rows.map((row) => ({
12531255
id: row.id,
1256+
projectId: row.projectId,
12541257
title: row.title,
12551258
session: row.threadId === null ? null : row,
12561259
})),
@@ -3164,6 +3167,7 @@ pending_approval_requests AS (
31643167
);
31653168
return Option.map(context, (row) => ({
31663169
id: row.id,
3170+
projectId: row.projectId,
31673171
title: row.title,
31683172
session: row.session === null ? null : mapSessionRow(row.session),
31693173
}));

‎apps/server/src/orchestration/Layers/ProviderCommandReactor.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
resolveSourceControlWriterModelSelection,
5454
ServerSettingsService,
5555
} from "../../serverSettings.ts";
56+
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
5657
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
5758
import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
5859
const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
@@ -328,6 +329,16 @@ const make = Effect.gen(function* () {
328329
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
329330
const textGeneration = yield* TextGeneration;
330331
const serverSettingsService = yield* ServerSettingsService;
332+
/** Environment settings with the thread's project overrides applied. */
333+
const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
334+
const settings = yield* serverSettingsService.getSettings;
335+
if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings;
336+
const thread = yield* projectionSnapshotQuery
337+
.getThreadShellById(threadId)
338+
.pipe(Effect.orElseSucceed(() => Option.none()));
339+
return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null)
340+
.settings;
341+
});
331342
const serverCommandId = (tag: string) =>
332343
crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
333344
const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));
@@ -917,7 +928,7 @@ const make = Effect.gen(function* () {
917928
const cwd = input.worktreePath;
918929
const attachments = input.attachments ?? [];
919930
yield* Effect.gen(function* () {
920-
const settings = yield* serverSettingsService.getSettings;
931+
const settings = yield* projectSettingsForThread(input.threadId);
921932
const modelSelection =
922933
settings.sourceControlWriterModelSelection === null
923934
? settings.textGenerationModelSelection
@@ -968,8 +979,9 @@ const make = Effect.gen(function* () {
968979
}) {
969980
const attachments = input.attachments ?? [];
970981
yield* Effect.gen(function* () {
971-
const { textGenerationModelSelection: modelSelection } =
972-
yield* serverSettingsService.getSettings;
982+
const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread(
983+
input.threadId,
984+
);
973985

974986
const generated = yield* textGeneration
975987
.generateThreadTitle({
@@ -1038,8 +1050,10 @@ const make = Effect.gen(function* () {
10381050
thread,
10391051
projects: project ? [project] : [],
10401052
}) ?? process.cwd();
1041-
const { textGenerationModelSelection: modelSelection } =
1042-
yield* serverSettingsService.getSettings;
1053+
const { textGenerationModelSelection: modelSelection } = resolveProjectSettings(
1054+
yield* serverSettingsService.getSettings,
1055+
thread.projectId,
1056+
).settings;
10431057
const generated = yield* textGeneration.generateThreadTitle({
10441058
cwd,
10451059
message,

‎apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
import { projectActivityPayload } from "../ActivityPayloadProjection.ts";
5252
import { forkParked } from "../../serverActivation.ts";
5353
import { ServerSettingsService } from "../../serverSettings.ts";
54+
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
5455
import { canReplaceThreadTitle } from "../threadTitles.ts";
5556

5657
const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
@@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () {
16681669

16691670
const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
16701671
serverSettingsService.getSettings,
1671-
(settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
1672+
(settings) =>
1673+
resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1674+
? "streaming"
1675+
: "buffered",
16721676
);
16731677
if (assistantDeliveryMode === "buffered") {
16741678
const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
@@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () {
17091713
});
17101714
const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
17111715
serverSettingsService.getSettings,
1712-
(settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
1716+
(settings) =>
1717+
resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
1718+
? "streaming"
1719+
: "buffered",
17131720
);
17141721
const flushedMessageIds =
17151722
assistantDeliveryMode === "buffered"

‎apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape {
209209
readonly getThreadRuntimeContext: (
210210
threadId: ThreadId,
211211
) => Effect.Effect<
212-
Option.Option<Pick<OrchestrationThreadShell, "id" | "title" | "session">>,
212+
Option.Option<Pick<OrchestrationThreadShell, "id" | "projectId" | "title" | "session">>,
213213
ProjectionRepositoryError
214214
>;
215215

‎apps/server/src/orchestration/ThreadSettlementReactor.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,59 @@ describe("ThreadSettlementReactor", () => {
486486
),
487487
);
488488

489+
it.effect("a project override settles only that project's inactive threads", () =>
490+
Effect.scoped(
491+
Effect.gen(function* () {
492+
yield* TestClock.setTime(Date.parse(NOW));
493+
const overriddenProject = ProjectId.make("overridden-project");
494+
const fixture = yield* makeHarness({
495+
snapshot: makeSnapshot(
496+
[
497+
makeThread("inherits-thread"),
498+
makeThread("overridden-thread", { projectId: overriddenProject }),
499+
],
500+
[makeProject(), makeProject(overriddenProject, "/workspace/overridden")],
501+
),
502+
settings: {
503+
...DEFAULT_SERVER_SETTINGS,
504+
sidebarAutoSettleAfterDays: null,
505+
sidebarAutoSettleOnMerge: false,
506+
projectSettingsOverrides: {
507+
[overriddenProject]: { sidebarAutoSettleAfterDays: 1 },
508+
},
509+
},
510+
});
511+
512+
yield* Effect.gen(function* () {
513+
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
514+
yield* reactor.start();
515+
yield* Queue.take(fixture.settingsReads);
516+
yield* Deferred.succeed(fixture.activation, undefined);
517+
yield* Queue.take(fixture.snapshotReads);
518+
yield* reactor.drain;
519+
assert.deepStrictEqual(
520+
(yield* Ref.get(fixture.commands)).map((command) => command.threadId),
521+
[ThreadId.make("overridden-thread")],
522+
);
523+
524+
// Clearing the override is a settlement change, so the sweep re-arms.
525+
yield* fixture.updateSettings({
526+
projectSettingsOverrides: { [overriddenProject]: null },
527+
sidebarAutoSettleAfterDays: 1,
528+
});
529+
yield* Queue.take(fixture.snapshotReads);
530+
yield* reactor.drain;
531+
// The static snapshot never records the first settlement, so the
532+
// second sweep dispatches for both; the inheriting thread is new.
533+
assert.include(
534+
(yield* Ref.get(fixture.commands)).map((command) => command.threadId),
535+
ThreadId.make("inherits-thread"),
536+
);
537+
}).pipe(Effect.provide(fixture.layer));
538+
}),
539+
),
540+
);
541+
489542
it.effect("starts without clients and skips protected threads before pull request lookup", () =>
490543
Effect.scoped(
491544
Effect.gen(function* () {

‎apps/server/src/orchestration/ThreadSettlementReactor.ts‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { CommandId } from "@t3tools/contracts";
1+
import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts";
2+
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
23
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
34
import * as Cause from "effect/Cause";
45
import * as Context from "effect/Context";
@@ -33,6 +34,31 @@ export class ThreadSettlementReactor extends Context.Service<
3334
>()("t3/orchestration/ThreadSettlementReactor") {}
3435

3536
/** @public Service construction is part of the canonical Effect module API. */
37+
/** Whether any environment default or project override can settle a thread. */
38+
function 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+
function autoSettlementSettingsKey(settings: ServerSettingsValue): string {
51+
return JSON.stringify([
52+
settings.sidebarAutoSettleOnMerge,
53+
settings.sidebarAutoSettleAfterDays,
54+
Object.entries(settings.projectSettingsOverrides).map(([projectId, entry]) => [
55+
projectId,
56+
entry.sidebarAutoSettleOnMerge,
57+
entry.sidebarAutoSettleAfterDays,
58+
]),
59+
]);
60+
}
61+
3662
export const make = Effect.gen(function* () {
3763
const engine = yield* OrchestrationEngine.OrchestrationEngineService;
3864
const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
@@ -46,7 +72,7 @@ export const make = Effect.gen(function* () {
4672
mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
4773
) {
4874
const settings = yield* settingsService.getSettings;
49-
if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) {
75+
if (!autoSettlementConfigured(settings)) {
5076
return;
5177
}
5278
const snapshot = yield* snapshots.getShellSnapshot();
@@ -60,7 +86,10 @@ export const make = Effect.gen(function* () {
6086
// dispatch skips it for this snapshot instead of retrying through a lookup.
6187
const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")(
6288
function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {
63-
const settings = yield* settingsService.getSettings;
89+
const settings = resolveProjectSettings(
90+
yield* settingsService.getSettings,
91+
thread.projectId,
92+
).settings;
6493
const decisionNow = DateTime.formatIso(yield* DateTime.now);
6594
const settledAt = resolveAutoSettlementAt({
6695
thread,
@@ -254,8 +283,7 @@ export const make = Effect.gen(function* () {
254283
const settingsChanges = yield* settingsService.subscribeChanges;
255284
const mergedPullRequests = yield* pullRequests.subscribeMerges;
256285
const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
257-
let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
258-
let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
286+
let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);
259287
yield* forkParked(
260288
Effect.gen(function* () {
261289
yield* worker.enqueue(undefined);
@@ -264,14 +292,11 @@ export const make = Effect.gen(function* () {
264292
);
265293
yield* forkParked(
266294
Stream.runForEach(settingsChanges, (settings) => {
267-
if (
268-
settings.sidebarAutoSettleAfterDays === lastAfterDays &&
269-
settings.sidebarAutoSettleOnMerge === lastOnMerge
270-
) {
295+
const key = autoSettlementSettingsKey(settings);
296+
if (key === lastSettlementSettings) {
271297
return Effect.void;
272298
}
273-
lastAfterDays = settings.sidebarAutoSettleAfterDays;
274-
lastOnMerge = settings.sidebarAutoSettleOnMerge;
299+
lastSettlementSettings = key;
275300
return worker.enqueue(undefined);
276301
}),
277302
);

‎apps/server/src/provider/Layers/ProviderService.test.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4802,7 +4802,7 @@ describe("agent browser access", () => {
48024802
const startSessionWith = (
48034803
access: boolean | { readonly browser: boolean; readonly device: boolean },
48044804
threadId: ThreadId,
4805-
projectOverride?: boolean,
4805+
projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean },
48064806
) =>
48074807
Effect.gen(function* () {
48084808
const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser;
@@ -4880,8 +4880,21 @@ describe("agent browser access", () => {
48804880
ServerSettings.ServerSettingsService.layerTest({
48814881
enableAgentBrowserAccess,
48824882
enableAgentDeviceAccess,
4883-
projectAgentBrowserAccessOverrides:
4884-
projectOverride === undefined ? {} : { [projectId]: projectOverride },
4883+
projectSettingsOverrides:
4884+
projectOverride === undefined
4885+
? {}
4886+
: typeof projectOverride === "boolean"
4887+
? { [projectId]: { enableAgentBrowserAccess: projectOverride } }
4888+
: {
4889+
[projectId]: {
4890+
...(projectOverride.browser !== undefined
4891+
? { enableAgentBrowserAccess: projectOverride.browser }
4892+
: {}),
4893+
...(projectOverride.device !== undefined
4894+
? { enableAgentDeviceAccess: projectOverride.device }
4895+
: {}),
4896+
},
4897+
},
48854898
}),
48864899
),
48874900
Layer.provide(serverConfigTestLayer),
@@ -4965,4 +4978,14 @@ describe("agent browser access", () => {
49654978
assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]);
49664979
}).pipe(Effect.provide(NodeServices.layer)),
49674980
);
4981+
4982+
it.effect("a project device override grants device access when the environment denies it", () =>
4983+
Effect.gen(function* () {
4984+
const threadId = asThreadId("thread-project-device-on");
4985+
const issued = yield* startSessionWith({ browser: false, device: false }, threadId, {
4986+
device: true,
4987+
});
4988+
assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]);
4989+
}).pipe(Effect.provide(NodeServices.layer)),
4990+
);
49684991
});

0 commit comments

Comments
 (0)