Skip to content
Source
Sign inSign up

projectSettings.ts

260 lines9.9 KB

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 resolveTextGenerationSelectionEnabled,
11 type ResolvedServerSettings,
12 type ServerSettings,
13 type T3ProjectFile,
14 type ThreadEnvMode,
15 type WorktreeCleanupRules,
16} from "@t3tools/contracts";
17import { readCustomModelEntries } from "./model.ts";
18import { normalizeProjectPathForComparison } from "./path.ts";
19import { fallbackTextGenerationModelSelection } from "./serverSettings.ts";
20​
21export type ProjectSettingSource = "environment" | "project" | "t3.json";
22​
23export type ProjectSettingSources = Readonly<
24 Record<ProjectScopedServerSettingKey, ProjectSettingSource>
25>;
26​
27export interface ResolvedProjectSettings<Settings extends ServerSettings = ServerSettings> {
28 readonly settings: Settings;
29 readonly sources: ProjectSettingSources;
30 readonly overrides: ProjectSettingsOverrides;
32​
33const EMPTY_OVERRIDES: ProjectSettingsOverrides = {};
34​
35const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries(
36 PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]),
37) as Record<ProjectScopedServerSettingKey, ProjectSettingSource>;
38​
39export function hasProjectSettingsOverrides(
40 settings: Pick<ServerSettings, "projectSettingsOverrides">,
41): boolean {
42 for (const entry of Object.values(settings.projectSettingsOverrides)) {
43 // Forward-compatible decoding can leave a present undefined, which is not an override.
44 if (Object.values(entry).some((value) => value !== undefined)) return true;
45 }
46 return false;
48​
49/** Legacy aggregate fields apply only until folded into overrides, so stale values cannot undo later resets. */
50export interface LegacyProjectSettingsFields {
51 readonly defaultModelSelection?: ModelSelection | null | undefined;
52 readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined;
54​
55export function resolveProjectSettings(
56 settings: ServerSettings,
57 projectId: ProjectId | null,
58 project?: LegacyProjectSettingsFields | null,
59): ResolvedProjectSettings;
60export function resolveProjectSettings(
61 settings: ServerSettings,
62 projectId: ProjectId | null,
63 project: LegacyProjectSettingsFields | null | undefined,
64 projectFile: T3ProjectFile | null,
65): ResolvedProjectSettings<ResolvedServerSettings>;
66export function resolveProjectSettings(
67 settings: ServerSettings,
68 projectId: ProjectId | null,
69 project?: LegacyProjectSettingsFields | null,
70 projectFile?: T3ProjectFile | null,
71): ResolvedProjectSettings {
72 const resolved = resolveProjectOverrides(settings, projectId, project);
73 return projectFile === undefined ? resolved : applyProjectFile(resolved, projectFile);
75​
76function applyProjectFile(
77 resolved: ResolvedProjectSettings,
78 projectFile: T3ProjectFile | null,
79): ResolvedProjectSettings {
80 let effective: Record<string, unknown> | null = null;
81 let sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> | null = null;
82 for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) {
83 if (resolved.settings[key] !== null) continue;
84 const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile);
85 effective ??= { ...resolved.settings };
86 sources ??= { ...resolved.sources };
87 effective[key] = value;
88 sources[key] = source;
89 }
90 return effective === null || sources === null
91 ? resolved
92 : { ...resolved, settings: effective as ServerSettings, sources };
94​
95export function resolveProjectFileBackedSetting<K extends ProjectFileBackedSettingKey>(
96 key: K,
97 setting: ServerSettings[K],
98 projectFile: T3ProjectFile | null,
99): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } {
100 if (setting !== null) {
101 return { value: setting as ResolvedServerSettings[K], source: "environment" };
102 }
103 const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key];
104 const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined;
105 return fromFile === undefined
106 ? { value: builtIn as ResolvedServerSettings[K], source: "environment" }
107 : { value: fromFile, source: "t3.json" };
109​
110function resolveProjectOverrides(
111 settings: ServerSettings,
112 projectId: ProjectId | null,
113 project?: LegacyProjectSettingsFields | null,
114): ResolvedProjectSettings {
115 const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId];
116 const overrides: ProjectSettingsOverrides =
117 project == null || settings.projectSettingsFolded
118 ? (stored ?? EMPTY_OVERRIDES)
119 : {
120 ...(project.defaultModelSelection != null
121 ? { defaultModelSelection: project.defaultModelSelection }
122 : {}),
123 ...(project.defaultThreadEnvMode != null
124 ? { defaultThreadEnvMode: project.defaultThreadEnvMode }
125 : {}),
126 ...stored,
127 };
128 if (Object.keys(overrides).length === 0) {
129 return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES };
130 }
131 const sources: Record<ProjectScopedServerSettingKey, ProjectSettingSource> = {
132 ...ENVIRONMENT_SOURCES,
133 };
134 const effective: Record<string, unknown> = { ...settings };
135 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
136 if (!Object.hasOwn(overrides, key)) continue;
137 const value = overrides[key];
138 // Forward-compatible decoding can leave a present undefined, which is not an override.
139 if (value === undefined) continue;
140 if (
141 (key === "textGenerationModelSelection" || key === "defaultModelSelection") &&
142 value !== undefined &&
143 value !== null &&
144 !(
145 key === "textGenerationModelSelection"
146 ? resolveTextGenerationSelectionEnabled
147 : resolveProjectProviderInstanceEnabled
148 )(settings, projectId, (value as ModelSelection).instanceId)
149 ) {
150 continue;
151 }
152 // Clients resolve a hidden model to the catalog's first visible one, so a
153 // hidden project default would silently pick a model nobody chose.
154 // Custom models stay visible even when listed as hidden, as in the clients.
155 if (key === "defaultModelSelection" && value !== null) {
156 const { instanceId, model } = value as ModelSelection;
157 const config = settings.providerInstances[instanceId]?.config as
158 | { customModels?: unknown }
159 | undefined;
160 if (
161 settings.providerModelPreferences?.[instanceId]?.hiddenModels.includes(model) &&
162 !readCustomModelEntries(config?.customModels).some((entry) => entry.slug === model)
163 ) {
164 continue;
165 }
166 }
167 effective[key] = value;
168 sources[key] = "project";
169 }
170 const textGeneration = effective.textGenerationModelSelection as ModelSelection;
171 if (!resolveTextGenerationSelectionEnabled(settings, projectId, textGeneration.instanceId)) {
172 const fallback = fallbackTextGenerationModelSelection(
173 settings.providerInstances,
174 (instanceId) => resolveProjectProviderInstanceEnabled(settings, projectId, instanceId),
175 { defaultModelSelection: effective.defaultModelSelection as ModelSelection | null },
176 );
177 if (fallback !== null) effective.textGenerationModelSelection = fallback;
178 }
179 return { settings: effective as ServerSettings, sources, overrides };
181​
182/** New name-only projects use their checkout until the first thread, even when the default is worktree. */
183export function resolveNewThreadEnvMode(input: {
184 readonly projectSettings: ResolvedProjectSettings<ResolvedServerSettings>;
185 readonly workspaceRoot: string | null;
186 readonly newProjectsRoot: string | null | undefined;
187 readonly projectHasThreads: boolean;
188}): ThreadEnvMode {
189 const configured = input.projectSettings.settings.defaultThreadEnvMode;
190 if (
191 configured === "local" ||
192 input.projectHasThreads ||
193 input.projectSettings.sources.defaultThreadEnvMode !== "environment" ||
194 input.workspaceRoot === null ||
195 input.newProjectsRoot == null
196 ) {
197 return configured;
198 }
199 const root = normalizeProjectPathForComparison(input.newProjectsRoot);
200 const folder = normalizeProjectPathForComparison(input.workspaceRoot);
201 const separator = root.includes("\\") ? "\\" : "/";
202 const prefix = root.endsWith(separator) ? root : `${root}${separator}`;
203 const isNewProjectFolder =
204 folder.startsWith(prefix) &&
205 folder.length > prefix.length &&
206 !folder.slice(prefix.length).includes(separator);
207 return isNewProjectFolder ? "local" : configured;
209​
210export function resolveSendEnvMode(input: {
211 readonly requestedEnvMode: ThreadEnvMode;
212 readonly isGitRepo: boolean;
213}): ThreadEnvMode {
214 return input.isGitRepo ? input.requestedEnvMode : "local";
216​
217export function clearProjectSettingsOverrides(
218 settings: Pick<ServerSettings, "projectSettingsOverrides">,
219 projectId: ProjectId,
220 keys: readonly ProjectScopedServerSettingKey[],
221): ProjectSettingsOverrides | null {
222 const current = settings.projectSettingsOverrides[projectId];
223 if (current === undefined) return null;
224 const next = { ...current };
225 for (const key of keys) delete next[key];
226 return Object.keys(next).length === 0 ? null : next;
228​
229export function resolveWorktreeCleanup(
230 settings: ServerSettings,
231 projectId: ProjectId | null,
232): WorktreeCleanupRules {
233 const policy = resolveProjectSettings(settings, projectId).settings.worktreeCleanup;
234 if (policy?.mode === "custom") return policy.rules;
235 if (policy?.mode === "off")
236 return {
237 worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen,
238 worktreeAfterDays: null,
239 worktreeSettledAfterDays: null,
240 worktreeOnMerge: false,
241 worktreeOnDelete: false,
242 worktreeUnchanged: false,
243 };
244 const {
245 worktreeKeepWhen,
246 worktreeAfterDays,
247 worktreeSettledAfterDays,
248 worktreeOnMerge,
249 worktreeOnDelete,
250 worktreeUnchanged,
251 } = settings.storageCleanup;
252 return {
253 worktreeKeepWhen,
254 worktreeAfterDays,
255 worktreeSettledAfterDays,
256 worktreeOnMerge,
257 worktreeOnDelete,
258 worktreeUnchanged,
259 };