Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1601,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
}),
),
updatedAt: updatedAt ?? new Date(0).toISOString(),
updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
};

return yield* decodeShellSnapshot(snapshot).pipe(
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";
import { deepMerge } from "@t3tools/shared/Struct";
import { createModelCapabilities } from "@t3tools/shared/model";
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";

import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
Expand All @@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t
import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
const decodeServerSettings = Schema.decodeSync(ServerSettings);
const encodeServerSettings = Schema.encodeSync(ServerSettings);
const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);

const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
Expand Down Expand Up @@ -256,7 +259,8 @@ function makeMutableServerSettingsService(
updateSettings: (patch) =>
Effect.gen(function* () {
const current = yield* Ref.get(settingsRef);
const next = decodeServerSettings(deepMerge(current, patch));
const next = applyServerSettingsPatch(current, patch);
encodeServerSettings(next);
yield* Ref.set(settingsRef, next);
yield* PubSub.publish(changes, next);
return next;
Expand Down Expand Up @@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
const missingBinary = `t3code_codex_missing_`;
const serverSettings = yield* makeMutableServerSettingsService(
decodeServerSettings(
deepMerge(DEFAULT_SERVER_SETTINGS, {
deepMerge(encodedDefaultServerSettings, {
providers: {
// Disable every built-in probe that would otherwise spawn
// on the CI host. `enabled: false` short-circuits each
Expand Down Expand Up @@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
const secondMissing = `t3code_codex_second_`;
const serverSettings = yield* makeMutableServerSettingsService(
decodeServerSettings(
deepMerge(DEFAULT_SERVER_SETTINGS, {
deepMerge(encodedDefaultServerSettings, {
providers: {
codex: { enabled: true, binaryPath: firstMissing },
claudeAgent: { enabled: false },
Expand Down Expand Up @@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
Effect.gen(function* () {
const serverSettings = yield* makeMutableServerSettingsService(
decodeServerSettings(
deepMerge(DEFAULT_SERVER_SETTINGS, {
deepMerge(encodedDefaultServerSettings, {
providers: {
codex: { enabled: false },
claudeAgent: { enabled: false },
Expand Down Expand Up @@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
Effect.gen(function* () {
const serverSettings = yield* makeMutableServerSettingsService(
decodeServerSettings(
deepMerge(DEFAULT_SERVER_SETTINGS, {
deepMerge(encodedDefaultServerSettings, {
providers: {
codex: {
enabled: false,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: {
snapshotSequence: 0,
projects: [],
threads: [],
updatedAt: new Date(0).toISOString(),
updatedAt: "1970-01-01T00:00:00.000Z",
}),
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
getProjectShellById: () => Effect.succeed(Option.none()),
Expand Down
109 changes: 63 additions & 46 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,33 @@
import { writeFileStringAtomically } from "./atomicWrite.ts";
import { ServerConfig } from "./config.ts";
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
const decodeServerSettings = Schema.decodeEffect(ServerSettings);

const encodeServerSettings = Schema.encodeEffect(ServerSettings);
const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

const normalizeServerSettings = (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
settings: ServerSettings,
): Effect.Effect<ServerSettings, ServerSettingsError> =>
encodeServerSettings(settings).pipe(
Effect.flatMap(decodeServerSettings),
Effect.mapError(
(cause) =>
new ServerSettingsError({

Check failure on line 67 in apps/server/src/serverSettings.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

src/serverSettings.test.ts > server settings > defaults blank binary paths to provider executables

ServerSettingsError: Server settings error at <memory>: failed to normalize server settings: Expected a string with no leading or trailing whitespace, got " " at ["providers"]["codex"]["binaryPath"] ❯ src/serverSettings.ts:67:9 ❯ ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:3143:72 ❯ body ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Utils.ts:242:11 ❯ internalCall ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:944:94 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:890:21 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Effect.ts:23234:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { settingsPath: '<memory>', detail: 'failed to normalize server settings: Expected a string with no leading or trailing whitespace, got " "\n at ["providers"]["codex"]["binaryPath"]', _tag: 'ServerSettingsError' } Caused by: Caused by: SchemaError: Expected a string with no leading or trailing whitespace, got " " at ["providers"]["codex"]["binaryPath"] ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { '~effect/Schema/SchemaError': '~effect/Schema/SchemaError', _tag: 'SchemaError', issue: { '~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue', _tag: 'Composite', ast: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Objects', propertySignatures: [ { name: 'enableAssistantStreaming', type: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: [ { to: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Boolean', constructor: 'Function<Boolean>', getParser: 'Function<getParser>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, transformation: { '~effect/SchemaTransformation/Transformation': '~effect/SchemaTransformation/Transformation', _tag: 'Transformation', decode: { run: 'Function<anonymous>', constructor: 'Function<Getter>', map: 'Function<map>', compose: 'Function<compose>', pipe: 'Function<pipe>' }, encode: { run: 'Function<anonymous>', constructor: 'Function<Getter>', map: 'Function<map>', compose: 'Function<compose>', pipe: 'Function<pipe>' }, constructor: 'Function<Transformation>', flip: 'Function<flip>', compose: 'Function<compose>' }, constructor: 'Function<Link>' } ], context: { isOptional: true, isMutable: false, defaultValue: undefined, annotations: undefined, constructor: 'Function<Context>' }, _tag: 'Union', types: [ { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Boolean', constructor: 'Function<Boolean>', getParser: 'Function<getParser>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Undefined', constructor: 'Function<Undefined>', getParser: 'Function<getParser>', toCodecJson: 'Function<toCodecJson>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' } ], mode: 'anyOf', constructor: 'Function<Union>', getParser: 'Function<getParser>', recur: 'Function<recur>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, constructor: 'Function<PropertySignature>' }, { name: 'automaticGitFetchInterval', type: { '~effect/Schema': '~effect/Schema', annotations: undefi

Check failure on line 67 in apps/server/src/serverSettings.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

src/serverSettings.test.ts > server settings > trims observability settings when updates are applied

ServerSettingsError: Server settings error at <memory>: failed to normalize server settings: Expected a string with no leading or trailing whitespace, got " ~/Development " at ["addProjectBaseDirectory"] ❯ src/serverSettings.ts:67:9 ❯ ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:3143:72 ❯ body ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Utils.ts:242:11 ❯ internalCall ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:944:94 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:890:21 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Effect.ts:23234:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { settingsPath: '<memory>', detail: 'failed to normalize server settings: Expected a string with no leading or trailing whitespace, got " ~/Development "\n at ["addProjectBaseDirectory"]', _tag: 'ServerSettingsError' } Caused by: Caused by: SchemaError: Expected a string with no leading or trailing whitespace, got " ~/Development " at ["addProjectBaseDirectory"] ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { '~effect/Schema/SchemaError': '~effect/Schema/SchemaError', _tag: 'SchemaError', issue: { '~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue', _tag: 'Composite', ast: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Objects', propertySignatures: [ { name: 'enableAssistantStreaming', type: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: [ { to: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Boolean', constructor: 'Function<Boolean>', getParser: 'Function<getParser>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, transformation: { '~effect/SchemaTransformation/Transformation': '~effect/SchemaTransformation/Transformation', _tag: 'Transformation', decode: { run: 'Function<anonymous>', constructor: 'Function<Getter>', map: 'Function<map>', compose: 'Function<compose>', pipe: 'Function<pipe>' }, encode: { run: 'Function<anonymous>', constructor: 'Function<Getter>', map: 'Function<map>', compose: 'Function<compose>', pipe: 'Function<pipe>' }, constructor: 'Function<Transformation>', flip: 'Function<flip>', compose: 'Function<compose>' }, constructor: 'Function<Link>' } ], context: { isOptional: true, isMutable: false, defaultValue: undefined, annotations: undefined, constructor: 'Function<Context>' }, _tag: 'Union', types: [ { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Boolean', constructor: 'Function<Boolean>', getParser: 'Function<getParser>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Undefined', constructor: 'Function<Undefined>', getParser: 'Function<getParser>', toCodecJson: 'Function<toCodecJson>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' } ], mode: 'anyOf', constructor: 'Function<Union>', getParser: 'Function<getParser>', recur: 'Function<recur>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, constructor: 'Function<PropertySignature>' }, { name: 'automaticGitFetchInterval', type: { '~effect/Schema': '~effect/Schema', anno

Check failure on line 67 in apps/server/src/serverSettings.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

src/serverSettings.test.ts > server settings > trims provider path settings when updates are applied

ServerSettingsError: Server settings error at <memory>: failed to normalize server settings: Expected a string with no leading or trailing whitespace, got " /opt/homebrew/bin/codex " at ["providers"]["codex"]["binaryPath"] ❯ src/serverSettings.ts:67:9 ❯ ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:3143:72 ❯ body ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Utils.ts:242:11 ❯ internalCall ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:944:94 ❯ Object.~effect/Effect/evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:890:21 ❯ FiberImpl.runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:633:39 ❯ runLoop ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:593:22 ❯ evaluate ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/internal/effect.ts:1030:14 ❯ resume ../../node_modules/.bun/effect@4.0.0-beta.59/node_modules/effect/src/Effect.ts:23234:12 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { settingsPath: '<memory>', detail: 'failed to normalize server settings: Expected a string with no leading or trailing whitespace, got " /opt/homebrew/bin/codex "\n at ["providers"]["codex"]["binaryPath"]', _tag: 'ServerSettingsError' } Caused by: Caused by: SchemaError: Expected a string with no leading or trailing whitespace, got " /opt/homebrew/bin/codex " at ["providers"]["codex"]["binaryPath"] ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { '~effect/Schema/SchemaError': '~effect/Schema/SchemaError', _tag: 'SchemaError', issue: { '~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue', _tag: 'Composite', ast: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Objects', propertySignatures: [ { name: 'enableAssistantStreaming', type: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: [ { to: { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Boolean', constructor: 'Function<Boolean>', getParser: 'Function<getParser>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, transformation: { '~effect/SchemaTransformation/Transformation': '~effect/SchemaTransformation/Transformation', _tag: 'Transformation', decode: { run: 'Function<anonymous>', constructor: 'Function<Getter>', map: 'Function<map>', compose: 'Function<compose>', pipe: 'Function<pipe>' }, encode: { run: 'Function<anonymous>', constructor: 'Function<Getter>', map: 'Function<map>', compose: 'Function<compose>', pipe: 'Function<pipe>' }, constructor: 'Function<Transformation>', flip: 'Function<flip>', compose: 'Function<compose>' }, constructor: 'Function<Link>' } ], context: { isOptional: true, isMutable: false, defaultValue: undefined, annotations: undefined, constructor: 'Function<Context>' }, _tag: 'Union', types: [ { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Boolean', constructor: 'Function<Boolean>', getParser: 'Function<getParser>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, { '~effect/Schema': '~effect/Schema', annotations: undefined, checks: undefined, encoding: undefined, context: undefined, _tag: 'Undefined', constructor: 'Function<Undefined>', getParser: 'Function<getParser>', toCodecJson: 'Function<toCodecJson>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' } ], mode: 'anyOf', constructor: 'Function<Union>', getParser: 'Function<getParser>', recur: 'Function<recur>', getExpected: 'Function<getExpected>', toString: 'Function<toString>' }, constructor: 'Function<PropertySignature>' }, { name: 'automaticGitFetchIn
settingsPath: "<memory>",
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
cause,
}),
),
);

function providerEnvironmentSecretName(input: {
readonly instanceId: string;
readonly name: string;
Expand Down Expand Up @@ -117,28 +135,24 @@
Layer.effect(
ServerSettingsService,
Effect.gen(function* () {
const currentSettingsRef = yield* Ref.make<ServerSettings>(
deepMerge(DEFAULT_SERVER_SETTINGS, overrides),
);
const { automaticGitFetchInterval, ...overridesForMerge } = overrides;
const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
const initialSettings = yield* normalizeServerSettings({
...merged,
...(automaticGitFetchInterval !== undefined
? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
: {}),
});
const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);

return {
start: Effect.void,
ready: Effect.void,
getSettings: Ref.get(currentSettingsRef),
updateSettings: (patch) =>
Ref.get(currentSettingsRef).pipe(
Effect.flatMap((currentSettings) =>
decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath: "<memory>",
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
cause,
}),
),
),
),
Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
Effect.flatMap(normalizeServerSettings),
Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
),
streamChanges: Stream.empty,
Expand Down Expand Up @@ -200,7 +214,10 @@
}

// Values under these keys are compared as a whole — never stripped field-by-field.
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]);
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
"automaticGitFetchInterval",
"textGenerationModelSelection",
]);

function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
if (Array.isArray(current) || Array.isArray(defaults)) {
Expand Down Expand Up @@ -430,25 +447,34 @@
};
});

const writeSettingsAtomically = (settings: ServerSettings) => {
const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
const writeSettingsAtomically = Effect.fnUntraced(
function* (settings: ServerSettings) {
const sparseSettingsJson = yield* Effect.flatten(
Effect.zipWith(
encodeServerSettings(settings),
encodeServerSettings(DEFAULT_SERVER_SETTINGS),
(a, b) => encodeServerSettingsJson(stripDefaultServerSettings(a, b) ?? {}),
{ concurrent: true },
),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
);

return writeFileStringAtomically({
filePath: settingsPath,
contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,
}).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, pathService),
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
detail: "failed to write settings file",
cause,
}),
),
);
};
return yield* writeFileStringAtomically({
filePath: settingsPath,
contents: `${sparseSettingsJson}\n`,
}).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, pathService),
);
},
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
detail: "failed to write settings file",
cause,
}),
),
);

const revalidateAndEmit = writeSemaphore.withPermits(1)(
Effect.gen(function* () {
Expand Down Expand Up @@ -533,16 +559,7 @@
current,
applyServerSettingsPatch(current, patch),
);
const next = yield* decodeServerSettings(nextPersisted).pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath: "<memory>",
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
cause,
}),
),
);
const next = yield* normalizeServerSettings(nextPersisted);
yield* writeSettingsAtomically(next);
yield* Cache.set(settingsCache, cacheKey, next);
yield* emitChange(next);
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("disables SSH askpass for background upstream status fetches", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-");
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const sshLogPath = pathService.join(tempDir, "ssh-env.txt");
const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh");
const previousGitSsh = process.env.GIT_SSH;
const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE;
const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG;

yield* fileSystem.writeFileString(
sshWrapperPath,
[
"#!/bin/sh",
'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"',
"exit 1",
"",
].join("\n"),
);
yield* fileSystem.chmod(sshWrapperPath, 0o755);
yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]);
yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]);
yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]);

yield* Effect.gen(function* () {
process.env.GIT_SSH = sshWrapperPath;
process.env.SSH_ASKPASS_REQUIRE = "force";
process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath;

yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);

assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never");
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previousGitSsh === undefined) {
delete process.env.GIT_SSH;
} else {
process.env.GIT_SSH = previousGitSsh;
}
if (previousAskpassRequire === undefined) {
delete process.env.SSH_ASKPASS_REQUIRE;
} else {
process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire;
}
if (previousAskpassLog === undefined) {
delete process.env.T3_TEST_SSH_ASKPASS_LOG;
} else {
process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog;
}
}),
),
);
}),
);

it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
SSH_ASKPASS_REQUIRE: "never",
} satisfies NodeJS.ProcessEnv);
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
Expand Down Expand Up @@ -72,6 +75,7 @@ interface ExecuteGitOptions {
timeoutMs?: number | undefined;
allowNonZeroExit?: boolean | undefined;
fallbackErrorMessage?: string | undefined;
env?: NodeJS.ProcessEnv | undefined;
maxOutputBytes?: number | undefined;
truncateOutputAtMaxBytes?: boolean | undefined;
progress?: GitVcsDriver.ExecuteGitProgress | undefined;
Expand Down Expand Up @@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
cwd,
args,
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
...(options.env !== undefined ? { env: options.env } : {}),
allowNonZeroExit: true,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
Expand Down Expand Up @@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
{
allowNonZeroExit: true,
env: STATUS_UPSTREAM_REFRESH_ENV,
timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
},
).pipe(Effect.asVoid);
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, it, describe } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
Expand Down Expand Up @@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => {
}).pipe(Effect.provide(makeTestLayer(state)));
});

it.effect("does not start automatic remote refreshes when disabled", () => {
const state = {
currentLocalStatus: baseLocalStatus,
currentRemoteStatus: baseRemoteStatus,
localStatusCalls: 0,
remoteStatusCalls: 0,
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
};

return Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const snapshot = yield* Stream.runHead(
broadcaster.streamStatus(
{ cwd: "/repo" },
{ automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) },
),
);

assert.isTrue(Option.isSome(snapshot));
assert.equal(state.remoteStatusCalls, 0);
assert.equal(state.remoteInvalidationCalls, 0);
}).pipe(Effect.provide(makeTestLayer(state)));
});

it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
const state = {
currentLocalStatus: baseLocalStatus,
Expand Down
Loading
Loading