| author | |
| committer | |
| log | 2c0e891740f86205784506a974096cf110b64da7 |
| tree | ae3d48ea2e70f8f47fc7f6279ba8264d11c19ed6 |
| parent | fb52d125b78b2ed1638a719a7546fad9c695c620 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 37 | import { | ||
| 38 | hasProjectSettingsOverrides, | ||
| 39 | resolveProjectSettings, | ||
| 40 | } from "@t3tools/shared/projectSettings"; | ||
| 41 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 35 | import { | 42 | import { |
| 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* () { |
| 661 | 668 | ||
| 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; |
| 2602 | 2631 | ||
| 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 === null | 2634 | 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({ |
| 142 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; | 142 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; |
| 143 | const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ | 143 | const 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 | SELECT | 1233 | 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"; |
| 57 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 57 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 58 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 58 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; | 59 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; |
| 59 | const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); | 60 | const 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 === null | 1012 | settings.sourceControlWriterModelSelection === null |
| 1002 | ? settings.textGenerationModelSelection | 1013 | ? 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 | ); | ||
| 1052 | 1064 | ||
| 1053 | const generated = yield* textGeneration | 1065 | 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 { |
| 51 | import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; | 51 | import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; |
| 52 | import { forkParked } from "../../serverActivation.ts"; | 52 | import { forkParked } from "../../serverActivation.ts"; |
| 53 | import { ServerSettingsService } from "../../serverSettings.ts"; | 53 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 54 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 54 | import { canReplaceThreadTitle } from "../threadTitles.ts"; | 55 | import { canReplaceThreadTitle } from "../threadTitles.ts"; |
| 55 | 56 | ||
| 56 | const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; | 57 | const 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* () { |
| 1668 | 1669 | ||
| 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 | ProjectionRepositoryError | 213 | ProjectionRepositoryError |
| 214 | >; | 214 | >; |
| 215 | 215 |
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 | }); |
| 303 | 303 | ||
| 304 | describe("ThreadSettlementReactor", () => { | 304 | describe("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 | ); |
| 488 | 517 | ||
| 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 @@ |
| 1 | import { CommandId } from "@t3tools/contracts"; | 1 | import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; |
| 2 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| 2 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 3 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 3 | import * as Cause from "effect/Cause"; | 4 | import * as Cause from "effect/Cause"; |
| 4 | import * as Context from "effect/Context"; | 5 | import * 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") {} |
| 34 | 35 | ||
| 36 | /** @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 | /** @internal Exported for tests. */ | ||
| 51 | export 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. */ |
| 36 | export const make = Effect.gen(function* () { | 76 | export 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"; |
| 36 | import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; | 38 | import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; |
| 37 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 39 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 38 | import { causeErrorTag } from "@t3tools/shared/observability"; | 40 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 39 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 41 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 40 | import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; | 42 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 41 | import * as DateTime from "effect/DateTime"; | 43 | import * as DateTime from "effect/DateTime"; |
| 42 | import * as Deferred from "effect/Deferred"; | 44 | import * as Deferred from "effect/Deferred"; |
| 43 | import * as Effect from "effect/Effect"; | 45 | import * 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 project | 882 | // 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 | ); |
| 897 | 905 | ||
| ... | @@ -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 | }); |
| 906 | 915 | ||
| ... | @@ -2224,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -2224,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 2224 | ); | 2233 | ); |
| 2225 | 2234 | ||
| 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.activeTurnId | 2287 | 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | 2 | import { |
| 3 | DEFAULT_MODEL, | ||
| 4 | DEFAULT_SERVER_SETTINGS, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 3 | import { assert, it } from "@effect/vitest"; | 9 | import { assert, it } from "@effect/vitest"; |
| 4 | import * as Crypto from "effect/Crypto"; | 10 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Deferred from "effect/Deferred"; | 11 | import * 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; |
| 45 | 51 | 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)); | ||
| 54 | 79 | ||
| 55 | assert.deepStrictEqual(pulled, ["/clean"]); | 80 | assert.deepStrictEqual(pulled, ["/clean"]); |
| 56 | 81 | ||
| 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"; |
| 13 | import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; | 14 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 14 | import * as Cause from "effect/Cause"; | 15 | import * as Cause from "effect/Cause"; |
| 15 | import * as Console from "effect/Console"; | 16 | import * as Console from "effect/Console"; |
| 16 | import * as Context from "effect/Context"; | 17 | import * 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 | } |
| 234 | 236 | ||
| 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; | ||
| 490 | 497 | ||
| 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's | 575 | // 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 { |
| 742 | 749 | ||
| 743 | export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( | 750 | export 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 | projects | 757 | 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { | 2 | import { |
| 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"; |
| 51 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; | 53 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 52 | import { | 54 | import { |
| 53 | applyServerSettingsPatch, | 55 | applyServerSettingsPatch, |
| 56 | deriveLegacyProjectOverrides, | ||
| 54 | isModelSelectionProviderEnabled, | 57 | isModelSelectionProviderEnabled, |
| 55 | } from "@t3tools/shared/serverSettings"; | 58 | } from "@t3tools/shared/serverSettings"; |
| 56 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 59 | import * 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 | ]); |
| 357 | 362 | ||
| 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 | } |
| 402 | 407 | ||
| 408 | const decodeProjectScriptsJson = Schema.decodeUnknownOption( | ||
| 409 | Schema.fromJsonString(Schema.Array(ProjectScript)), | ||
| 410 | ); | ||
| 411 | const decodeModelSelectionJson = Schema.decodeUnknownOption( | ||
| 412 | Schema.fromJsonString(Schema.NullOr(ModelSelection)), | ||
| 413 | ); | ||
| 414 | |||
| 415 | interface 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 | */ | ||
| 429 | function 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 | |||
| 403 | const make = Effect.gen(function* () { | 495 | const 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 | ); |
| 441 | 533 | ||
| 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; | ||
| 445 | 564 | ||
| 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 | ); |
| 492 | 612 | ||
| 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 | }); |
| 497 | 647 | ||
| 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 | }); |
| 740 | 890 | ||
| 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"; |
| 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 25 | import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; | 25 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 26 | 26 | ||
| 27 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 27 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 28 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; | 28 | import * 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 @@ |
| 1 | import { | 1 | import { |
| 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 | }); |
| 44 | 45 | ||
| 45 | describe("splitSharedServerPatch", () => { | 46 | describe("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. Same | 104 | /** 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({}))); |
| 929 | export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; | 929 | export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; |
| 930 | 930 | ||
| 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 | */ | ||
| 937 | export 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; | ||
| 954 | export 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 | */ | ||
| 961 | export 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>); | ||
| 978 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; | ||
| 979 | |||
| 931 | export const ServerSettings = Schema.Struct({ | 980 | export const ServerSettings = Schema.Struct({ |
| 932 | // Legacy token-by-token assistant output. Deliberately a fresh key (was | 981 | // 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 way | 1037 | * 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 | ), | ||
| 1056 | 1128 | ||
| 1057 | // Legacy single-instance-per-driver settings. Continues to be the source | 1129 | // Legacy single-instance-per-driver settings. Continues to be the source |
| 1058 | // of truth until `providerInstances` (below) lands per-driver migration | 1130 | // 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 @@ |
| 1 | import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; | 1 | import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | /** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ | 3 | type 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 | */ | ||
| 4 | export function resolveProjectScripts( | 17 | export 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 | } |
| 14 | 28 | ||
| 15 | export function projectScriptsInheritDefaults( | 29 | export 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 | } |
| 22 | 40 | ||
| 23 | interface ProjectScriptRuntimeEnvInput { | 41 | interface ProjectScriptRuntimeEnvInput { |
packages/shared/src/projectSettings.test.ts created+208| ... | @@ -0,0 +1,208 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | PROJECT_SCOPED_SERVER_SETTING_KEYS, | ||
| 4 | ProjectId, | ||
| 5 | ProviderInstanceId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | ||
| 8 | import { createModelSelection } from "./model.ts"; | ||
| 9 | import { | ||
| 10 | clearProjectSettingsOverrides, | ||
| 11 | hasProjectSettingsOverrides, | ||
| 12 | resolveProjectSettings, | ||
| 13 | withProjectSettingsOverrides, | ||
| 14 | } from "./projectSettings.ts"; | ||
| 15 | import { applyServerSettingsPatch } from "./serverSettings.ts"; | ||
| 16 | |||
| 17 | const projectId = ProjectId.make("project-a"); | ||
| 18 | const otherProjectId = ProjectId.make("project-b"); | ||
| 19 | |||
| 20 | describe("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 | |||
| 112 | describe("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 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 10 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; | ||
| 11 | |||
| 12 | export type ProjectSettingSource = "environment" | "project"; | ||
| 13 | |||
| 14 | export type ProjectSettingSources = Readonly< | ||
| 15 | Record<ProjectScopedServerSettingKey, ProjectSettingSource> | ||
| 16 | >; | ||
| 17 | |||
| 18 | export 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 | |||
| 27 | const EMPTY_OVERRIDES: ProjectSettingsOverrides = {}; | ||
| 28 | |||
| 29 | const 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. */ | ||
| 34 | export 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 | */ | ||
| 49 | export 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 | */ | ||
| 59 | export 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. */ | ||
| 104 | export 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. */ | ||
| 114 | export 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"; |
| 22 | 22 | ||
| 23 | /** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ | ||
| 24 | const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; | ||
| 25 | |||
| 23 | describe("serverSettings helpers", () => { | 26 | describe("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 { |
| 24 | const ServerSettingsJson = fromLenientJson(ServerSettings); | 26 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 25 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); | 27 | const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); |
| 26 | 28 | ||
| 29 | /** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */ | ||
| 27 | export function resolveProjectAgentBrowserAccess( | 30 | export 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.enableAgentBrowserAccess | 38 | settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ?? |
| 39 | settings.projectAgentBrowserAccessOverrides[projectId] ?? | ||
| 40 | settings.enableAgentBrowserAccess | ||
| 33 | ); | 41 | ); |
| 34 | } | 42 | } |
| 35 | 43 | ||
| 44 | /** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */ | ||
| 36 | export function resolveProjectAutoPull( | 45 | export 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 | } |
| 162 | 175 | ||
| 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 | */ | ||
| 181 | export 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 | */ | ||
| 210 | function 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 | |||
| 163 | export function applyServerSettingsPatch( | 262 | export 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 | ...patchForMerge | 283 | ...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 !== undefined | 336 | ...(patch.providerInstances !== undefined |
| 232 | ? { providerInstances: patch.providerInstances } | 337 | ? { providerInstances: patch.providerInstances } |
| 233 | : {}), | 338 | : {}), |
| 234 | ...(projectAgentBrowserAccessOverridesPatch !== undefined | 339 | ...(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 !== undefined | 351 | ...(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 !== undefined | 354 | ...(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, |