| 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,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1601 | hasActionableProposedPlan: row.hasActionableProposedPlan > 0, | 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 | return yield* decodeShellSnapshot(snapshot).pipe( | 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,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 27 | import { ChildProcessSpawner } from "effect/unstable/process"; | 27 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 28 | import { deepMerge } from "@t3tools/shared/Struct"; | 28 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 29 | import { createModelCapabilities } from "@t3tools/shared/model"; | 29 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 30 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 30 | 31 | ||
| 31 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | 32 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 32 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | 33 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| ... | @@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t | ... | @@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t |
| 48 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; | 49 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| 49 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; | 50 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| 50 | const decodeServerSettings = Schema.decodeSync(ServerSettings); | 51 | const decodeServerSettings = Schema.decodeSync(ServerSettings); |
| 52 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 53 | const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); | ||
| 51 | 54 | ||
| 52 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | 55 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 53 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | 56 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| ... | @@ -256,7 +259,8 @@ function makeMutableServerSettingsService( | ... | @@ -256,7 +259,8 @@ function makeMutableServerSettingsService( |
| 256 | updateSettings: (patch) => | 259 | updateSettings: (patch) => |
| 257 | Effect.gen(function* () { | 260 | Effect.gen(function* () { |
| 258 | const current = yield* Ref.get(settingsRef); | 261 | const current = yield* Ref.get(settingsRef); |
| 259 | const next = decodeServerSettings(deepMerge(current, patch)); | 262 | const next = applyServerSettingsPatch(current, patch); |
| 263 | encodeServerSettings(next); | ||
| 260 | yield* Ref.set(settingsRef, next); | 264 | yield* Ref.set(settingsRef, next); |
| 261 | yield* PubSub.publish(changes, next); | 265 | yield* PubSub.publish(changes, next); |
| 262 | return next; | 266 | return next; |
| ... | @@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 930 | const missingBinary = `t3code_codex_missing_`; | 934 | const missingBinary = `t3code_codex_missing_`; |
| 931 | const serverSettings = yield* makeMutableServerSettingsService( | 935 | const serverSettings = yield* makeMutableServerSettingsService( |
| 932 | decodeServerSettings( | 936 | decodeServerSettings( |
| 933 | deepMerge(DEFAULT_SERVER_SETTINGS, { | 937 | deepMerge(encodedDefaultServerSettings, { |
| 934 | providers: { | 938 | providers: { |
| 935 | // Disable every built-in probe that would otherwise spawn | 939 | // Disable every built-in probe that would otherwise spawn |
| 936 | // on the CI host. `enabled: false` short-circuits each | 940 | // on the CI host. `enabled: false` short-circuits each |
| ... | @@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1029 | const secondMissing = `t3code_codex_second_`; | 1033 | const secondMissing = `t3code_codex_second_`; |
| 1030 | const serverSettings = yield* makeMutableServerSettingsService( | 1034 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1031 | decodeServerSettings( | 1035 | decodeServerSettings( |
| 1032 | deepMerge(DEFAULT_SERVER_SETTINGS, { | 1036 | deepMerge(encodedDefaultServerSettings, { |
| 1033 | providers: { | 1037 | providers: { |
| 1034 | codex: { enabled: true, binaryPath: firstMissing }, | 1038 | codex: { enabled: true, binaryPath: firstMissing }, |
| 1035 | claudeAgent: { enabled: false }, | 1039 | claudeAgent: { enabled: false }, |
| ... | @@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1124 | Effect.gen(function* () { | 1128 | Effect.gen(function* () { |
| 1125 | const serverSettings = yield* makeMutableServerSettingsService( | 1129 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1126 | decodeServerSettings( | 1130 | decodeServerSettings( |
| 1127 | deepMerge(DEFAULT_SERVER_SETTINGS, { | 1131 | deepMerge(encodedDefaultServerSettings, { |
| 1128 | providers: { | 1132 | providers: { |
| 1129 | codex: { enabled: false }, | 1133 | codex: { enabled: false }, |
| 1130 | claudeAgent: { enabled: false }, | 1134 | claudeAgent: { enabled: false }, |
| ... | @@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T | ... | @@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T |
| 1180 | Effect.gen(function* () { | 1184 | Effect.gen(function* () { |
| 1181 | const serverSettings = yield* makeMutableServerSettingsService( | 1185 | const serverSettings = yield* makeMutableServerSettingsService( |
| 1182 | decodeServerSettings( | 1186 | decodeServerSettings( |
| 1183 | deepMerge(DEFAULT_SERVER_SETTINGS, { | 1187 | deepMerge(encodedDefaultServerSettings, { |
| 1184 | providers: { | 1188 | providers: { |
| 1185 | codex: { | 1189 | codex: { |
| 1186 | enabled: false, | 1190 | enabled: false, |
apps/server/src/server.test.ts+1-1| ... | @@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: { |
| 635 | snapshotSequence: 0, | 635 | snapshotSequence: 0, |
| 636 | projects: [], | 636 | projects: [], |
| 637 | threads: [], | 637 | threads: [], |
| 638 | updatedAt: new Date(0).toISOString(), | 638 | updatedAt: "1970-01-01T00:00:00.000Z", |
| 639 | }), | 639 | }), |
| 640 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), | 640 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), |
| 641 | getProjectShellById: () => Effect.succeed(Option.none()), | 641 | getProjectShellById: () => Effect.succeed(Option.none()), |
apps/server/src/serverSettings.test.ts+3| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | import { assert, it } from "@effect/vitest"; | 10 | import { assert, it } from "@effect/vitest"; |
| 11 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | 13 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 14 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| ... | @@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 437 | serverPassword: "secret-password", | 438 | serverPassword: "secret-password", |
| 438 | }, | 439 | }, |
| 439 | }, | 440 | }, |
| 441 | automaticGitFetchInterval: Duration.seconds(10), | ||
| 440 | }); | 442 | }); |
| 441 | 443 | ||
| 442 | assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); | 444 | assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); |
| ... | @@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 458 | serverPassword: "secret-password", | 460 | serverPassword: "secret-password", |
| 459 | }, | 461 | }, |
| 460 | }, | 462 | }, |
| 463 | automaticGitFetchInterval: 10_000, | ||
| 461 | }); | 464 | }); |
| 462 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 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,15 +45,33 @@ import * as Semaphore from "effect/Semaphore"; |
| 45 | import { writeFileStringAtomically } from "./atomicWrite.ts"; | 45 | import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 46 | import { ServerConfig } from "./config.ts"; | 46 | import { ServerConfig } from "./config.ts"; |
| 47 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; | 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 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 49 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 50 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 50 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 51 | import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts"; | 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 | const textEncoder = new TextEncoder(); | 57 | const textEncoder = new TextEncoder(); |
| 55 | const textDecoder = new TextDecoder(); | 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 | function providerEnvironmentSecretName(input: { | 75 | function providerEnvironmentSecretName(input: { |
| 58 | readonly instanceId: string; | 76 | readonly instanceId: string; |
| 59 | readonly name: string; | 77 | readonly name: string; |
| ... | @@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service< | ... | @@ -117,9 +135,15 @@ export class ServerSettingsService extends Context.Service< |
| 117 | Layer.effect( | 135 | Layer.effect( |
| 118 | ServerSettingsService, | 136 | ServerSettingsService, |
| 119 | Effect.gen(function* () { | 137 | Effect.gen(function* () { |
| 120 | const currentSettingsRef = yield* Ref.make<ServerSettings>( | 138 | const { automaticGitFetchInterval, ...overridesForMerge } = overrides; |
| 121 | deepMerge(DEFAULT_SERVER_SETTINGS, overrides), | 139 | const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge); |
| 122 | ); | 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 | return { | 148 | return { |
| 125 | start: Effect.void, | 149 | start: Effect.void, |
| ... | @@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service< | ... | @@ -127,18 +151,8 @@ export class ServerSettingsService extends Context.Service< |
| 127 | getSettings: Ref.get(currentSettingsRef), | 151 | getSettings: Ref.get(currentSettingsRef), |
| 128 | updateSettings: (patch) => | 152 | updateSettings: (patch) => |
| 129 | Ref.get(currentSettingsRef).pipe( | 153 | Ref.get(currentSettingsRef).pipe( |
| 130 | Effect.flatMap((currentSettings) => | 154 | Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), |
| 131 | decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe( | 155 | Effect.flatMap(normalizeServerSettings), |
| 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 | ), | ||
| 142 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), | 156 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), |
| 143 | ), | 157 | ), |
| 144 | streamChanges: Stream.empty, | 158 | streamChanges: Stream.empty, |
| ... | @@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting | ... | @@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting |
| 200 | } | 214 | } |
| 201 | 215 | ||
| 202 | // Values under these keys are compared as a whole — never stripped field-by-field. | 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 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { | 222 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { |
| 206 | if (Array.isArray(current) || Array.isArray(defaults)) { | 223 | if (Array.isArray(current) || Array.isArray(defaults)) { |
| ... | @@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () { |
| 430 | }; | 447 | }; |
| 431 | }); | 448 | }); |
| 432 | 449 | ||
| 433 | const writeSettingsAtomically = (settings: ServerSettings) => { | 450 | const writeSettingsAtomically = Effect.fnUntraced( |
| 434 | const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; | 451 | function* (settings: ServerSettings) { |
| 452 | const sparseSettingsJson = yield* encodeServerSettingsJson( | ||
| 453 | stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, | ||
| 454 | ); | ||
| 435 | 455 | ||
| 436 | return writeFileStringAtomically({ | 456 | return yield* writeFileStringAtomically({ |
| 437 | filePath: settingsPath, | 457 | filePath: settingsPath, |
| 438 | contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, | 458 | contents: `${sparseSettingsJson}\n`, |
| 439 | }).pipe( | 459 | }).pipe( |
| 440 | Effect.provideService(FileSystem.FileSystem, fs), | 460 | Effect.provideService(FileSystem.FileSystem, fs), |
| 441 | Effect.provideService(Path.Path, pathService), | 461 | Effect.provideService(Path.Path, pathService), |
| 442 | Effect.mapError( | 462 | ); |
| 443 | (cause) => | 463 | }, |
| 444 | new ServerSettingsError({ | 464 | Effect.mapError( |
| 445 | settingsPath, | 465 | (cause) => |
| 446 | detail: "failed to write settings file", | 466 | new ServerSettingsError({ |
| 447 | cause, | 467 | settingsPath, |
| 448 | }), | 468 | detail: "failed to write settings file", |
| 449 | ), | 469 | cause, |
| 450 | ); | 470 | }), |
| 451 | }; | 471 | ), |
| 472 | ); | ||
| 452 | 473 | ||
| 453 | const revalidateAndEmit = writeSemaphore.withPermits(1)( | 474 | const revalidateAndEmit = writeSemaphore.withPermits(1)( |
| 454 | Effect.gen(function* () { | 475 | Effect.gen(function* () { |
| ... | @@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () { | ... | @@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () { |
| 533 | current, | 554 | current, |
| 534 | applyServerSettingsPatch(current, patch), | 555 | applyServerSettingsPatch(current, patch), |
| 535 | ); | 556 | ); |
| 536 | const next = yield* decodeServerSettings(nextPersisted).pipe( | 557 | const next = yield* normalizeServerSettings(nextPersisted); |
| 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 | ); | ||
| 546 | yield* writeSettingsAtomically(next); | 558 | yield* writeSettingsAtomically(next); |
| 547 | yield* Cache.set(settingsCache, cacheKey, next); | 559 | yield* Cache.set(settingsCache, cacheKey, next); |
| 548 | yield* emitChange(next); | 560 | yield* emitChange(next); |
apps/server/src/vcs/GitVcsDriverCore.test.ts+59| ... | @@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ... | @@ -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 | it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => | 192 | it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () => |
| 134 | Effect.gen(function* () { | 193 | Effect.gen(function* () { |
| 135 | const cwd = yield* makeTmpDir(); | 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,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 41 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | 41 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 42 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | 42 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 43 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 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 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; | 47 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 45 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | 48 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; |
| 46 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ | 49 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({ |
| ... | @@ -72,6 +75,7 @@ interface ExecuteGitOptions { | ... | @@ -72,6 +75,7 @@ interface ExecuteGitOptions { |
| 72 | timeoutMs?: number | undefined; | 75 | timeoutMs?: number | undefined; |
| 73 | allowNonZeroExit?: boolean | undefined; | 76 | allowNonZeroExit?: boolean | undefined; |
| 74 | fallbackErrorMessage?: string | undefined; | 77 | fallbackErrorMessage?: string | undefined; |
| 78 | env?: NodeJS.ProcessEnv | undefined; | ||
| 75 | maxOutputBytes?: number | undefined; | 79 | maxOutputBytes?: number | undefined; |
| 76 | truncateOutputAtMaxBytes?: boolean | undefined; | 80 | truncateOutputAtMaxBytes?: boolean | undefined; |
| 77 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; | 81 | progress?: GitVcsDriver.ExecuteGitProgress | undefined; |
| ... | @@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 738 | cwd, | 742 | cwd, |
| 739 | args, | 743 | args, |
| 740 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), | 744 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), |
| 745 | ...(options.env !== undefined ? { env: options.env } : {}), | ||
| 741 | allowNonZeroExit: true, | 746 | allowNonZeroExit: true, |
| 742 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | 747 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 743 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | 748 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), |
| ... | @@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 870 | ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], | 875 | ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], |
| 871 | { | 876 | { |
| 872 | allowNonZeroExit: true, | 877 | allowNonZeroExit: true, |
| 878 | env: STATUS_UPSTREAM_REFRESH_ENV, | ||
| 873 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), | 879 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), |
| 874 | }, | 880 | }, |
| 875 | ).pipe(Effect.asVoid); | 881 | ).pipe(Effect.asVoid); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+26| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 6 | import * as Exit from "effect/Exit"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => { |
| 284 | }).pipe(Effect.provide(makeTestLayer(state))); | 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 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { | 313 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { |
| 288 | const state = { | 314 | const state = { |
| 289 | currentLocalStatus: baseLocalStatus, | 315 | currentLocalStatus: baseLocalStatus, |
apps/server/src/vcs/VcsStatusBroadcaster.ts+32-9| ... | @@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; | ... | @@ -22,7 +22,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 22 | 22 | ||
| 23 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; | 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 | interface VcsStatusChange { | 27 | interface VcsStatusChange { |
| 28 | readonly cwd: string; | 28 | readonly cwd: string; |
| ... | @@ -44,6 +44,10 @@ interface ActiveRemotePoller { | ... | @@ -44,6 +44,10 @@ interface ActiveRemotePoller { |
| 44 | readonly subscriberCount: number; | 44 | readonly subscriberCount: number; |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 47 | interface StreamStatusOptions { | ||
| 48 | readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>; | ||
| 49 | } | ||
| 50 | |||
| 47 | export interface VcsStatusBroadcasterShape { | 51 | export interface VcsStatusBroadcasterShape { |
| 48 | readonly getStatus: ( | 52 | readonly getStatus: ( |
| 49 | input: VcsStatusInput, | 53 | input: VcsStatusInput, |
| ... | @@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape { | ... | @@ -54,6 +58,7 @@ export interface VcsStatusBroadcasterShape { |
| 54 | readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; | 58 | readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; |
| 55 | readonly streamStatus: ( | 59 | readonly streamStatus: ( |
| 56 | input: VcsStatusInput, | 60 | input: VcsStatusInput, |
| 61 | options?: StreamStatusOptions, | ||
| 57 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; | 62 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; |
| 58 | } | 63 | } |
| 59 | 64 | ||
| ... | @@ -232,19 +237,32 @@ export const layer = Layer.effect( | ... | @@ -232,19 +237,32 @@ export const layer = Layer.effect( |
| 232 | return mergeGitStatusParts(local, remote); | 237 | return mergeGitStatusParts(local, remote); |
| 233 | }); | 238 | }); |
| 234 | 239 | ||
| 235 | const makeRemoteRefreshLoop = (cwd: string) => { | 240 | const makeRemoteRefreshLoop = ( |
| 236 | const logRefreshFailure = (error: Error) => | 241 | cwd: string, |
| 242 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, | ||
| 243 | ) => { | ||
| 244 | const logRefreshFailure = (error: GitManagerServiceError) => | ||
| 237 | Effect.logWarning("VCS remote status refresh failed", { | 245 | Effect.logWarning("VCS remote status refresh failed", { |
| 238 | cwd, | 246 | cwd, |
| 239 | detail: error.message, | 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 | Effect.catch(logRefreshFailure), | 261 | Effect.catch(logRefreshFailure), |
| 244 | Effect.andThen( | 262 | Effect.andThen( |
| 245 | Effect.forever( | 263 | Effect.forever( |
| 246 | Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe( | 264 | sleepForConfiguredInterval.pipe( |
| 247 | Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), | 265 | Effect.andThen(refreshRemoteStatusIfEnabled.pipe(Effect.catch(logRefreshFailure))), |
| 248 | ), | 266 | ), |
| 249 | ), | 267 | ), |
| 250 | ), | 268 | ), |
| ... | @@ -253,6 +271,7 @@ export const layer = Layer.effect( | ... | @@ -253,6 +271,7 @@ export const layer = Layer.effect( |
| 253 | 271 | ||
| 254 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( | 272 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( |
| 255 | cwd: string, | 273 | cwd: string, |
| 274 | automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>, | ||
| 256 | ) { | 275 | ) { |
| 257 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { | 276 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { |
| 258 | const existing = activePollers.get(cwd); | 277 | const existing = activePollers.get(cwd); |
| ... | @@ -265,7 +284,7 @@ export const layer = Layer.effect( | ... | @@ -265,7 +284,7 @@ export const layer = Layer.effect( |
| 265 | return Effect.succeed([undefined, nextPollers] as const); | 284 | return Effect.succeed([undefined, nextPollers] as const); |
| 266 | } | 285 | } |
| 267 | 286 | ||
| 268 | return makeRemoteRefreshLoop(cwd).pipe( | 287 | return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval).pipe( |
| 269 | Effect.forkIn(broadcasterScope), | 288 | Effect.forkIn(broadcasterScope), |
| 270 | Effect.map((fiber) => { | 289 | Effect.map((fiber) => { |
| 271 | const nextPollers = new Map(activePollers); | 290 | const nextPollers = new Map(activePollers); |
| ... | @@ -307,14 +326,18 @@ export const layer = Layer.effect( | ... | @@ -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 | Stream.unwrap( | 330 | Stream.unwrap( |
| 312 | Effect.gen(function* () { | 331 | Effect.gen(function* () { |
| 313 | const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); | 332 | const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); |
| 314 | const subscription = yield* PubSub.subscribe(changesPubSub); | 333 | const subscription = yield* PubSub.subscribe(changesPubSub); |
| 315 | const initialLocal = yield* getOrLoadLocalStatus(cwd); | 334 | const initialLocal = yield* getOrLoadLocalStatus(cwd); |
| 316 | const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; | 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 | const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); | 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,6 +9,7 @@ import * as Ref from "effect/Ref"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as Stream from "effect/Stream"; | 10 | import * as Stream from "effect/Stream"; |
| 11 | import { | 11 | import { |
| 12 | DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, | ||
| 12 | type AuthAccessStreamEvent, | 13 | type AuthAccessStreamEvent, |
| 13 | AuthSessionId, | 14 | AuthSessionId, |
| 14 | CommandId, | 15 | CommandId, |
| ... | @@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -180,6 +181,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 180 | const serverEnvironment = yield* ServerEnvironment; | 181 | const serverEnvironment = yield* ServerEnvironment; |
| 181 | const serverAuth = yield* ServerAuth; | 182 | const serverAuth = yield* ServerAuth; |
| 182 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; | 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 | const sourceControlRepositories = yield* SourceControlRepositoryService; | 192 | const sourceControlRepositories = yield* SourceControlRepositoryService; |
| 184 | const bootstrapCredentials = yield* BootstrapCredentialService; | 193 | const bootstrapCredentials = yield* BootstrapCredentialService; |
| 185 | const sessions = yield* SessionCredentialService; | 194 | const sessions = yield* SessionCredentialService; |
| ... | @@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -981,7 +990,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 981 | [WS_METHODS.subscribeVcsStatus]: (input) => | 990 | [WS_METHODS.subscribeVcsStatus]: (input) => |
| 982 | observeRpcStream( | 991 | observeRpcStream( |
| 983 | WS_METHODS.subscribeVcsStatus, | 992 | WS_METHODS.subscribeVcsStatus, |
| 984 | vcsStatusBroadcaster.streamStatus(input), | 993 | vcsStatusBroadcaster.streamStatus(input, { |
| 994 | automaticRemoteRefreshInterval: automaticGitFetchInterval, | ||
| 995 | }), | ||
| 985 | { | 996 | { |
| 986 | "rpc.aggregate": "vcs", | 997 | "rpc.aggregate": "vcs", |
| 987 | }, | 998 | }, |
apps/web/src/components/ChatView.browser.tsx+5-2| ... | @@ -18,11 +18,13 @@ import { | ... | @@ -18,11 +18,13 @@ import { |
| 18 | WS_METHODS, | 18 | WS_METHODS, |
| 19 | OrchestrationSessionStatus, | 19 | OrchestrationSessionStatus, |
| 20 | DEFAULT_SERVER_SETTINGS, | 20 | DEFAULT_SERVER_SETTINGS, |
| 21 | ServerConfig as ServerConfigSchema, | ||
| 21 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 22 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 23 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| 23 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; | 24 | import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; |
| 24 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | 25 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 25 | import * as Option from "effect/Option"; | 26 | import * as Option from "effect/Option"; |
| 27 | import * as Schema from "effect/Schema"; | ||
| 26 | import { HttpResponse, http, ws } from "msw"; | 28 | import { HttpResponse, http, ws } from "msw"; |
| 27 | import { setupWorker } from "msw/browser"; | 29 | import { setupWorker } from "msw/browser"; |
| 28 | import { page } from "vitest/browser"; | 30 | import { page } from "vitest/browser"; |
| ... | @@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness(); | ... | @@ -106,6 +108,7 @@ const rpcHarness = new BrowserWsRpcHarness(); |
| 106 | const wsRequests = rpcHarness.requests; | 108 | const wsRequests = rpcHarness.requests; |
| 107 | let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; | 109 | let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; |
| 108 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | 110 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 111 | const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); | ||
| 109 | 112 | ||
| 110 | interface ViewportSpec { | 113 | interface ViewportSpec { |
| 111 | name: string; | 114 | name: string; |
| ... | @@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -955,7 +958,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 955 | } | 958 | } |
| 956 | const tag = body._tag; | 959 | const tag = body._tag; |
| 957 | if (tag === WS_METHODS.serverGetConfig) { | 960 | if (tag === WS_METHODS.serverGetConfig) { |
| 958 | return fixture.serverConfig; | 961 | return encodeServerConfig(fixture.serverConfig); |
| 959 | } | 962 | } |
| 960 | if (tag === WS_METHODS.serverDiscoverSourceControl) { | 963 | if (tag === WS_METHODS.serverDiscoverSourceControl) { |
| 961 | return { | 964 | return { |
| ... | @@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1662,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1662 | { | 1665 | { |
| 1663 | version: 1, | 1666 | version: 1, |
| 1664 | type: "snapshot", | 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,12 +11,15 @@ import { |
| 11 | ProviderInstanceId, | 11 | ProviderInstanceId, |
| 12 | type ServerConfig, | 12 | type ServerConfig, |
| 13 | type ServerLifecycleWelcomePayload, | 13 | type ServerLifecycleWelcomePayload, |
| 14 | ServerConfig as ServerConfigSchema, | ||
| 15 | ServerSettings, | ||
| 14 | type ThreadId, | 16 | type ThreadId, |
| 15 | WS_METHODS, | 17 | WS_METHODS, |
| 16 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 17 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | 19 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 18 | import { ws, http, HttpResponse } from "msw"; | 20 | import { ws, http, HttpResponse } from "msw"; |
| 19 | import { setupWorker } from "msw/browser"; | 21 | import { setupWorker } from "msw/browser"; |
| 22 | import * as Schema from "effect/Schema"; | ||
| 20 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | 23 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| 21 | import { render } from "vitest-browser-react"; | 24 | import { render } from "vitest-browser-react"; |
| 22 | 25 | ||
| ... | @@ -50,6 +53,8 @@ interface TestFixture { | ... | @@ -50,6 +53,8 @@ interface TestFixture { |
| 50 | 53 | ||
| 51 | let fixture: TestFixture; | 54 | let fixture: TestFixture; |
| 52 | const rpcHarness = new BrowserWsRpcHarness(); | 55 | const rpcHarness = new BrowserWsRpcHarness(); |
| 56 | const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); | ||
| 57 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 53 | 58 | ||
| 54 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | 59 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 55 | 60 | ||
| ... | @@ -254,7 +259,7 @@ function buildFixture(): TestFixture { | ... | @@ -254,7 +259,7 @@ function buildFixture(): TestFixture { |
| 254 | 259 | ||
| 255 | function resolveWsRpc(tag: string): unknown { | 260 | function resolveWsRpc(tag: string): unknown { |
| 256 | if (tag === WS_METHODS.serverGetConfig) { | 261 | if (tag === WS_METHODS.serverGetConfig) { |
| 257 | return fixture.serverConfig; | 262 | return encodeServerConfig(fixture.serverConfig); |
| 258 | } | 263 | } |
| 259 | if (tag === WS_METHODS.vcsListRefs) { | 264 | if (tag === WS_METHODS.vcsListRefs) { |
| 260 | return { | 265 | return { |
| ... | @@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise<void> { | ... | @@ -394,7 +399,7 @@ async function waitForServerConfigStreamReady(): Promise<void> { |
| 394 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { | 399 | rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { |
| 395 | version: 1, | 400 | version: 1, |
| 396 | type: "settingsUpdated", | 401 | type: "settingsUpdated", |
| 397 | payload: { settings: fixture.serverConfig.settings }, | 402 | payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, |
| 398 | }); | 403 | }); |
| 399 | 404 | ||
| 400 | try { | 405 | try { |
| ... | @@ -485,7 +490,7 @@ describe("Keybindings update toast", () => { | ... | @@ -485,7 +490,7 @@ describe("Keybindings update toast", () => { |
| 485 | { | 490 | { |
| 486 | version: 1, | 491 | version: 1, |
| 487 | type: "snapshot", | 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,10 +1256,47 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1256 | </AppAtomRegistryProvider>, | 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 | await expect.element(page.getByText("Nothing detected yet")).not.toBeInTheDocument(); | 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 | it("does not rescan on remount while the discovery atom is fresh", async () => { | 1300 | it("does not rescan on remount while the discovery atom is fresh", async () => { |
| 1264 | let calls = 0; | 1301 | let calls = 0; |
| 1265 | setSourceControlDiscoveryStub(async () => { | 1302 | setSourceControlDiscoveryStub(async () => { |
| ... | @@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => { | ... | @@ -1287,7 +1324,7 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1287 | </AppAtomRegistryProvider>, | 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 | expect(calls).toBe(1); | 1328 | expect(calls).toBe(1); |
| 1292 | 1329 | ||
| 1293 | const teardown = mounted.cleanup ?? mounted.unmount; | 1330 | const teardown = mounted.cleanup ?? mounted.unmount; |
| ... | @@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => { | ... | @@ -1301,7 +1338,7 @@ describe("SourceControlSettingsPanel discovery states", () => { |
| 1301 | </AppAtomRegistryProvider>, | 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 | expect(calls).toBe(1); | 1342 | expect(calls).toBe(1); |
| 1306 | }); | 1343 | }); |
| 1307 | }); | 1344 | }); |
apps/web/src/components/settings/SettingsPanels.tsx+7| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 14 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 15 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 15 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 16 | import { createModelSelection } from "@t3tools/shared/model"; | 16 | import { createModelSelection } from "@t3tools/shared/model"; |
| 17 | import * as Duration from "effect/Duration"; | ||
| 17 | import * as Equal from "effect/Equal"; | 18 | import * as Equal from "effect/Equal"; |
| 18 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; | 19 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 19 | import { | 20 | import { |
| ... | @@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -407,6 +408,10 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 407 | ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming | 408 | ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming |
| 408 | ? ["Assistant output"] | 409 | ? ["Assistant output"] |
| 409 | : []), | 410 | : []), |
| 411 | ...(Duration.toMillis(settings.automaticGitFetchInterval) !== | ||
| 412 | Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) | ||
| 413 | ? ["Automatic Git fetch interval"] | ||
| 414 | : []), | ||
| 410 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode | 415 | ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode |
| 411 | ? ["New thread mode"] | 416 | ? ["New thread mode"] |
| 412 | : []), | 417 | : []), |
| ... | @@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -430,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 430 | settings.defaultThreadEnvMode, | 435 | settings.defaultThreadEnvMode, |
| 431 | settings.diffIgnoreWhitespace, | 436 | settings.diffIgnoreWhitespace, |
| 432 | settings.diffWordWrap, | 437 | settings.diffWordWrap, |
| 438 | settings.automaticGitFetchInterval, | ||
| 433 | settings.enableAssistantStreaming, | 439 | settings.enableAssistantStreaming, |
| 434 | settings.sidebarThreadPreviewCount, | 440 | settings.sidebarThreadPreviewCount, |
| 435 | settings.timestampFormat, | 441 | settings.timestampFormat, |
| ... | @@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -455,6 +461,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 455 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, | 461 | sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, |
| 456 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, | 462 | autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, |
| 457 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, | 463 | enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, |
| 464 | automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, | ||
| 458 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, | 465 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, |
| 459 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, | 466 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| 460 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, | 467 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
apps/web/src/components/settings/SourceControlSettings.tsx+124-8| ... | @@ -1,6 +1,7 @@ | ... | @@ -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 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 3 | import { type ReactNode } from "react"; | 4 | import { useState, type ReactNode } from "react"; |
| 4 | import type { | 5 | import type { |
| 5 | SourceControlProviderKind, | 6 | SourceControlProviderKind, |
| 6 | SourceControlDiscoveryResult, | 7 | SourceControlDiscoveryResult, |
| ... | @@ -9,7 +10,9 @@ import type { | ... | @@ -9,7 +10,9 @@ import type { |
| 9 | VcsDriverKind, | 10 | VcsDriverKind, |
| 10 | VcsDiscoveryItem, | 11 | VcsDiscoveryItem, |
| 11 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | ||
| 12 | 14 | ||
| 15 | import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; | ||
| 13 | import { cn } from "../../lib/utils"; | 16 | import { cn } from "../../lib/utils"; |
| 14 | import { | 17 | import { |
| 15 | refreshSourceControlDiscovery, | 18 | refreshSourceControlDiscovery, |
| ... | @@ -17,6 +20,7 @@ import { | ... | @@ -17,6 +20,7 @@ import { |
| 17 | } from "../../lib/sourceControlDiscoveryState"; | 20 | } from "../../lib/sourceControlDiscoveryState"; |
| 18 | import { Badge } from "../ui/badge"; | 21 | import { Badge } from "../ui/badge"; |
| 19 | import { Button } from "../ui/button"; | 22 | import { Button } from "../ui/button"; |
| 23 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | ||
| 20 | import { | 24 | import { |
| 21 | Empty, | 25 | Empty, |
| 22 | EmptyContent, | 26 | EmptyContent, |
| ... | @@ -26,6 +30,13 @@ import { | ... | @@ -26,6 +30,13 @@ import { |
| 26 | EmptyTitle, | 30 | EmptyTitle, |
| 27 | } from "../ui/empty"; | 31 | } from "../ui/empty"; |
| 28 | import { Skeleton } from "../ui/skeleton"; | 32 | import { Skeleton } from "../ui/skeleton"; |
| 33 | import { | ||
| 34 | NumberField, | ||
| 35 | NumberFieldDecrement, | ||
| 36 | NumberFieldGroup, | ||
| 37 | NumberFieldIncrement, | ||
| 38 | NumberFieldInput, | ||
| 39 | } from "../ui/number-field"; | ||
| 29 | import { Switch } from "../ui/switch"; | 40 | import { Switch } from "../ui/switch"; |
| 30 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 41 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 31 | import { | 42 | import { |
| ... | @@ -38,7 +49,7 @@ import { | ... | @@ -38,7 +49,7 @@ import { |
| 38 | type Icon, | 49 | type Icon, |
| 39 | } from "../Icons"; | 50 | } from "../Icons"; |
| 40 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 51 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 41 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | 52 | import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 42 | 53 | ||
| 43 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { | 54 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 44 | versionControlSystems: [], | 55 | versionControlSystems: [], |
| ... | @@ -58,6 +69,18 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { | ... | @@ -58,6 +69,18 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = { |
| 58 | }; | 69 | }; |
| 59 | 70 | ||
| 60 | const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; | 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 | function optionLabel(value: Option.Option<string>): string | null { | 85 | function optionLabel(value: Option.Option<string>): string | null { |
| 63 | return Option.getOrNull(value); | 86 | return Option.getOrNull(value); |
| ... | @@ -146,7 +169,7 @@ function itemSummary({ | ... | @@ -146,7 +169,7 @@ function itemSummary({ |
| 146 | } | 169 | } |
| 147 | 170 | ||
| 148 | if (item.status !== "available") { | 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 | if (auth) { | 175 | if (auth) { |
| ... | @@ -189,8 +212,10 @@ function itemSummary({ | ... | @@ -189,8 +212,10 @@ function itemSummary({ |
| 189 | 212 | ||
| 190 | function DiscoveryItemRow({ | 213 | function DiscoveryItemRow({ |
| 191 | item, | 214 | item, |
| 215 | children, | ||
| 192 | }: { | 216 | }: { |
| 193 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; | 217 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; |
| 218 | readonly children?: ReactNode; | ||
| 194 | }) { | 219 | }) { |
| 195 | const version = optionLabel(item.version); | 220 | const version = optionLabel(item.version); |
| 196 | const enabled = | 221 | const enabled = |
| ... | @@ -198,6 +223,8 @@ function DiscoveryItemRow({ | ... | @@ -198,6 +223,8 @@ function DiscoveryItemRow({ |
| 198 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; | 223 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; |
| 199 | const authStatus = auth ? authPresentation(auth) : null; | 224 | const authStatus = auth ? authPresentation(auth) : null; |
| 200 | const authAccount = auth ? optionLabel(auth.account) : null; | 225 | const authAccount = auth ? optionLabel(auth.account) : null; |
| 226 | const [isExpanded, setIsExpanded] = useState(false); | ||
| 227 | const hasDetails = children !== undefined; | ||
| 201 | 228 | ||
| 202 | return ( | 229 | return ( |
| 203 | <div | 230 | <div |
| ... | @@ -211,9 +238,9 @@ function DiscoveryItemRow({ | ... | @@ -211,9 +238,9 @@ function DiscoveryItemRow({ |
| 211 | <div className="min-w-0 flex-1 space-y-1"> | 238 | <div className="min-w-0 flex-1 space-y-1"> |
| 212 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | 239 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 213 | <SourceControlItemMark item={item} /> | 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 | {item.label} | 242 | {item.label} |
| 216 | </h3> | 243 | </span> |
| 217 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} | 244 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} |
| 218 | {isVcsNotReady(item) ? ( | 245 | {isVcsNotReady(item) ? ( |
| 219 | <Badge variant="warning" size="sm"> | 246 | <Badge variant="warning" size="sm"> |
| ... | @@ -231,12 +258,100 @@ function DiscoveryItemRow({ | ... | @@ -231,12 +258,100 @@ function DiscoveryItemRow({ |
| 231 | </p> | 258 | </p> |
| 232 | </div> | 259 | </div> |
| 233 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 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 | {!isVcsNotReady(item) ? ( | 275 | {!isVcsNotReady(item) ? ( |
| 235 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> | 276 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> |
| 236 | ) : null} | 277 | ) : null} |
| 237 | </div> | 278 | </div> |
| 238 | </div> | 279 | </div> |
| 239 | </div> | 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 | </div> | 355 | </div> |
| 241 | ); | 356 | ); |
| 242 | } | 357 | } |
| ... | @@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({ | ... | @@ -324,7 +439,6 @@ function EmptySourceControlDiscovery({ |
| 324 | 439 | ||
| 325 | export function SourceControlSettingsPanel() { | 440 | export function SourceControlSettingsPanel() { |
| 326 | const discovery = useSourceControlDiscovery(); | 441 | const discovery = useSourceControlDiscovery(); |
| 327 | |||
| 328 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; | 442 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; |
| 329 | const hasDiscoveryItems = | 443 | const hasDiscoveryItems = |
| 330 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; | 444 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; |
| ... | @@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() { | ... | @@ -364,7 +478,9 @@ export function SourceControlSettingsPanel() { |
| 364 | {result.versionControlSystems.length > 0 ? ( | 478 | {result.versionControlSystems.length > 0 ? ( |
| 365 | <SettingsSection title="Version Control" headerAction={scanButton}> | 479 | <SettingsSection title="Version Control" headerAction={scanButton}> |
| 366 | {result.versionControlSystems.map((item) => ( | 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 | </SettingsSection> | 485 | </SettingsSection> |
| 370 | ) : null} | 486 | ) : null} |
apps/web/src/lib/archivedThreadsState.ts+3-2| ... | @@ -1,9 +1,10 @@ | ... | @@ -1,9 +1,10 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | 2 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 3 | import { Cause, Effect, Option } from "effect"; | ||
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 5 | import { useCallback, useMemo } from "react"; | 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 | import { readEnvironmentApi } from "../environmentApi"; | 8 | import { readEnvironmentApi } from "../environmentApi"; |
| 8 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 9 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 9 | 10 |
apps/web/src/rpc/wsTransport.test.ts+5-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -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 | import * as Stream from "effect/Stream"; | 3 | import * as Stream from "effect/Stream"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | 5 | ||
| ... | @@ -18,6 +19,8 @@ import { | ... | @@ -18,6 +19,8 @@ import { |
| 18 | } from "../rpc/wsConnectionState"; | 19 | } from "../rpc/wsConnectionState"; |
| 19 | import { WsTransport } from "./wsTransport"; | 20 | import { WsTransport } from "./wsTransport"; |
| 20 | 21 | ||
| 22 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 23 | |||
| 21 | type WsEventType = "open" | "message" | "close" | "error"; | 24 | type WsEventType = "open" | "message" | "close" | "error"; |
| 22 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | 25 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; |
| 23 | type WsListener = (event?: WsEvent) => void; | 26 | type WsListener = (event?: WsEvent) => void; |
| ... | @@ -1222,7 +1225,7 @@ describe("WsTransport", () => { | ... | @@ -1222,7 +1225,7 @@ describe("WsTransport", () => { |
| 1222 | requestId: requestMessage.id, | 1225 | requestId: requestMessage.id, |
| 1223 | exit: { | 1226 | exit: { |
| 1224 | _tag: "Success", | 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,6 +1,16 @@ |
| 1 | import * as Effect from "effect/Effect"; | ||
| 1 | import * as Schema from "effect/Schema"; | 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 | export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); | 14 | export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); |
| 5 | 15 | ||
| 6 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | 16 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
packages/contracts/src/providerInstance.test.ts+16| ... | @@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => { | ... | @@ -109,6 +109,22 @@ describe("ProviderInstanceConfig", () => { |
| 109 | expect(decoded.config).toEqual(opaqueConfig); | 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 | it("decodes generic environment variables on the instance envelope", () => { | 128 | it("decodes generic environment variables on the instance envelope", () => { |
| 113 | const decoded = decodeProviderInstanceConfig({ | 129 | const decoded = decodeProviderInstanceConfig({ |
| 114 | driver: "claudeAgent", | 130 | driver: "claudeAgent", |
packages/contracts/src/settings.test.ts+59| ... | @@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./ | ... | @@ -6,6 +6,7 @@ import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./ |
| 6 | 6 | ||
| 7 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); | 7 | const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); |
| 8 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); | 8 | const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); |
| 9 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | ||
| 9 | 10 | ||
| 10 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | 11 | describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 11 | it("defaults to an empty record so legacy configs without the key still decode", () => { | 12 | it("defaults to an empty record so legacy configs without the key still decode", () => { |
| ... | @@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => { | ... | @@ -92,3 +93,61 @@ describe("ServerSettingsPatch.providerInstances", () => { |
| 92 | expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama"); | 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,4 +1,5 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Duration from "effect/Duration"; | ||
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; | 5 | import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; |
| ... | @@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({ | ... | @@ -336,8 +337,15 @@ export const ObservabilitySettings = Schema.Struct({ |
| 336 | }); | 337 | }); |
| 337 | export type ObservabilitySettings = typeof ObservabilitySettings.Type; | 338 | export type ObservabilitySettings = typeof ObservabilitySettings.Type; |
| 338 | 339 | ||
| 340 | export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); | ||
| 341 | |||
| 339 | export const ServerSettings = Schema.Struct({ | 342 | export const ServerSettings = Schema.Struct({ |
| 340 | enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 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 | defaultThreadEnvMode: ThreadEnvMode.pipe( | 349 | defaultThreadEnvMode: ThreadEnvMode.pipe( |
| 342 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), | 350 | Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), |
| 343 | ), | 351 | ), |
| ... | @@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({ | ... | @@ -408,45 +416,46 @@ const ModelSelectionPatch = Schema.Struct({ |
| 408 | 416 | ||
| 409 | const CodexSettingsPatch = Schema.Struct({ | 417 | const CodexSettingsPatch = Schema.Struct({ |
| 410 | enabled: Schema.optionalKey(Schema.Boolean), | 418 | enabled: Schema.optionalKey(Schema.Boolean), |
| 411 | binaryPath: Schema.optionalKey(Schema.String), | 419 | binaryPath: Schema.optionalKey(TrimmedString), |
| 412 | homePath: Schema.optionalKey(Schema.String), | 420 | homePath: Schema.optionalKey(TrimmedString), |
| 413 | shadowHomePath: Schema.optionalKey(Schema.String), | 421 | shadowHomePath: Schema.optionalKey(TrimmedString), |
| 414 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 422 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 415 | }); | 423 | }); |
| 416 | 424 | ||
| 417 | const ClaudeSettingsPatch = Schema.Struct({ | 425 | const ClaudeSettingsPatch = Schema.Struct({ |
| 418 | enabled: Schema.optionalKey(Schema.Boolean), | 426 | enabled: Schema.optionalKey(Schema.Boolean), |
| 419 | binaryPath: Schema.optionalKey(Schema.String), | 427 | binaryPath: Schema.optionalKey(TrimmedString), |
| 420 | homePath: Schema.optionalKey(Schema.String), | 428 | homePath: Schema.optionalKey(TrimmedString), |
| 421 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 429 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 422 | launchArgs: Schema.optionalKey(Schema.String), | 430 | launchArgs: Schema.optionalKey(TrimmedString), |
| 423 | }); | 431 | }); |
| 424 | 432 | ||
| 425 | const CursorSettingsPatch = Schema.Struct({ | 433 | const CursorSettingsPatch = Schema.Struct({ |
| 426 | enabled: Schema.optionalKey(Schema.Boolean), | 434 | enabled: Schema.optionalKey(Schema.Boolean), |
| 427 | binaryPath: Schema.optionalKey(Schema.String), | 435 | binaryPath: Schema.optionalKey(TrimmedString), |
| 428 | apiEndpoint: Schema.optionalKey(Schema.String), | 436 | apiEndpoint: Schema.optionalKey(TrimmedString), |
| 429 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 437 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 430 | }); | 438 | }); |
| 431 | 439 | ||
| 432 | const OpenCodeSettingsPatch = Schema.Struct({ | 440 | const OpenCodeSettingsPatch = Schema.Struct({ |
| 433 | enabled: Schema.optionalKey(Schema.Boolean), | 441 | enabled: Schema.optionalKey(Schema.Boolean), |
| 434 | binaryPath: Schema.optionalKey(Schema.String), | 442 | binaryPath: Schema.optionalKey(TrimmedString), |
| 435 | serverUrl: Schema.optionalKey(Schema.String), | 443 | serverUrl: Schema.optionalKey(TrimmedString), |
| 436 | serverPassword: Schema.optionalKey(Schema.String), | 444 | serverPassword: Schema.optionalKey(TrimmedString), |
| 437 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 445 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), |
| 438 | }); | 446 | }); |
| 439 | 447 | ||
| 440 | export const ServerSettingsPatch = Schema.Struct({ | 448 | export const ServerSettingsPatch = Schema.Struct({ |
| 441 | // Server settings | 449 | // Server settings |
| 442 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), | 450 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 451 | automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), | ||
| 443 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), | 452 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 444 | addProjectBaseDirectory: Schema.optionalKey(Schema.String), | 453 | addProjectBaseDirectory: Schema.optionalKey(TrimmedString), |
| 445 | textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), | 454 | textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), |
| 446 | observability: Schema.optionalKey( | 455 | observability: Schema.optionalKey( |
| 447 | Schema.Struct({ | 456 | Schema.Struct({ |
| 448 | otlpTracesUrl: Schema.optionalKey(Schema.String), | 457 | otlpTracesUrl: Schema.optionalKey(TrimmedString), |
| 449 | otlpMetricsUrl: Schema.optionalKey(Schema.String), | 458 | otlpMetricsUrl: Schema.optionalKey(TrimmedString), |
| 450 | }), | 459 | }), |
| 451 | ), | 460 | ), |
| 452 | providers: Schema.optionalKey( | 461 | providers: Schema.optionalKey( |
packages/shared/src/serverSettings.ts+9-8| ... | @@ -76,14 +76,15 @@ export function applyServerSettingsPatch( | ... | @@ -76,14 +76,15 @@ export function applyServerSettingsPatch( |
| 76 | patch: ServerSettingsPatch, | 76 | patch: ServerSettingsPatch, |
| 77 | ): ServerSettings { | 77 | ): ServerSettings { |
| 78 | const selectionPatch = patch.textGenerationModelSelection; | 78 | const selectionPatch = patch.textGenerationModelSelection; |
| 79 | const next = deepMerge(current, patch); | 79 | const { automaticGitFetchInterval, ...patchForMerge } = patch; |
| 80 | const nextWithReplacements = | 80 | const next = deepMerge(current, patchForMerge); |
| 81 | patch.providerInstances !== undefined | 81 | const nextWithReplacements = { |
| 82 | ? { | 82 | ...next, |
| 83 | ...next, | 83 | ...(patch.providerInstances !== undefined |
| 84 | providerInstances: patch.providerInstances, | 84 | ? { providerInstances: patch.providerInstances } |
| 85 | } | 85 | : {}), |
| 86 | : next; | 86 | ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), |
| 87 | }; | ||
| 87 | if (!selectionPatch) { | 88 | if (!selectionPatch) { |
| 88 | return nextWithReplacements; | 89 | return nextWithReplacements; |
| 89 | } | 90 | } |