| author | |
| committer | |
| log | 34ec8a86fb3a76f5d65e6805dcf98d785536b6f8 |
| tree | 336ee6ab0abb3b1ef30d172c42657457f32f7ee3 |
| parent | 932df4ed17ff9465053e3a1bbfdd6d2ba23500f0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>22 files changed, 517 insertions(+), 106 deletions(-)
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+1-1| ... | ... | @@ -1601,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1601 | 1601 | hasActionableProposedPlan: row.hasActionableProposedPlan > 0, |
| 1602 | 1602 | }), |
| 1603 | 1603 | ), |
| 1604 | updatedAt: updatedAt ?? new Date(0).toISOString(), | |
| 1604 | updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", | |
| 1605 | 1605 | }; |
| 1606 | 1606 | |
| 1607 | 1607 | return yield* decodeShellSnapshot(snapshot).pipe( |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+9-5| ... | ... | @@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 27 | 27 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 28 | 28 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 29 | 29 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 30 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 30 | 31 | |
| 31 | 32 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 32 | 33 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| ... | ... | @@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t |
| 48 | 49 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| 49 | 50 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| 50 | 51 | const decodeServerSettings = Schema.decodeSync(ServerSettings); |
| 52 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | |
| 53 | const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); | |
| 51 | 54 | |
| 52 | 55 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 53 | 56 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| ... | ... | @@ -256,7 +259,8 @@ function makeMutableServerSettingsService( |
| 256 | 259 | updateSettings: (patch) => |
| 257 | 260 | Effect.gen(function* () { |
| 258 | 261 | const current = yield* Ref.get(settingsRef); |
| 259 | const next = decodeServerSettings(deepMerge(current, patch)); | |
| 262 | const next = applyServerSettingsPatch(current, patch); | |
| 263 | encodeServerSettings(next); | |
| 260 | 264 | yield* Ref.set(settingsRef, next); |
| 261 | 265 | yield* PubSub.publish(changes, next); |
| 262 | 266 | return next; |
| ... | ... | @@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 930 | 934 | const missingBinary = `t3code_codex_missing_`; |
| 931 | 935 | const serverSettings = yield* makeMutableServerSettingsService( |
| 932 | 936 | decodeServerSettings( |
| 933 | deepMerge(DEFAULT_SERVER_SETTINGS, { | |
| 937 | deepMerge(encodedDefaultServerSettings, { | |
| 934 | 938 | providers: { |
| 935 | 939 | // Disable every built-in probe that would otherwise spawn |
| 936 | 940 | // on the CI host. `enabled: false` short-circuits each |
| ... | ... | @@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1029 | 1033 | const secondMissing = `t3code_codex_second_`; |
| 1030 | 1034 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1031 | 1035 | decodeServerSettings( |
| 1032 | deepMerge(DEFAULT_SERVER_SETTINGS, { | |
| 1036 | deepMerge(encodedDefaultServerSettings, { | |
| 1033 | 1037 | providers: { |
| 1034 | 1038 | codex: { enabled: true, binaryPath: firstMissing }, |
| 1035 | 1039 | claudeAgent: { enabled: false }, |
| ... | ... | @@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1124 | 1128 | Effect.gen(function* () { |
| 1125 | 1129 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1126 | 1130 | decodeServerSettings( |
| 1127 | deepMerge(DEFAULT_SERVER_SETTINGS, { | |
| 1131 | deepMerge(encodedDefaultServerSettings, { | |
| 1128 | 1132 | providers: { |
| 1129 | 1133 | codex: { enabled: false }, |
| 1130 | 1134 | claudeAgent: { enabled: false }, |
| ... | ... | @@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1180 | 1184 | Effect.gen(function* () { |
| 1181 | 1185 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1182 | 1186 | decodeServerSettings( |
| 1183 | deepMerge(DEFAULT_SERVER_SETTINGS, { | |
| 1187 | deepMerge(encodedDefaultServerSettings, { | |
| 1184 | 1188 | providers: { |
| 1185 | 1189 | codex: { |
| 1186 | 1190 | enabled: false, |
apps/server/src/server.test.ts+1-1| ... | ... | @@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: { |
| 635 | 635 | snapshotSequence: 0, |
| 636 | 636 | projects: [], |
| 637 | 637 | threads: [], |
| 638 | updatedAt: new Date(0).toISOString(), | |
| 638 | updatedAt: "1970-01-01T00:00:00.000Z", | |
| 639 | 639 | }), |
| 640 | 640 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), |
| 641 | 641 | getProjectShellById: () => Effect.succeed(Option.none()), |
apps/server/src/serverSettings.test.ts+3| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | 10 | import { assert, it } from "@effect/vitest"; |
| 11 | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Duration from "effect/Duration"; | |
| 12 | 13 | import * as FileSystem from "effect/FileSystem"; |
| 13 | 14 | import * as Layer from "effect/Layer"; |
| 14 | 15 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 437 | 438 | serverPassword: "secret-password", |
| 438 | 439 | }, |
| 439 | 440 | }, |
| 441 | automaticGitFetchInterval: Duration.seconds(10), | |
| 440 | 442 | }); |
| 441 | 443 | |
| 442 | 444 | assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); |
| ... | ... | @@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 458 | 460 | serverPassword: "secret-password", |
| 459 | 461 | }, |
| 460 | 462 | }, |
| 463 | automaticGitFetchInterval: 10_000, | |
| 461 | 464 | }); |
| 462 | 465 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 463 | 466 | ); |
apps/server/src/serverSettings.ts+58-46| ... | ... | @@ -45,15 +45,33 @@ import * as Semaphore from "effect/Semaphore"; |
| 45 | 45 | import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 46 | 46 | import { ServerConfig } from "./config.ts"; |
| 47 | 47 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 48 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 48 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 49 | 49 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 50 | 50 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 51 | 51 | import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; |
| 52 | const decodeServerSettings = Schema.decodeEffect(ServerSettings); | |
| 52 | ||
| 53 | const encodeServerSettings = Schema.encodeEffect(ServerSettings); | |
| 54 | const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); | |
| 55 | const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); | |
| 53 | 56 | |
| 54 | 57 | const textEncoder = new TextEncoder(); |
| 55 | 58 | const textDecoder = new TextDecoder(); |
| 56 | 59 | |
| 60 | const normalizeServerSettings = ( | |
| 61 | settings: ServerSettings, | |
| 62 | ): Effect.Effect<ServerSettings, ServerSettingsError> => | |
| 63 | encodeServerSettings(settings).pipe( | |
| 64 | Effect.flatMap(decodeServerSettings), | |
| 65 | Effect.mapError( | |
| 66 | (cause) => | |
| 67 | new ServerSettingsError({ | |
| 68 | settingsPath: "<memory>", | |
| 69 | detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, | |
| 70 | cause, | |
| 71 | }), | |
| 72 | ), | |
| 73 | ); | |
| 74 | ||
| 57 | 75 | function providerEnvironmentSecretName(input: { |
| 58 | 76 | readonly instanceId: string; |
| 59 | 77 | readonly name: string; |
| ... | ... | @@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service< |
| 117 | 135 | Layer.effect( |
| 118 | 136 | ServerSettingsService, |
| 119 | 137 | Effect.gen(function* () { |
| 120 | const currentSettingsRef = yield* Ref.make<ServerSettings>( | |
| 121 | deepMerge(DEFAULT_SERVER_SETTINGS, overrides), | |
| 122 | ); | |
| 138 | const { automaticGitFetchInterval, ...overridesForMerge } = overrides; | |
| 139 | const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); | |
| 140 | const initialSettings = yield* normalizeServerSettings({ | |
| 141 | ...merged, | |
| 142 | ...(automaticGitFetchInterval !== undefined | |
| 143 | ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration } | |
| 144 | : {}), | |
| 145 | }); | |
| 146 | const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings); | |
| 123 | 147 | |
| 124 | 148 | return { |
| 125 | 149 | start: Effect.void, |
| ... | ... | @@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service< |
| 127 | 151 | getSettings: Ref.get(currentSettingsRef), |
| 128 | 152 | updateSettings: (patch) => |
| 129 | 153 | Ref.get(currentSettingsRef).pipe( |
| 130 | Effect.flatMap((currentSettings) => | |
| 131 | decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe( | |
| 132 | Effect.mapError( | |
| 133 | (cause) => | |
| 134 | new ServerSettingsError({ | |
| 135 | settingsPath: "<memory>", | |
| 136 | detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, | |
| 137 | cause, | |
| 138 | }), | |
| 139 | ), | |
| 140 | ), | |
| 141 | ), | |
| 154 | Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), | |
| 155 | Effect.flatMap(normalizeServerSettings), | |
| 142 | 156 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), |
| 143 | 157 | ), |
| 144 | 158 | streamChanges: Stream.empty, |
| ... | ... | @@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 200 | 214 | } |
| 201 | 215 | |
| 202 | 216 | // Values under these keys are compared as a whole — never stripped field-by-field. |
| 203 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]); | |
| 217 | const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ | |
| 218 | "automaticGitFetchInterval", | |
| 219 | "textGenerationModelSelection", | |
| 220 | ]); | |
| 204 | 221 | |
| 205 | 222 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { |
| 206 | 223 | if (Array.isArray(current) || Array.isArray(defaults)) { |
| ... | ... | @@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () { |
| 430 | 447 | }; |
| 431 | 448 | }); |
| 432 | 449 | |
| 433 | const writeSettingsAtomically = (settings: ServerSettings) => { | |
| 434 | const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; | |
| 450 | const writeSettingsAtomically = Effect.fnUntraced( | |
| 451 | function* (settings: ServerSettings) { | |
| 452 | const sparseSettingsJson = yield* encodeServerSettingsJson( | |
| 453 | stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, | |
| 454 | ); | |
| 435 | 455 | |
| 436 | return writeFileStringAtomically({ | |
| 437 | filePath: settingsPath, | |
| 438 | contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, | |
| 439 | }).pipe( | |
| 440 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 441 | Effect.provideService(Path.Path, pathService), | |
| 442 | Effect.mapError( | |
| 443 | (cause) => | |
| 444 | new ServerSettingsError({ | |
| 445 | settingsPath, | |
| 446 | detail: "failed to write settings file", | |
| 447 | cause, | |
| 448 | }), | |
| 449 | ), | |
| 450 | ); | |
| 451 | }; | |
| 456 | return yield* writeFileStringAtomically({ | |
| 457 | filePath: settingsPath, | |
| 458 | contents: `${sparseSettingsJson}\n`, | |
| 459 | }).pipe( | |
| 460 | Effect.provideService(FileSystem.FileSystem, fs), | |
| 461 | Effect.provideService(Path.Path, pathService), | |
| 462 | ); | |
| 463 | }, | |
| 464 | Effect.mapError( | |
| 465 | (cause) => | |
| 466 | new ServerSettingsError({ | |
| 467 | settingsPath, | |
| 468 | detail: "failed to write settings file", | |
| 469 | cause, | |
| 470 | }), | |
| 471 | ), | |
| 472 | ); | |
| 452 | 473 | |
| 453 | 474 | const revalidateAndEmit = writeSemaphore.withPermits(1)( |
| 454 | 475 | Effect.gen(function* () { |
| ... | ... | @@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () { |
| 533 | 554 | current, |
| 534 | 555 | applyServerSettingsPatch(current, patch), |
| 535 | 556 | ); |
| 536 | const next = yield* decodeServerSettings(nextPersisted).pipe( | |
| 537 | Effect.mapError( | |
| 538 | (cause) => | |
| 539 | new ServerSettingsError({ | |
| 540 | settingsPath: "<memory>", | |
| 541 | detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, | |
| 542 | cause, | |
| 543 | }), | |
| 544 | ), | |
| 545 | ); | |
| 557 | const next = yield* normalizeServerSettings(nextPersisted); | |
| 546 | 558 | yield* writeSettingsAtomically(next); |
| 547 | 559 | yield* Cache.set(settingsCache, cacheKey, next); |
| 548 | 560 | yield* emitChange(next); |
apps/server/src/vcs/GitVcsDriverCore.test.ts+59| ... | ... | @@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 130 | 130 | }), |
| 131 | 131 | ); |
| 132 | 132 | |
| 133 | it.effect("disables SSH askpass for background upstream status fetches", () => | |
| 134 | Effect.gen(function* () { | |
| 135 | const cwd = yield* makeTmpDir(); | |
| 136 | const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-"); | |
| 137 | const { initialBranch } = yield* initRepoWithCommit(cwd); | |
| 138 | const fileSystem = yield* FileSystem.FileSystem; | |
| 139 | const pathService = yield* Path.Path; | |
| 140 | const sshLogPath = pathService.join(tempDir, "ssh-env.txt"); | |
| 141 | const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh"); | |
| 142 | const previousGitSsh = process.env.GIT_SSH; | |
| 143 | const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE; | |
| 144 | const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG; | |
| 145 | ||
| 146 | yield* fileSystem.writeFileString( | |
| 147 | sshWrapperPath, | |
| 148 | [ | |
| 149 | "#!/bin/sh", | |
| 150 | 'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"', | |
| 151 | "exit 1", | |
| 152 | "", | |
| 153 | ].join("\n"), | |
| 154 | ); | |
| 155 | yield* fileSystem.chmod(sshWrapperPath, 0o755); | |
| 156 | yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); | |
| 157 | yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); | |
| 158 | yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); | |
| 159 | ||
| 160 | yield* Effect.gen(function* () { | |
| 161 | process.env.GIT_SSH = sshWrapperPath; | |
| 162 | process.env.SSH_ASKPASS_REQUIRE = "force"; | |
| 163 | process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; | |
| 164 | ||
| 165 | yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); | |
| 166 | ||
| 167 | assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never"); | |
| 168 | }).pipe( | |
| 169 | Effect.ensuring( | |
| 170 | Effect.sync(() => { | |
| 171 | if (previousGitSsh === undefined) { | |
| 172 | delete process.env.GIT_SSH; | |
| 173 | } else { | |
| 174 | process.env.GIT_SSH = previousGitSsh; | |
| 175 | } | |
| 176 | if (previousAskpassRequire === undefined) { | |
| 177 | delete process.env.SSH_ASKPASS_REQUIRE; | |
| 178 | } else { | |
| 179 | process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire; | |
| 180 | } | |
| 181 | if (previousAskpassLog === undefined) { | |
| 182 | delete process.env.T3_TEST_SSH_ASKPASS_LOG; | |
| 183 | } else { | |
| 184 | process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog; | |
| 185 | } | |
| 186 | }), | |
| 187 | ), | |
| 188 | ); | |
| 189 | }), | |
| 190 | ); | |
| 191 | ||
| 133 | 192 | it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => |
| 134 | 193 | Effect.gen(function* () { |
| 135 | 194 | const cwd = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+6| ... | ... | @@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 41 | 41 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 42 | 42 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 43 | 43 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 44 | const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ | |
| 45 | SSH_ASKPASS_REQUIRE: "never", | |
| 46 | } satisfies NodeJS.ProcessEnv); | |
| 44 | 47 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 45 | 48 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; |
| 46 | 49 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ |
| ... | ... | @@ -72,6 +75,7 @@ interface ExecuteGitOptions { |
| 72 | 75 | timeoutMs?: number | undefined; |
| 73 | 76 | allowNonZeroExit?: boolean | undefined; |
| 74 | 77 | fallbackErrorMessage?: string | undefined; |
| 78 | env?: NodeJS.ProcessEnv | undefined; | |
| 75 | 79 | maxOutputBytes?: number | undefined; |
| 76 | 80 | truncateOutputAtMaxBytes?: boolean | undefined; |
| 77 | 81 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; |
| ... | ... | @@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 738 | 742 | cwd, |
| 739 | 743 | args, |
| 740 | 744 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), |
| 745 | ...(options.env !== undefined ? { env: options.env } : {}), | |
| 741 | 746 | allowNonZeroExit: true, |
| 742 | 747 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 743 | 748 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| ... | ... | @@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 870 | 875 | ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], |
| 871 | 876 | { |
| 872 | 877 | allowNonZeroExit: true, |
| 878 | env: STATUS_UPSTREAM_REFRESH_ENV, | |
| 873 | 879 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), |
| 874 | 880 | }, |
| 875 | 881 | ).pipe(Effect.asVoid); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+26| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; | |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as Exit from "effect/Exit"; |
| 6 | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => { |
| 284 | 285 | }).pipe(Effect.provide(makeTestLayer(state))); |
| 285 | 286 | }); |
| 286 | 287 | |
| 288 | it.effect("does not start automatic remote refreshes when disabled", () => { | |
| 289 | const state = { | |
| 290 | currentLocalStatus: baseLocalStatus, | |
| 291 | currentRemoteStatus: baseRemoteStatus, | |
| 292 | localStatusCalls: 0, | |
| 293 | remoteStatusCalls: 0, | |
| 294 | localInvalidationCalls: 0, | |
| 295 | remoteInvalidationCalls: 0, | |
| 296 | }; | |
| 297 | ||
| 298 | return Effect.gen(function* () { | |
| 299 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | |
| 300 | const snapshot = yield* Stream.runHead( | |
| 301 | broadcaster.streamStatus( | |
| 302 | { cwd: "/repo" }, | |
| 303 | { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, | |
| 304 | ), | |
| 305 | ); | |
| 306 | ||
| 307 | assert.isTrue(Option.isSome(snapshot)); | |
| 308 | assert.equal(state.remoteStatusCalls, 0); | |
| 309 | assert.equal(state.remoteInvalidationCalls, 0); | |
| 310 | }).pipe(Effect.provide(makeTestLayer(state))); | |
| 311 | }); | |
| 312 | ||
| 287 | 313 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { |
| 288 | 314 | const state = { |
| 289 | 315 | currentLocalStatus: baseLocalStatus, |
apps/server/src/vcs/VcsStatusBroadcaster.ts+32-9| ... | ... | @@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 22 | 22 | |
| 23 | 23 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 24 | 24 | |
| 25 | const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | |
| 25 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | |
| 26 | 26 | |
| 27 | 27 | interface VcsStatusChange { |
| 28 | 28 | readonly cwd: string; |
| ... | ... | @@ -44,6 +44,10 @@ interface ActiveRemotePoller { |
| 44 | 44 | readonly subscriberCount: number; |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | interface StreamStatusOptions { | |
| 48 | readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>; | |
| 49 | } | |
| 50 | ||
| 47 | 51 | export interface VcsStatusBroadcasterShape { |
| 48 | 52 | readonly getStatus: ( |
| 49 | 53 | input: VcsStatusInput, |
| ... | ... | @@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape { |
| 54 | 58 | readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; |
| 55 | 59 | readonly streamStatus: ( |
| 56 | 60 | input: VcsStatusInput, |
| 61 | options?: StreamStatusOptions, | |
| 57 | 62 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; |
| 58 | 63 | } |
| 59 | 64 | |
| ... | ... | @@ -232,19 +237,32 @@ export const layer = Layer.effect( |
| 232 | 237 | return mergeGitStatusParts(local, remote); |
| 233 | 238 | }); |
| 234 | 239 | |
| 235 | const makeRemoteRefreshLoop = (cwd: string) => { | |
| 236 | const logRefreshFailure = (error: Error) => | |
| 240 | const makeRemoteRefreshLoop = ( | |
| 241 | cwd: string, | |
| 242 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, | |
| 243 | ) => { | |
| 244 | const logRefreshFailure = (error: GitManagerServiceError) => | |
| 237 | 245 | Effect.logWarning("VCS remote status refresh failed", { |
| 238 | 246 | cwd, |
| 239 | 247 | detail: error.message, |
| 240 | 248 | }); |
| 249 | const refreshRemoteStatusIfEnabled = automaticRemoteRefreshInterval.pipe( | |
| 250 | Effect.flatMap((interval) => | |
| 251 | Duration.isZero(interval) ? Effect.void : refreshRemoteStatus(cwd).pipe(Effect.asVoid), | |
| 252 | ), | |
| 253 | ); | |
| 254 | const sleepForConfiguredInterval = automaticRemoteRefreshInterval.pipe( | |
| 255 | Effect.flatMap((interval) => | |
| 256 | Effect.sleep(Duration.isZero(interval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : interval), | |
| 257 | ), | |
| 258 | ); | |
| 241 | 259 | |
| 242 | return refreshRemoteStatus(cwd).pipe( | |
| 260 | return refreshRemoteStatusIfEnabled.pipe( | |
| 243 | 261 | Effect.catch(logRefreshFailure), |
| 244 | 262 | Effect.andThen( |
| 245 | 263 | Effect.forever( |
| 246 | Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe( | |
| 247 | Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), | |
| 264 | sleepForConfiguredInterval.pipe( | |
| 265 | Effect.andThen(refreshRemoteStatusIfEnabled.pipe(Effect.catch(logRefreshFailure))), | |
| 248 | 266 | ), |
| 249 | 267 | ), |
| 250 | 268 | ), |
| ... | ... | @@ -253,6 +271,7 @@ export const layer = Layer.effect( |
| 253 | 271 | |
| 254 | 272 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( |
| 255 | 273 | cwd: string, |
| 274 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, | |
| 256 | 275 | ) { |
| 257 | 276 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { |
| 258 | 277 | const existing = activePollers.get(cwd); |
| ... | ... | @@ -265,7 +284,7 @@ export const layer = Layer.effect( |
| 265 | 284 | return Effect.succeed([undefined, nextPollers] as const); |
| 266 | 285 | } |
| 267 | 286 | |
| 268 | return makeRemoteRefreshLoop(cwd).pipe( | |
| 287 | return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe( | |
| 269 | 288 | Effect.forkIn(broadcasterScope), |
| 270 | 289 | Effect.map((fiber) => { |
| 271 | 290 | const nextPollers = new Map(activePollers); |
| ... | ... | @@ -307,14 +326,18 @@ export const layer = Layer.effect( |
| 307 | 326 | } |
| 308 | 327 | }); |
| 309 | 328 | |
| 310 | const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) => | |
| 329 | const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => | |
| 311 | 330 | Stream.unwrap( |
| 312 | 331 | Effect.gen(function* () { |
| 313 | 332 | const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); |
| 314 | 333 | const subscription = yield* PubSub.subscribe(changesPubSub); |
| 315 | 334 | const initialLocal = yield* getOrLoadLocalStatus(cwd); |
| 316 | 335 | const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; |
| 317 | yield* retainRemotePoller(cwd); | |
| 336 | yield* retainRemotePoller( | |
| 337 | cwd, | |
| 338 | options?.automaticRemoteRefreshInterval ?? | |
| 339 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), | |
| 340 | ); | |
| 318 | 341 | |
| 319 | 342 | const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); |
| 320 | 343 |
apps/server/src/ws.ts+12-1| ... | ... | @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | import * as Stream from "effect/Stream"; |
| 11 | 11 | import { |
| 12 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, | |
| 12 | 13 | type AuthAccessStreamEvent, |
| 13 | 14 | AuthSessionId, |
| 14 | 15 | CommandId, |
| ... | ... | @@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 180 | 181 | const serverEnvironment = yield* ServerEnvironment; |
| 181 | 182 | const serverAuth = yield* ServerAuth; |
| 182 | 183 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; |
| 184 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( | |
| 185 | Effect.map((settings) => settings.automaticGitFetchInterval), | |
| 186 | Effect.catch((cause) => | |
| 187 | Effect.logWarning("Failed to read automatic Git fetch interval setting", { | |
| 188 | detail: cause.message, | |
| 189 | }).pipe(Effect.as(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), | |
| 190 | ), | |
| 191 | ); | |
| 183 | 192 | const sourceControlRepositories = yield* SourceControlRepositoryService; |
| 184 | 193 | const bootstrapCredentials = yield* BootstrapCredentialService; |
| 185 | 194 | const sessions = yield* SessionCredentialService; |
| ... | ... | @@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 981 | 990 | [WS_METHODS.subscribeVcsStatus]: (input) => |
| 982 | 991 | observeRpcStream( |
| 983 | 992 | WS_METHODS.subscribeVcsStatus, |
| 984 | vcsStatusBroadcaster.streamStatus(input), | |
| 993 | vcsStatusBroadcaster.streamStatus(input, { | |
| 994 | automaticRemoteRefreshInterval: automaticGitFetchInterval, | |
| 995 | }), | |
| 985 | 996 | { |
| 986 | 997 | "rpc.aggregate": "vcs", |
| 987 | 998 | }, |
apps/web/src/components/ChatView.browser.tsx+5-2| ... | ... | @@ -18,11 +18,13 @@ import { |
| 18 | 18 | WS_METHODS, |
| 19 | 19 | OrchestrationSessionStatus, |
| 20 | 20 | DEFAULT_SERVER_SETTINGS, |
| 21 | ServerConfig as ServerConfigSchema, | |
| 21 | 22 | } from "@t3tools/contracts"; |
| 22 | 23 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| 23 | 24 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; |
| 24 | 25 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 25 | 26 | import * as Option from "effect/Option"; |
| 27 | import * as Schema from "effect/Schema"; | |
| 26 | 28 | import { HttpResponse, http, ws } from "msw"; |
| 27 | 29 | import { setupWorker } from "msw/browser"; |
| 28 | 30 | import { page } from "vitest/browser"; |
| ... | ... | @@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness(); |
| 106 | 108 | const wsRequests = rpcHarness.requests; |
| 107 | 109 | let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; |
| 108 | 110 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 111 | const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); | |
| 109 | 112 | |
| 110 | 113 | interface ViewportSpec { |
| 111 | 114 | name: string; |
| ... | ... | @@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 955 | 958 | } |
| 956 | 959 | const tag = body._tag; |
| 957 | 960 | if (tag === WS_METHODS.serverGetConfig) { |
| 958 | return fixture.serverConfig; | |
| 961 | return encodeServerConfig(fixture.serverConfig); | |
| 959 | 962 | } |
| 960 | 963 | if (tag === WS_METHODS.serverDiscoverSourceControl) { |
| 961 | 964 | return { |
| ... | ... | @@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1662 | 1665 | { |
| 1663 | 1666 | version: 1, |
| 1664 | 1667 | type: "snapshot", |
| 1665 | config: fixture.serverConfig, | |
| 1668 | config: encodeServerConfig(fixture.serverConfig), | |
| 1666 | 1669 | }, |
| 1667 | 1670 | ]; |
| 1668 | 1671 | } |
apps/web/src/components/KeybindingsToast.browser.tsx+8-3| ... | ... | @@ -11,12 +11,15 @@ import { |
| 11 | 11 | ProviderInstanceId, |
| 12 | 12 | type ServerConfig, |
| 13 | 13 | type ServerLifecycleWelcomePayload, |
| 14 | ServerConfig as ServerConfigSchema, | |
| 15 | ServerSettings, | |
| 14 | 16 | type ThreadId, |
| 15 | 17 | WS_METHODS, |
| 16 | 18 | } from "@t3tools/contracts"; |
| 17 | 19 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 18 | 20 | import { ws, http, HttpResponse } from "msw"; |
| 19 | 21 | import { setupWorker } from "msw/browser"; |
| 22 | import * as Schema from "effect/Schema"; | |
| 20 | 23 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| 21 | 24 | import { render } from "vitest-browser-react"; |
| 22 | 25 | |
| ... | ... | @@ -50,6 +53,8 @@ interface TestFixture { |
| 50 | 53 | |
| 51 | 54 | let fixture: TestFixture; |
| 52 | 55 | const rpcHarness = new BrowserWsRpcHarness(); |
| 56 | const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); | |
| 57 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | |
| 53 | 58 | |
| 54 | 59 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 55 | 60 | |
| ... | ... | @@ -254,7 +259,7 @@ function buildFixture(): TestFixture { |
| 254 | 259 | |
| 255 | 260 | function resolveWsRpc(tag: string): unknown { |
| 256 | 261 | if (tag === WS_METHODS.serverGetConfig) { |
| 257 | return fixture.serverConfig; | |
| 262 | return encodeServerConfig(fixture.serverConfig); | |
| 258 | 263 | } |
| 259 | 264 | if (tag === WS_METHODS.vcsListRefs) { |
| 260 | 265 | return { |
| ... | ... | @@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise<void> { |
| 394 | 399 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { |
| 395 | 400 | version: 1, |
| 396 | 401 | type: "settingsUpdated", |
| 397 | payload: { settings: fixture.serverConfig.settings }, | |
| 402 | payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, | |
| 398 | 403 | }); |
| 399 | 404 | |
| 400 | 405 | try { |
| ... | ... | @@ -485,7 +490,7 @@ describe("Keybindings update toast", () => { |
| 485 | 490 | { |
| 486 | 491 | version: 1, |
| 487 | 492 | type: "snapshot", |
| 488 | config: fixture.serverConfig, | |
| 493 | config: encodeServerConfig(fixture.serverConfig), | |
| 489 | 494 | }, |
| 490 | 495 | ]; |
| 491 | 496 | } |
apps/web/src/components/settings/SettingsPanels.browser.tsx+40-3| ... | ... | @@ -1256,10 +1256,47 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1256 | 1256 | </AppAtomRegistryProvider>, |
| 1257 | 1257 | ); |
| 1258 | 1258 | |
| 1259 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | |
| 1259 | await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); | |
| 1260 | 1260 | await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); |
| 1261 | 1261 | }); |
| 1262 | 1262 | |
| 1263 | it("shows Git fetch interval settings inside the Git details dropdown", async () => { | |
| 1264 | setSourceControlDiscoveryStub(async () => ({ | |
| 1265 | versionControlSystems: [ | |
| 1266 | { | |
| 1267 | kind: "git", | |
| 1268 | label: "Git", | |
| 1269 | executable: "git", | |
| 1270 | implemented: true, | |
| 1271 | status: "available", | |
| 1272 | version: Option.some("git version 2.50.0"), | |
| 1273 | installHint: "Install Git.", | |
| 1274 | detail: Option.none(), | |
| 1275 | }, | |
| 1276 | ], | |
| 1277 | sourceControlProviders: [], | |
| 1278 | })); | |
| 1279 | ||
| 1280 | mounted = await render( | |
| 1281 | <AppAtomRegistryProvider> | |
| 1282 | <SourceControlSettingsPanel /> | |
| 1283 | </AppAtomRegistryProvider>, | |
| 1284 | ); | |
| 1285 | ||
| 1286 | const toggle = page.getByRole("button", { name: "Toggle Git details" }); | |
| 1287 | await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); | |
| 1288 | ||
| 1289 | await toggle.click(); | |
| 1290 | ||
| 1291 | await expect.element(toggle).toHaveAttribute("aria-expanded", "true"); | |
| 1292 | await expect | |
| 1293 | .element(page.getByLabelText("Automatic Git fetch interval in seconds")) | |
| 1294 | .toBeVisible(); | |
| 1295 | await expect | |
| 1296 | .element(page.getByText("Automatic Git fetches run every 30 seconds")) | |
| 1297 | .not.toBeInTheDocument(); | |
| 1298 | }); | |
| 1299 | ||
| 1263 | 1300 | it("does not rescan on remount while the discovery atom is fresh", async () => { |
| 1264 | 1301 | let calls = 0; |
| 1265 | 1302 | setSourceControlDiscoveryStub(async () => { |
| ... | ... | @@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1287 | 1324 | </AppAtomRegistryProvider>, |
| 1288 | 1325 | ); |
| 1289 | 1326 | |
| 1290 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | |
| 1327 | await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); | |
| 1291 | 1328 | expect(calls).toBe(1); |
| 1292 | 1329 | |
| 1293 | 1330 | const teardown = mounted.cleanup ?? mounted.unmount; |
| ... | ... | @@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1301 | 1338 | </AppAtomRegistryProvider>, |
| 1302 | 1339 | ); |
| 1303 | 1340 | |
| 1304 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | |
| 1341 | await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled(); | |
| 1305 | 1342 | expect(calls).toBe(1); |
| 1306 | 1343 | }); |
| 1307 | 1344 | }); |
apps/web/src/components/settings/SettingsPanels.tsx+7| ... | ... | @@ -14,6 +14,7 @@ import { |
| 14 | 14 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 15 | 15 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 16 | 16 | import { createModelSelection } from "@t3tools/shared/model"; |
| 17 | import * as Duration from "effect/Duration"; | |
| 17 | 18 | import * as Equal from "effect/Equal"; |
| 18 | 19 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 19 | 20 | import { |
| ... | ... | @@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 407 | 408 | ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming |
| 408 | 409 | ? ["Assistant output"] |
| 409 | 410 | : []), |
| 411 | ...(Duration.toMillis(settings.automaticGitFetchInterval) !== | |
| 412 | Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) | |
| 413 | ? ["Automatic Git fetch interval"] | |
| 414 | : []), | |
| 410 | 415 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode |
| 411 | 416 | ? ["New thread mode"] |
| 412 | 417 | : []), |
| ... | ... | @@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 430 | 435 | settings.defaultThreadEnvMode, |
| 431 | 436 | settings.diffIgnoreWhitespace, |
| 432 | 437 | settings.diffWordWrap, |
| 438 | settings.automaticGitFetchInterval, | |
| 433 | 439 | settings.enableAssistantStreaming, |
| 434 | 440 | settings.sidebarThreadPreviewCount, |
| 435 | 441 | settings.timestampFormat, |
| ... | ... | @@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 455 | 461 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 456 | 462 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, |
| 457 | 463 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, |
| 464 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 458 | 465 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, |
| 459 | 466 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| 460 | 467 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
apps/web/src/components/settings/SourceControlSettings.tsx+124-8| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | |
| 1 | import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | |
| 2 | import * as Duration from "effect/Duration"; | |
| 2 | 3 | import * as Option from "effect/Option"; |
| 3 | import { type ReactNode } from "react"; | |
| 4 | import { useState, type ReactNode } from "react"; | |
| 4 | 5 | import type { |
| 5 | 6 | SourceControlProviderKind, |
| 6 | 7 | SourceControlDiscoveryResult, |
| ... | ... | @@ -9,7 +10,9 @@ import type { |
| 9 | 10 | VcsDriverKind, |
| 10 | 11 | VcsDiscoveryItem, |
| 11 | 12 | } from "@t3tools/contracts"; |
| 13 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | |
| 12 | 14 | |
| 15 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | |
| 13 | 16 | import { cn } from "../../lib/utils"; |
| 14 | 17 | import { |
| 15 | 18 | refreshSourceControlDiscovery, |
| ... | ... | @@ -17,6 +20,7 @@ import { |
| 17 | 20 | } from "../../lib/sourceControlDiscoveryState"; |
| 18 | 21 | import { Badge } from "../ui/badge"; |
| 19 | 22 | import { Button } from "../ui/button"; |
| 23 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | |
| 20 | 24 | import { |
| 21 | 25 | Empty, |
| 22 | 26 | EmptyContent, |
| ... | ... | @@ -26,6 +30,13 @@ import { |
| 26 | 30 | EmptyTitle, |
| 27 | 31 | } from "../ui/empty"; |
| 28 | 32 | import { Skeleton } from "../ui/skeleton"; |
| 33 | import { | |
| 34 | NumberField, | |
| 35 | NumberFieldDecrement, | |
| 36 | NumberFieldGroup, | |
| 37 | NumberFieldIncrement, | |
| 38 | NumberFieldInput, | |
| 39 | } from "../ui/number-field"; | |
| 29 | 40 | import { Switch } from "../ui/switch"; |
| 30 | 41 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 31 | 42 | import { |
| ... | ... | @@ -38,7 +49,7 @@ import { |
| 38 | 49 | type Icon, |
| 39 | 50 | } from "../Icons"; |
| 40 | 51 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 41 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | |
| 52 | import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; | |
| 42 | 53 | |
| 43 | 54 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 44 | 55 | versionControlSystems: [], |
| ... | ... | @@ -58,6 +69,18 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 58 | 69 | }; |
| 59 | 70 | |
| 60 | 71 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; |
| 72 | const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; | |
| 73 | ||
| 74 | function durationToSeconds(duration: Duration.Duration): number { | |
| 75 | return Math.round(Duration.toMillis(duration) / 1_000); | |
| 76 | } | |
| 77 | ||
| 78 | function normalizeFetchIntervalSeconds(value: number | null): number { | |
| 79 | if (value === null || !Number.isFinite(value)) { | |
| 80 | return 0; | |
| 81 | } | |
| 82 | return Math.max(0, Math.round(value)); | |
| 83 | } | |
| 61 | 84 | |
| 62 | 85 | function optionLabel(value: Option.Option<string>): string | null { |
| 63 | 86 | return Option.getOrNull(value); |
| ... | ... | @@ -146,7 +169,7 @@ function itemSummary({ |
| 146 | 169 | } |
| 147 | 170 | |
| 148 | 171 | if (item.status !== "available") { |
| 149 | return <span>Not available on this server — {item.installHint}</span>; | |
| 172 | return <span>Not available on this server: {item.installHint}</span>; | |
| 150 | 173 | } |
| 151 | 174 | |
| 152 | 175 | if (auth) { |
| ... | ... | @@ -189,8 +212,10 @@ function itemSummary({ |
| 189 | 212 | |
| 190 | 213 | function DiscoveryItemRow({ |
| 191 | 214 | item, |
| 215 | children, | |
| 192 | 216 | }: { |
| 193 | 217 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 218 | readonly children?: ReactNode; | |
| 194 | 219 | }) { |
| 195 | 220 | const version = optionLabel(item.version); |
| 196 | 221 | const enabled = |
| ... | ... | @@ -198,6 +223,8 @@ function DiscoveryItemRow({ |
| 198 | 223 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; |
| 199 | 224 | const authStatus = auth ? authPresentation(auth) : null; |
| 200 | 225 | const authAccount = auth ? optionLabel(auth.account) : null; |
| 226 | const [isExpanded, setIsExpanded] = useState(false); | |
| 227 | const hasDetails = children !== undefined; | |
| 201 | 228 | |
| 202 | 229 | return ( |
| 203 | 230 | <div |
| ... | ... | @@ -211,9 +238,9 @@ function DiscoveryItemRow({ |
| 211 | 238 | <div className="min-w-0 flex-1 space-y-1"> |
| 212 | 239 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 213 | 240 | <SourceControlItemMark item={item} /> |
| 214 | <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground"> | |
| 241 | <span className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground"> | |
| 215 | 242 | {item.label} |
| 216 | </h3> | |
| 243 | </span> | |
| 217 | 244 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} |
| 218 | 245 | {isVcsNotReady(item) ? ( |
| 219 | 246 | <Badge variant="warning" size="sm"> |
| ... | ... | @@ -231,12 +258,100 @@ function DiscoveryItemRow({ |
| 231 | 258 | </p> |
| 232 | 259 | </div> |
| 233 | 260 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 261 | {hasDetails ? ( | |
| 262 | <Button | |
| 263 | size="sm" | |
| 264 | variant="ghost" | |
| 265 | className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" | |
| 266 | onClick={() => setIsExpanded((open) => !open)} | |
| 267 | aria-expanded={isExpanded} | |
| 268 | aria-label={`Toggle ${item.label} details`} | |
| 269 | > | |
| 270 | <ChevronDownIcon | |
| 271 | className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")} | |
| 272 | /> | |
| 273 | </Button> | |
| 274 | ) : null} | |
| 234 | 275 | {!isVcsNotReady(item) ? ( |
| 235 | 276 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> |
| 236 | 277 | ) : null} |
| 237 | 278 | </div> |
| 238 | 279 | </div> |
| 239 | 280 | </div> |
| 281 | ||
| 282 | {hasDetails ? ( | |
| 283 | <Collapsible open={isExpanded} onOpenChange={setIsExpanded}> | |
| 284 | <CollapsibleContent> | |
| 285 | <div className="border-t border-border/60 px-4 py-3 sm:px-5">{children}</div> | |
| 286 | </CollapsibleContent> | |
| 287 | </Collapsible> | |
| 288 | ) : null} | |
| 289 | </div> | |
| 290 | ); | |
| 291 | } | |
| 292 | ||
| 293 | function GitFetchIntervalSettings() { | |
| 294 | const automaticGitFetchInterval = useSettings((settings) => settings.automaticGitFetchInterval); | |
| 295 | const { updateSettings } = useUpdateSettings(); | |
| 296 | const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); | |
| 297 | const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( | |
| 298 | DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 299 | ); | |
| 300 | const canResetFetchInterval = | |
| 301 | automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds; | |
| 302 | ||
| 303 | return ( | |
| 304 | <div className="grid gap-3"> | |
| 305 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> | |
| 306 | <div className="min-w-0 space-y-1"> | |
| 307 | <div className="flex min-w-0 items-center gap-1"> | |
| 308 | <span className="text-xs font-medium text-foreground">Fetch interval</span> | |
| 309 | <span | |
| 310 | className={cn( | |
| 311 | "inline-flex size-5 shrink-0 items-center justify-center transition-opacity", | |
| 312 | canResetFetchInterval ? "opacity-100" : "pointer-events-none opacity-0", | |
| 313 | )} | |
| 314 | aria-hidden={!canResetFetchInterval} | |
| 315 | > | |
| 316 | {canResetFetchInterval ? ( | |
| 317 | <SettingResetButton | |
| 318 | label="fetch interval" | |
| 319 | onClick={() => | |
| 320 | updateSettings({ | |
| 321 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | |
| 322 | }) | |
| 323 | } | |
| 324 | /> | |
| 325 | ) : null} | |
| 326 | </span> | |
| 327 | </div> | |
| 328 | <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground"> | |
| 329 | Refresh remote branch status in the background. Set this to 0 seconds if Git credentials | |
| 330 | or security keys should only be prompted by explicit Git actions. | |
| 331 | </p> | |
| 332 | </div> | |
| 333 | <div className="flex shrink-0 items-center gap-2"> | |
| 334 | <NumberField | |
| 335 | value={automaticGitFetchIntervalSeconds} | |
| 336 | min={0} | |
| 337 | step={GIT_FETCH_INTERVAL_STEP_SECONDS} | |
| 338 | size="sm" | |
| 339 | className="w-32" | |
| 340 | onValueChange={(value) => | |
| 341 | updateSettings({ | |
| 342 | automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), | |
| 343 | }) | |
| 344 | } | |
| 345 | > | |
| 346 | <NumberFieldGroup> | |
| 347 | <NumberFieldDecrement aria-label="Decrease fetch interval" /> | |
| 348 | <NumberFieldInput aria-label="Automatic Git fetch interval in seconds" /> | |
| 349 | <NumberFieldIncrement aria-label="Increase fetch interval" /> | |
| 350 | </NumberFieldGroup> | |
| 351 | </NumberField> | |
| 352 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 353 | </div> | |
| 354 | </div> | |
| 240 | 355 | </div> |
| 241 | 356 | ); |
| 242 | 357 | } |
| ... | ... | @@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({ |
| 324 | 439 | |
| 325 | 440 | export function SourceControlSettingsPanel() { |
| 326 | 441 | const discovery = useSourceControlDiscovery(); |
| 327 | ||
| 328 | 442 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; |
| 329 | 443 | const hasDiscoveryItems = |
| 330 | 444 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; |
| ... | ... | @@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() { |
| 364 | 478 | {result.versionControlSystems.length > 0 ? ( |
| 365 | 479 | <SettingsSection title="Version Control" headerAction={scanButton}> |
| 366 | 480 | {result.versionControlSystems.map((item) => ( |
| 367 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item} /> | |
| 481 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> | |
| 482 | {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined} | |
| 483 | </DiscoveryItemRow> | |
| 368 | 484 | ))} |
| 369 | 485 | </SettingsSection> |
| 370 | 486 | ) : null} |
apps/web/src/lib/archivedThreadsState.ts+3-2| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 3 | import { Cause, Effect, Option } from "effect"; | |
| 4 | 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 5 | 4 | import { useCallback, useMemo } from "react"; |
| 6 | ||
| 5 | import * as Cause from "effect/Cause"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Option from "effect/Option"; | |
| 7 | 8 | import { readEnvironmentApi } from "../environmentApi"; |
| 8 | 9 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 9 | 10 |
apps/web/src/rpc/wsTransport.test.ts+5-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 2 | 3 | import * as Stream from "effect/Stream"; |
| 3 | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | 5 | |
| ... | ... | @@ -18,6 +19,8 @@ import { |
| 18 | 19 | } from "../rpc/wsConnectionState"; |
| 19 | 20 | import { WsTransport } from "./wsTransport"; |
| 20 | 21 | |
| 22 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | |
| 23 | ||
| 21 | 24 | type WsEventType = "open" | "message" | "close" | "error"; |
| 22 | 25 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; |
| 23 | 26 | type WsListener = (event?: WsEvent) => void; |
| ... | ... | @@ -1222,7 +1225,7 @@ describe("WsTransport", () => { |
| 1222 | 1225 | requestId: requestMessage.id, |
| 1223 | 1226 | exit: { |
| 1224 | 1227 | _tag: "Success", |
| 1225 | value: DEFAULT_SERVER_SETTINGS, | |
| 1228 | value: encodeServerSettings(DEFAULT_SERVER_SETTINGS), | |
| 1226 | 1229 | }, |
| 1227 | 1230 | }), |
| 1228 | 1231 | ); |
packages/contracts/src/baseSchemas.ts+11-1| ... | ... | @@ -1,6 +1,16 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 1 | 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 2 | 4 | |
| 3 | export const TrimmedString = Schema.Trim; | |
| 5 | export const TrimmedString = Schema.String.pipe( | |
| 6 | Schema.decodeTo( | |
| 7 | Schema.String, | |
| 8 | SchemaTransformation.transformOrFail({ | |
| 9 | decode: (value) => Effect.succeed(value.trim()), | |
| 10 | encode: (value) => Effect.succeed(value.trim()), | |
| 11 | }), | |
| 12 | ), | |
| 13 | ); | |
| 4 | 14 | export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); |
| 5 | 15 | |
| 6 | 16 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
packages/contracts/src/providerInstance.test.ts+16| ... | ... | @@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => { |
| 109 | 109 | expect(decoded.config).toEqual(opaqueConfig); |
| 110 | 110 | }); |
| 111 | 111 | |
| 112 | it("trims provider instance envelope fields", () => { | |
| 113 | const decoded = decodeProviderInstanceConfig({ | |
| 114 | driver: " codex ", | |
| 115 | displayName: " Codex Personal ", | |
| 116 | accentColor: " #dc2626 ", | |
| 117 | environment: [{ name: " OPENROUTER_API_KEY ", value: " sk-or-test " }], | |
| 118 | }); | |
| 119 | ||
| 120 | expect(decoded).toMatchObject({ | |
| 121 | driver: "codex", | |
| 122 | displayName: "Codex Personal", | |
| 123 | accentColor: "#dc2626", | |
| 124 | environment: [{ name: "OPENROUTER_API_KEY", value: " sk-or-test " }], | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 112 | 128 | it("decodes generic environment variables on the instance envelope", () => { |
| 113 | 129 | const decoded = decodeProviderInstanceConfig({ |
| 114 | 130 | driver: "claudeAgent", |
packages/contracts/src/settings.test.ts+59| ... | ... | @@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./ |
| 6 | 6 | |
| 7 | 7 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 8 | 8 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 9 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | |
| 9 | 10 | |
| 10 | 11 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 11 | 12 | it("defaults to an empty record so legacy configs without the key still decode", () => { |
| ... | ... | @@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => { |
| 92 | 93 | expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama"); |
| 93 | 94 | }); |
| 94 | 95 | }); |
| 96 | ||
| 97 | describe("ServerSettingsPatch string normalization", () => { | |
| 98 | it("trims string settings while decoding patches", () => { | |
| 99 | const patch = decodeServerSettingsPatch({ | |
| 100 | addProjectBaseDirectory: " ~/Development ", | |
| 101 | textGenerationModelSelection: { model: " gpt-5.4-mini " }, | |
| 102 | observability: { | |
| 103 | otlpTracesUrl: " http://localhost:4318/v1/traces ", | |
| 104 | }, | |
| 105 | providers: { | |
| 106 | codex: { | |
| 107 | binaryPath: " /opt/homebrew/bin/codex ", | |
| 108 | homePath: " ~/.codex ", | |
| 109 | }, | |
| 110 | }, | |
| 111 | providerInstances: { | |
| 112 | codex_personal: { | |
| 113 | driver: " codex ", | |
| 114 | displayName: " Codex Personal ", | |
| 115 | config: { homePath: " ~/.codex-personal " }, | |
| 116 | }, | |
| 117 | }, | |
| 118 | }); | |
| 119 | ||
| 120 | expect(patch.addProjectBaseDirectory).toBe("~/Development"); | |
| 121 | expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); | |
| 122 | expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); | |
| 123 | expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); | |
| 124 | expect(patch.providers?.codex?.homePath).toBe("~/.codex"); | |
| 125 | expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( | |
| 126 | "codex", | |
| 127 | ); | |
| 128 | expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.displayName).toBe( | |
| 129 | "Codex Personal", | |
| 130 | ); | |
| 131 | expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.config).toEqual({ | |
| 132 | homePath: " ~/.codex-personal ", | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | it("trims encoded server settings values before validation", () => { | |
| 137 | const defaultSettings = decodeServerSettings({}); | |
| 138 | const encoded = encodeServerSettings({ | |
| 139 | ...defaultSettings, | |
| 140 | addProjectBaseDirectory: " ~/Development ", | |
| 141 | providers: { | |
| 142 | ...defaultSettings.providers, | |
| 143 | codex: { | |
| 144 | ...defaultSettings.providers.codex, | |
| 145 | binaryPath: " /opt/homebrew/bin/codex ", | |
| 146 | }, | |
| 147 | }, | |
| 148 | }); | |
| 149 | ||
| 150 | expect(encoded.addProjectBaseDirectory).toBe("~/Development"); | |
| 151 | expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); | |
| 152 | }); | |
| 153 | }); |
packages/contracts/src/settings.ts+23-14| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Duration from "effect/Duration"; | |
| 2 | 3 | import * as Schema from "effect/Schema"; |
| 3 | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | 5 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| ... | ... | @@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({ |
| 336 | 337 | }); |
| 337 | 338 | export type ObservabilitySettings = typeof ObservabilitySettings.Type; |
| 338 | 339 | |
| 340 | export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); | |
| 341 | ||
| 339 | 342 | export const ServerSettings = Schema.Struct({ |
| 340 | 343 | enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 344 | automaticGitFetchInterval: Schema.DurationFromMillis.pipe( | |
| 345 | Schema.withDecodingDefault( | |
| 346 | Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), | |
| 347 | ), | |
| 348 | ), | |
| 341 | 349 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 342 | 350 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 343 | 351 | ), |
| ... | ... | @@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({ |
| 408 | 416 | |
| 409 | 417 | const CodexSettingsPatch = Schema.Struct({ |
| 410 | 418 | enabled: Schema.optionalKey(Schema.Boolean), |
| 411 | binaryPath: Schema.optionalKey(Schema.String), | |
| 412 | homePath: Schema.optionalKey(Schema.String), | |
| 413 | shadowHomePath: Schema.optionalKey(Schema.String), | |
| 419 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 420 | homePath: Schema.optionalKey(TrimmedString), | |
| 421 | shadowHomePath: Schema.optionalKey(TrimmedString), | |
| 414 | 422 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 415 | 423 | }); |
| 416 | 424 | |
| 417 | 425 | const ClaudeSettingsPatch = Schema.Struct({ |
| 418 | 426 | enabled: Schema.optionalKey(Schema.Boolean), |
| 419 | binaryPath: Schema.optionalKey(Schema.String), | |
| 420 | homePath: Schema.optionalKey(Schema.String), | |
| 427 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 428 | homePath: Schema.optionalKey(TrimmedString), | |
| 421 | 429 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 422 | launchArgs: Schema.optionalKey(Schema.String), | |
| 430 | launchArgs: Schema.optionalKey(TrimmedString), | |
| 423 | 431 | }); |
| 424 | 432 | |
| 425 | 433 | const CursorSettingsPatch = Schema.Struct({ |
| 426 | 434 | enabled: Schema.optionalKey(Schema.Boolean), |
| 427 | binaryPath: Schema.optionalKey(Schema.String), | |
| 428 | apiEndpoint: Schema.optionalKey(Schema.String), | |
| 435 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 436 | apiEndpoint: Schema.optionalKey(TrimmedString), | |
| 429 | 437 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 430 | 438 | }); |
| 431 | 439 | |
| 432 | 440 | const OpenCodeSettingsPatch = Schema.Struct({ |
| 433 | 441 | enabled: Schema.optionalKey(Schema.Boolean), |
| 434 | binaryPath: Schema.optionalKey(Schema.String), | |
| 435 | serverUrl: Schema.optionalKey(Schema.String), | |
| 436 | serverPassword: Schema.optionalKey(Schema.String), | |
| 442 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 443 | serverUrl: Schema.optionalKey(TrimmedString), | |
| 444 | serverPassword: Schema.optionalKey(TrimmedString), | |
| 437 | 445 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 438 | 446 | }); |
| 439 | 447 | |
| 440 | 448 | export const ServerSettingsPatch = Schema.Struct({ |
| 441 | 449 | // Server settings |
| 442 | 450 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 451 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), | |
| 443 | 452 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 444 | addProjectBaseDirectory: Schema.optionalKey(Schema.String), | |
| 453 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), | |
| 445 | 454 | textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), |
| 446 | 455 | observability: Schema.optionalKey( |
| 447 | 456 | Schema.Struct({ |
| 448 | otlpTracesUrl: Schema.optionalKey(Schema.String), | |
| 449 | otlpMetricsUrl: Schema.optionalKey(Schema.String), | |
| 457 | otlpTracesUrl: Schema.optionalKey(TrimmedString), | |
| 458 | otlpMetricsUrl: Schema.optionalKey(TrimmedString), | |
| 450 | 459 | }), |
| 451 | 460 | ), |
| 452 | 461 | providers: Schema.optionalKey( |
packages/shared/src/serverSettings.ts+9-8| ... | ... | @@ -76,14 +76,15 @@ export function applyServerSettingsPatch( |
| 76 | 76 | patch: ServerSettingsPatch, |
| 77 | 77 | ): ServerSettings { |
| 78 | 78 | const selectionPatch = patch.textGenerationModelSelection; |
| 79 | const next = deepMerge(current, patch); | |
| 80 | const nextWithReplacements = | |
| 81 | patch.providerInstances !== undefined | |
| 82 | ? { | |
| 83 | ...next, | |
| 84 | providerInstances: patch.providerInstances, | |
| 85 | } | |
| 86 | : next; | |
| 79 | const { automaticGitFetchInterval, ...patchForMerge } = patch; | |
| 80 | const next = deepMerge(current, patchForMerge); | |
| 81 | const nextWithReplacements = { | |
| 82 | ...next, | |
| 83 | ...(patch.providerInstances !== undefined | |
| 84 | ? { providerInstances: patch.providerInstances } | |
| 85 | : {}), | |
| 86 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), | |
| 87 | }; | |
| 87 | 88 | if (!selectionPatch) { |
| 88 | 89 | return nextWithReplacements; |
| 89 | 90 | } |