1import {
2 type ModelSelection,
3 PROJECT_FILE_BACKED_SETTINGS,
4 PROJECT_SCOPED_SERVER_SETTING_KEYS,
5 type ProjectFileBackedSettingKey,
6 type ProjectId,
7 type ProjectScopedServerSettingKey,
8 type ProjectSettingsOverrides,
9 resolveProjectProviderInstanceEnabled,
10 type ResolvedServerSettings,
11 type ServerSettings,
12 type T3ProjectFile,
13 type ThreadEnvMode,
14 type WorktreeCleanupRules,
15} from "@t3tools/contracts";
16import { normalizeProjectPathForComparison } from "./path.ts";
17import { fallbackTextGenerationModelSelection } from "./serverSettings.ts";
18
19/**
20 * Where a project-scoped value came from. The order is the priority order:
21 * a project override, then the environment value, then the repository's
22 * t3.json for keys in `PROJECT_FILE_BACKED_SETTINGS`, then the built-in
23 * default (reported as "environment", since that is what the environment
24 * value is when nothing set it).
25 */
26export type ProjectSettingSource = "environment" | "project" | "t3.json";
27
28export type ProjectSettingSources = Readonly<
29 Record<ProjectScopedServerSettingKey, ProjectSettingSource>
30>;
31
32export interface ResolvedProjectSettings<Settings extends ServerSettings = ServerSettings> {
33 /** Environment settings with the project's overrides applied. */
34 readonly settings: Settings;
35 /** Where each scopable key's effective value came from. */
36 readonly sources: ProjectSettingSources;
37 /** The project's raw override entry; `{}` when it has none. */
38 readonly overrides: ProjectSettingsOverrides;
39}
40
41const EMPTY_OVERRIDES: ProjectSettingsOverrides = {};
42
43const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries(
44 PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]),
45) as Record<ProjectScopedServerSettingKey, ProjectSettingSource>;
46
47/** Cheap check so hot paths skip the projectId lookup when nothing is overridden. */
48export function hasProjectSettingsOverrides(
49 settings: Pick<ServerSettings, "projectSettingsOverrides">,
50): boolean {
51 for (const entry of Object.values(settings.projectSettingsOverrides)) {
52 // A forward-compatible decode can leave an unknown value as a present
53 // undefined; that is not an override.
54 if (Object.values(entry).some((value) => value !== undefined)) return true;
55 }
56 return false;
57}
58
59/**
60 * The project aggregate's own model and workspace fields. They remain the
61 * source of truth until the server has folded them into the override record;
62 * after the fold the record alone decides, so a reset there cannot be undone
63 * by a stale aggregate value.
64 */
65export interface LegacyProjectSettingsFields {
66 readonly defaultModelSelection?: ModelSelection | null | undefined;
67 readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined;
68}
69
70/**
71 * Apply one project's overrides on top of environment settings. A model
72 * override whose provider is disabled on this environment falls back to the
73 * environment value, the same guard the environment-level selection gets.
74 */
75export function resolveProjectSettings(
76 settings: ServerSettings,
77 projectId: ProjectId | null,
78 // Nullable, not just optional: the mobile new-task flow passes its selected
79 // project straight through, and that is null until the shell snapshot lands.
80 project?: LegacyProjectSettingsFields | null,
81): ResolvedProjectSettings;
82/**
83 * With the checkout's decoded t3.json (or null for a missing or invalid
84 * one), every file-backed key resolves to a concrete value: the file fills
85 * keys whose project and environment tiers are both unset, and the built-in
86 * default fills what is left.
87 */
88export function resolveProjectSettings(
89 settings: ServerSettings,
90 projectId: ProjectId | null,
91 project: LegacyProjectSettingsFields | null | undefined,
92 projectFile: T3ProjectFile | null,
93): ResolvedProjectSettings<ResolvedServerSettings>;
94export function resolveProjectSettings(
95 settings: ServerSettings,
96 projectId: ProjectId | null,
97 project?: LegacyProjectSettingsFields | null,
98 projectFile?: T3ProjectFile | null,
99): ResolvedProjectSettings {
100 const resolved = resolveProjectOverrides(settings, projectId, project);
101 return projectFile === undefined ? resolved : applyProjectFile(resolved, projectFile);
102}
103
104function applyProjectFile(
105 resolved: ResolvedProjectSettings,
106 projectFile: T3ProjectFile | null,
107): ResolvedProjectSettings {
108 let effective: Record<string, unknown> | null = null;
109 let sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> | null = null;
110 for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) {
111 if (resolved.settings[key] !== null) continue;
112 const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile);
113 effective ??= { ...resolved.settings };
114 sources ??= { ...resolved.sources };
115 effective[key] = value;
116 // A project override of null defers like an unset one, so the value did
117 // not come from the project either way.
118 sources[key] = source;
119 }
120 return effective === null || sources === null
121 ? resolved
122 : { ...resolved, settings: effective as ServerSettings, sources };
123}
124
125/**
126 * The file and built-in tiers for one key, given the project-over-environment
127 * value (`null` when neither is set). For callers that hold the settings tier
128 * but only see the file later, such as the git driver reading the t3.json of
129 * the checkout it just created. Same chain as `resolveProjectSettings`.
130 */
131export function resolveProjectFileBackedSetting<K extends ProjectFileBackedSettingKey>(
132 key: K,
133 setting: ServerSettings[K],
134 projectFile: T3ProjectFile | null,
135): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } {
136 if (setting !== null) {
137 return { value: setting as ResolvedServerSettings[K], source: "environment" };
138 }
139 const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key];
140 const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined;
141 return fromFile === undefined
142 ? { value: builtIn as ResolvedServerSettings[K], source: "environment" }
143 : { value: fromFile, source: "t3.json" };
144}
145
146function resolveProjectOverrides(
147 settings: ServerSettings,
148 projectId: ProjectId | null,
149 project?: LegacyProjectSettingsFields | null,
150): ResolvedProjectSettings {
151 const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];
152 const overrides: ProjectSettingsOverrides =
153 project == null || settings.projectSettingsFolded
154 ? (stored ?? EMPTY_OVERRIDES)
155 : {
156 ...(project.defaultModelSelection != null
157 ? { defaultModelSelection: project.defaultModelSelection }
158 : {}),
159 ...(project.defaultThreadEnvMode != null
160 ? { defaultThreadEnvMode: project.defaultThreadEnvMode }
161 : {}),
162 ...stored,
163 };
164 if (Object.keys(overrides).length === 0) {
165 return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES };
166 }
167 const sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> = {
168 ...ENVIRONMENT_SOURCES,
169 };
170 const effective: Record<string, unknown> = { ...settings };
171 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
172 if (!Object.hasOwn(overrides, key)) continue;
173 const value = overrides[key];
174 // A forward-compatible decode leaves an unknown value as a present
175 // undefined; that is not an override.
176 if (value === undefined) continue;
177 // A model on a disabled provider falls back to the environment, like the
178 // environment-level guards do for these keys. An instance this project
179 // enabled via `providerInstances[id].enabled` still counts as enabled here.
180 if (
181 (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&
182 value !== undefined &&
183 value !== null &&
184 !resolveProjectProviderInstanceEnabled(
185 settings,
186 projectId,
187 (value as ModelSelection).instanceId,
188 )
189 ) {
190 continue;
191 }
192 effective[key] = value;
193 sources[key] = "project";
194 }
195 // Like the environment guard, text generation never runs on an instance
196 // this project turned off.
197 const textGeneration = effective.textGenerationModelSelection as ModelSelection;
198 if (!resolveProjectProviderInstanceEnabled(settings, projectId, textGeneration.instanceId)) {
199 const fallback = fallbackTextGenerationModelSelection(
200 settings.providerInstances,
201 (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId),
202 {
203 includeConfiguredInstances: true,
204 defaultModelSelection: effective.defaultModelSelection as ModelSelection | null,
205 },
206 );
207 if (fallback !== null) effective.textGenerationModelSelection = fallback;
208 }
209 return { settings: effective as ServerSettings, sources, overrides };
210}
211
212/**
213 * The workspace mode a new thread starts in. A project the environment made
214 * from just a name (a folder directly inside `ServerConfig.newProjectsRoot`)
215 * has only its first commit, so until it has a thread, a new thread works in
216 * the project checkout even when the environment default is a new worktree.
217 * Only that environment default is replaced: a project override still wins,
218 * and so does a t3.json when the environment sets no default (the usual
219 * precedence; the scaffold writes no t3.json).
220 */
221export function resolveNewThreadEnvMode(input: {
222 readonly projectSettings: ResolvedProjectSettings<ResolvedServerSettings>;
223 readonly workspaceRoot: string | null;
224 readonly newProjectsRoot: string | null | undefined;
225 readonly projectHasThreads: boolean;
226}): ThreadEnvMode {
227 const configured = input.projectSettings.settings.defaultThreadEnvMode;
228 if (
229 configured === "local" ||
230 input.projectHasThreads ||
231 input.projectSettings.sources.defaultThreadEnvMode !== "environment" ||
232 input.workspaceRoot === null ||
233 input.newProjectsRoot == null
234 ) {
235 return configured;
236 }
237 const root = normalizeProjectPathForComparison(input.newProjectsRoot);
238 const folder = normalizeProjectPathForComparison(input.workspaceRoot);
239 const separator = root.includes("\\") ? "\\" : "/";
240 const prefix = root.endsWith(separator) ? root : `${root}${separator}`;
241 const isNewProjectFolder =
242 folder.startsWith(prefix) &&
243 folder.length > prefix.length &&
244 !folder.slice(prefix.length).includes(separator);
245 return isNewProjectFolder ? "local" : configured;
246}
247
248/** Replace the project's entry, dropping it entirely when nothing is overridden. */
249export function withProjectSettingsOverrides(
250 settings: Pick<ServerSettings, "projectSettingsOverrides">,
251 projectId: ProjectId,
252 next: ProjectSettingsOverrides | null,
253): ServerSettings["projectSettingsOverrides"] {
254 const { [projectId]: _removed, ...rest } = settings.projectSettingsOverrides;
255 return next === null || Object.keys(next).length === 0 ? rest : { ...rest, [projectId]: next };
256}
257
258/** The project's entry with `keys` removed; `null` when that leaves it empty. */
259export function clearProjectSettingsOverrides(
260 settings: Pick<ServerSettings, "projectSettingsOverrides">,
261 projectId: ProjectId,
262 keys: readonly ProjectScopedServerSettingKey[],
263): ProjectSettingsOverrides | null {
264 const current = settings.projectSettingsOverrides[projectId];
265 if (current === undefined) return null;
266 const next = { ...current };
267 for (const key of keys) delete next[key];
268 return Object.keys(next).length === 0 ? null : next;
269}
270
271/** Worktree rules are project-scoped; artifact and log retention stays environment-wide. */
272export function resolveWorktreeCleanup(
273 settings: ServerSettings,
274 projectId: ProjectId | null,
275): WorktreeCleanupRules {
276 const policy = resolveProjectSettings(settings, projectId).settings.worktreeCleanup;
277 if (policy?.mode === "custom") return policy.rules;
278 if (policy?.mode === "off")
279 return {
280 worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen,
281 worktreeAfterDays: null,
282 worktreeSettledAfterDays: null,
283 worktreeOnMerge: false,
284 worktreeOnDelete: false,
285 worktreeUnchanged: false,
286 };
287 const {
288 worktreeKeepWhen,
289 worktreeAfterDays,
290 worktreeSettledAfterDays,
291 worktreeOnMerge,
292 worktreeOnDelete,
293 worktreeUnchanged,
294 } = settings.storageCleanup;
295 return {
296 worktreeKeepWhen,
297 worktreeAfterDays,
298 worktreeSettledAfterDays,
299 worktreeOnMerge,
300 worktreeOnDelete,
301 worktreeUnchanged,
302 };
303}