| 1 | import { |
| 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"; |
| 16 | import { normalizeProjectPathForComparison } from "./path.ts"; |
| 17 | import { 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 | */ |
| 26 | export type ProjectSettingSource = "environment" | "project" | "t3.json"; |
| 27 | |
| 28 | export type ProjectSettingSources = Readonly< |
| 29 | Record<ProjectScopedServerSettingKey, ProjectSettingSource> |
| 30 | >; |
| 31 | |
| 32 | export 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 | |
| 41 | const EMPTY_OVERRIDES: ProjectSettingsOverrides = {}; |
| 42 | |
| 43 | const 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. */ |
| 48 | export 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 | */ |
| 65 | export 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 | */ |
| 75 | export 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 | */ |
| 88 | export function resolveProjectSettings( |
| 89 | settings: ServerSettings, |
| 90 | projectId: ProjectId | null, |
| 91 | project: LegacyProjectSettingsFields | null | undefined, |
| 92 | projectFile: T3ProjectFile | null, |
| 93 | ): ResolvedProjectSettings<ResolvedServerSettings>; |
| 94 | export 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 | |
| 104 | function 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 | */ |
| 131 | export 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 | |
| 146 | function 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 | */ |
| 221 | export 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. */ |
| 249 | export 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. */ |
| 259 | export 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. */ |
| 272 | export 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 | } |