Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { createModelSelection } from "@t3tools/shared/model";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
serverPassword: "secret-password",
},
},
automaticGitFetchInterval: Duration.seconds(10),
});

assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");
Expand All @@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
serverPassword: "secret-password",
},
},
automaticGitFetchInterval: 10_000,
});
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
Expand Down
104 changes: 58 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 * as Semaphore from "effect/Semaphore";
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({
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 @@ export class ServerSettingsService extends Context.Service<
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 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
}

// 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,29 @@ const makeServerSettings = Effect.gen(function* () {
};
});

const writeSettingsAtomically = (settings: ServerSettings) => {
const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
const writeSettingsAtomically = Effect.fnUntraced(
function* (settings: ServerSettings) {
const sparseSettingsJson = yield* encodeServerSettingsJson(
stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
);

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 +554,7 @@ const makeServerSettings = Effect.gen(function* () {
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