diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3ea92f50e..788e09bba 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,3 +1,4 @@ +import type { ServerSettings } from "@t3tools/contracts"; import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; @@ -43,13 +44,10 @@ import { DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - type ServerSettingsPatch, } from "@t3tools/contracts"; import { filterSharedServerPatch, - findSharedSettingsMismatches, supportsSharedSettingsSync, - pickSharedServerSettings, } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -67,6 +65,7 @@ import { resolveAgentAwarenessSignInMessage, resolveAgentAwarenessSubtitle, } from "./SettingsRouteScreen.logic"; +import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -622,6 +621,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD * has no primary environment, so the first eligible environment that * supports restart continuation when available is the reference value. Edits fan out to every eligible * environment, and a mismatch row lets the user push the reference out. + * Mobile edits auto-settle defaults across connected, capable environments. + * The first target supplies the displayed values. Applying them leaves each + * environment's other defaults and overrides intact. */ function SharedThreadSettingsRows() { const { environments } = useEnvironments(); @@ -646,7 +648,9 @@ function SharedThreadSettingsRows() { return null; } - const writeToAll = (patch: ServerSettingsPatch) => { + const writeToAll = ( + patch: Partial>, + ) => { for (const environment of syncTargets) { const supportedPatch = filterSharedServerPatch( patch, @@ -661,20 +665,22 @@ function SharedThreadSettingsRows() { } }; - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: reference.environmentId, - primarySettings: referenceSettings, - primaryCapabilities: reference.serverConfig?.environment.capabilities, - environments: environments.map((environment) => ({ + const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( + { environmentId: reference.environmentId, settings: referenceSettings }, + syncTargets.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, - capabilities: environment.serverConfig?.environment.capabilities, })), - }); + ); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; + const continuationMismatches = syncTargets.filter( + (environment) => + environment.serverConfig?.environment.capabilities.threadRestartContinuation === true && + environment.serverConfig.settings.continueThreadsAfterServerUpdate !== + referenceSettings.continueThreadsAfterServerUpdate, + ); const commitDays = () => { const draft = (daysDraft ?? "").trim(); setDaysDraft(null); @@ -735,7 +741,7 @@ function SharedThreadSettingsRows() { {mismatches.length > 0 ? ( - Settings differ + Auto-settle defaults differ {mismatches.map((mismatch) => mismatch.label).join(", ")} @@ -743,30 +749,40 @@ function SharedThreadSettingsRows() { { - const patch = pickSharedServerSettings( - referenceSettings, - reference.serverConfig?.environment.capabilities, - ); for (const mismatch of mismatches) { - const target = environments.find( - (candidate) => candidate.environmentId === mismatch.environmentId, - ); void updateSettings({ environmentId: mismatch.environmentId, - input: { - patch: filterSharedServerPatch( - patch, - target?.serverConfig?.environment.capabilities, - target?.serverConfig?.settings, - referenceSettings, - ), - }, + input: { patch: autoSettlePatch }, }); } }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > - Apply to all + + Apply auto-settle defaults + + + + ) : null} + {continuationMismatches.length > 0 ? ( + + + Restart continuation defaults differ + + {continuationMismatches.map((environment) => environment.label).join(", ")} + + + + writeToAll({ + continueThreadsAfterServerUpdate: + referenceSettings.continueThreadsAfterServerUpdate, + }) + } + className="rounded-full bg-subtle px-4 py-2 active:opacity-70" + > + Apply restart defaults ) : null} diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts new file mode 100644 index 000000000..ec550725a --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts @@ -0,0 +1,78 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; + +const reference = { + environmentId: EnvironmentId.make("reference"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + newWorktreesStartFromOrigin: false, + continueThreadsAfterServerUpdate: false, + }, +}; + +describe("auto-settle settings sync", () => { + it("ignores differences in independently configured environment settings", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Keep this environment's writing instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + + expect(plan.mismatches).toEqual([]); + expect(plan.patch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + }); + }); + + it("applies only auto-settle defaults when another environment differs", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Preserve these instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + const updated = { ...target.settings, ...plan.patch }; + + expect(plan.mismatches).toEqual([target]); + expect(updated.sidebarAutoSettleAfterDays).toBe(7); + expect(updated.sidebarAutoSettleOnMerge).toBe(true); + expect(updated.newWorktreesStartFromOrigin).toBe(true); + expect(updated.continueThreadsAfterServerUpdate).toBe(true); + expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle); + }); + + it("does not compare the reference or a target without loaded settings", () => { + const plan = planAutoSettleSettingsSync(reference, [ + { ...reference, label: "Reference" }, + { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null }, + ]); + + expect(plan.mismatches).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts new file mode 100644 index 000000000..6addfa381 --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts @@ -0,0 +1,31 @@ +import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; + +export type AutoSettleSettings = Pick< + ServerSettings, + "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" +>; + +interface AutoSettleSyncTarget { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly settings: AutoSettleSettings | null; +} + +/** Receives connected, capable targets. Applying these defaults must preserve other settings. */ +export function planAutoSettleSettingsSync( + reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, + targets: readonly AutoSettleSyncTarget[], +) { + const patch: AutoSettleSettings = { + sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge, + }; + const mismatches = targets.filter( + (target) => + target.environmentId !== reference.environmentId && + target.settings !== null && + (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || + target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), + ); + return { patch, mismatches }; +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index e86e25cc7..0a2eff581 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -14,10 +14,15 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, MessageId, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { + projectDefaultModelPreference, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { isDefaultThreadEnvModeSettled, @@ -429,17 +434,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); + // Environment settings with the project's overrides applied; the + // aggregate's own legacy fields still count until the server folds them. + const projectSettings = useMemo( + () => + resolveProjectSettings( + selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedProject?.id ?? null, + selectedProject, + ), + [selectedEnvironmentServerConfig?.settings, selectedProject], + ); + const projectThreadEnvMode = + projectSettings.sources.defaultThreadEnvMode === "project" + ? projectSettings.settings.defaultThreadEnvMode + : undefined; const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: selectedProject?.defaultThreadEnvMode, + projectSetting: projectThreadEnvMode, projectFile: t3ProjectFileDefaultMode, - globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + globalDefault: projectSettings.settings.defaultThreadEnvMode, }); // While unsettled the resolved default is provisional. Nothing may write // it into the draft during that window (the auto-branch effect does), or // the frozen interim value beats the t3.json default once it loads. const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: selectedProject?.defaultThreadEnvMode, + projectSetting: projectThreadEnvMode, projectFilePending: t3ProjectFileQuery.isPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; @@ -450,10 +470,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // value keeps tracking the server setting when the config loads late. const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = - draftStartFromOrigin ?? - selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? - true; - const draftRuntimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; + const defaultRuntimeMode = editingPendingTask + ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) + : projectSettings.settings.defaultRuntimeMode; + const draftRuntimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode; const draftInteractionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; @@ -463,10 +484,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // server status for remediation and require a new pick instead of silently // switching providers. Project and sticky defaults also reject legacy models. const storedDraftModelSelection = selectedProjectDraft.modelSelection ?? null; - const storedProjectDefaultModelSelection = - selectedProject?.defaultModelSelection ?? - selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? - null; + const storedProjectDefaultModelSelection = projectDefaultModelPreference(projectSettings); const storedStickyModelSelection = useStickyComposerModelSelection(); const unavailablePreferredProvider = resolveNewTaskUnavailableProvider( selectedEnvironmentServerConfig, @@ -998,7 +1016,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode: resolveModelSelectionRuntimeMode( selectedEnvironmentServerConfig, draftModelSelection, - draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, + draft.runtimeMode ?? defaultRuntimeMode, ), ...(preservedDeliveryHold === undefined ? {} : { deliveryHold: preservedDeliveryHold }), interactionMode: @@ -1040,6 +1058,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }; }, [ + defaultRuntimeMode, editingPendingProject, editingPendingTask, selectedEnvironmentServerConfig, diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 6244d5ff7..a515f2ddd 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -67,7 +67,9 @@ const CHART_HEIGHT = 180; export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const [tab, setTab] = useState("usage"); + // Limits first: remaining quota and reset time are what most people open + // the screen for. + const [tab, setTab] = useState("limits"); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 23cbd4f90..59709baf5 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -68,6 +68,7 @@ import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDelet import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; +import * as ProjectSettingsReactor from "../src/orchestration/ProjectSettingsReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -406,6 +407,12 @@ export const makeOrchestrationIntegrationHarness = ( requestSync: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ProjectSettingsReactor.ProjectSettingsReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 7a40e1e53..dc6c02397 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Effect from "effect/Effect"; @@ -7,6 +7,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; import { DeviceHostError, DeviceHost } from "./DeviceHost.ts"; import { makeWithHosts } from "./DeviceService.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; it.effect("keeps hosts independent when serials collide and another host fails", () => Effect.gen(function* () { @@ -89,7 +90,17 @@ it.effect("keeps hosts independent when serials collide and another host fails", expect(state.hostStatuses.offline?.status).toBe("failed"); const targeting = yield* service .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" }) - .pipe(Effect.forkChild); + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment-device-hosts"), + threadId, + providerSessionId: "provider-device-hosts", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["device"]), + issuedAt: 1, + }), + Effect.forkChild, + ); yield* Deferred.await(writeStarted); const replacing = yield* service .withLifecycleLock( diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 18680e25f..2dcfb9507 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + EnvironmentId, LOCAL_DEVICE_HOST_ID, + ProjectId, + ProviderInstanceId, ThreadId, type DeviceServiceState, } from "@t3tools/contracts"; @@ -15,6 +18,8 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; import * as DeviceHost from "./DeviceHost.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; @@ -66,6 +71,7 @@ const fixture = Effect.fn("fixture")(function* ( const starts: string[] = []; const agentStarts: string[] = []; const agentStops: string[] = []; + const agentConfigs: string[] = []; const requests: string[] = []; let booted = false; let shutDown = false; @@ -109,7 +115,12 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + const service = yield* makeWithHosts(new Map([[host.id, host]]), undefined, (hostId) => + Effect.sync(() => { + agentConfigs.push(hostId); + return "/test-agent-config.json"; + }), + ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, @@ -185,10 +196,70 @@ const fixture = Effect.fn("fixture")(function* ( ), ), ); - return { service, starts, agentStarts, agentStops, requests, settings }; + return { service, starts, agentStarts, agentStops, agentConfigs, requests, settings }; }); describe("device setup consent", () => { + it.effect("honors authenticated project device grants and keeps reverse transitions closed", () => + Effect.gen(function* () { + const { service, settings, agentStarts, agentConfigs } = yield* fixture(); + const projectId = ProjectId.make("project-device-permission"); + const threadId = ThreadId.make("thread-device-permission"); + const input = { threadId, hostId: LOCAL_DEVICE_HOST_ID, deviceId: "Pixel_API_35" }; + const invocation = (capabilities: ReadonlyArray) => ({ + environmentId: EnvironmentId.make("environment-device-permission"), + threadId, + providerSessionId: "provider-device-permission", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, + }); + const targetWith = (scope: McpInvocationContext.McpInvocationScope) => + service + .agentTarget(input) + .pipe(Effect.provideService(McpInvocationContext.McpInvocationContext, scope)); + yield* service.configure({ enabled: true }); + yield* Ref.update(settings, (value) => ({ + ...value, + enableAgentDeviceAccess: false, + projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } }, + })); + const allowed = resolveProjectSettings(yield* Ref.get(settings), projectId).settings; + expect(allowed.enableAgentDeviceAccess).toBe(true); + expect(yield* service.agentReadinessIfSupported()).toBeNull(); + const target = yield* targetWith( + invocation(allowed.enableAgentDeviceAccess ? ["device"] : []), + ); + expect(target).toContain("/test-agent-config.json"); + expect(agentStarts).toEqual(["start"]); + expect(agentConfigs).toEqual([LOCAL_DEVICE_HOST_ID]); + + yield* Ref.update(settings, (value) => ({ + ...value, + enableAgentDeviceAccess: true, + projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: false } }, + })); + const denied = resolveProjectSettings(yield* Ref.get(settings), projectId).settings; + expect(denied.enableAgentDeviceAccess).toBe(false); + expect( + (yield* targetWith(invocation(denied.enableAgentDeviceAccess ? ["device"] : [])).pipe( + Effect.result, + ))._tag, + ).toBe("Failure"); + expect((yield* targetWith(invocation(["preview"])).pipe(Effect.result))._tag).toBe("Failure"); + expect( + (yield* targetWith({ + ...invocation(["device"]), + threadId: ThreadId.make("another-thread"), + }).pipe(Effect.result))._tag, + ).toBe("Failure"); + yield* service.configure({ enabled: false }); + expect((yield* targetWith(invocation(["device"])).pipe(Effect.result))._tag).toBe("Failure"); + expect(agentStarts).toEqual(["start"]); + expect(agentConfigs).toEqual([LOCAL_DEVICE_HOST_ID]); + }).pipe(Effect.scoped), + ); + it.effect("listing and provider startup do not start helpers before consent", () => Effect.gen(function* () { const { service, starts, requests } = yield* fixture(); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 400047815..56c295098 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -58,6 +58,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as ServerSettings from "../serverSettings.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -116,7 +117,11 @@ export class DeviceService extends Context.Service< threadId: ThreadId; hostId: DeviceHostId; deviceId: DeviceId; - }) => Effect.Effect, DeviceError>; + }) => Effect.Effect< + ReadonlyArray, + DeviceError, + McpInvocationContext.McpInvocationContext + >; readonly state: Effect.Effect; readonly subscribe: Effect.Effect, never, Scope.Scope>; readonly configure: ( @@ -289,29 +294,39 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return yield* readiness(host.id); }); - const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] = - Effect.fn("DeviceService.agentReadinessIfSupported")(function* (hostId) { - const deviceSettings = yield* readDeviceSettings; - if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; - const host = yield* resolveHost(hostId); - const summary = yield* host.summary; - if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) - return null; - const ready = yield* host - .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) - .pipe( - Effect.tapError((error) => - setHostStatus(host.id, { status: "failed", detail: error.message }), - ), - Effect.mapError( - (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), - ), - ); - const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); - yield* publish((state) => ({ ...state, hosts: hostSummaries })); - yield* setHostStatus(host.id, { status: "ready" }); - return { hostId: host.id, ...ready }; - }, lifecycleLock.withPermit); + const prepareAgentReadiness = Effect.fn("DeviceService.prepareAgentReadiness")(function* ( + hostId: DeviceHostId | undefined, + authorization: "environment" | "credential", + ) { + const deviceSettings = yield* readDeviceSettings; + if ( + !deviceSettings.enabled || + (authorization === "environment" && !deviceSettings.agentAccessEnabled) + ) + return null; + const host = yield* resolveHost(hostId); + const summary = yield* host.summary; + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) + return null; + const ready = yield* host + .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .pipe( + Effect.tapError((error) => + setHostStatus(host.id, { status: "failed", detail: error.message }), + ), + Effect.mapError( + (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + ), + ); + const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); + yield* publish((state) => ({ ...state, hosts: hostSummaries })); + yield* setHostStatus(host.id, { status: "ready" }); + return { hostId: host.id, ...ready }; + }, lifecycleLock.withPermit); + + const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] = ( + hostId, + ) => prepareAgentReadiness(hostId, "environment"); const currentReadiness: DeviceService["Service"]["currentReadiness"] = (hostId) => resolveHost(hostId).pipe( @@ -804,8 +819,25 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), agentTarget: (input) => Effect.gen(function* () { + // The authenticated credential already resolves this thread's project + // override. Rechecking the environment default would reject an allowed + // project; unrelated or unprivileged invocations must never mint a CLI target. + const invocation = yield* McpInvocationContext.requireMcpCapability("device").pipe( + Effect.mapError( + () => + new DeviceHostUnavailableError({ + hostId: input.hostId, + reason: "The authenticated agent session does not grant device access.", + }), + ), + ); + if (invocation.threadId !== input.threadId) + return yield* new DeviceHostUnavailableError({ + hostId: input.hostId, + reason: "The authenticated agent session belongs to another thread.", + }); const host = yield* resolveHost(input.hostId); - const ready = yield* agentReadinessIfSupported(input.hostId); + const ready = yield* prepareAgentReadiness(input.hostId, "credential"); if (!ready) return yield* new DeviceHostUnavailableError({ hostId: input.hostId, @@ -814,6 +846,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); const configPath = yield* lifecycleLock.withPermit( Effect.gen(function* () { + if (!(yield* readDeviceSettings).enabled) + return yield* new DeviceHostUnavailableError({ + hostId: input.hostId, + reason: "Device support was disabled. Enable it before opening an agent target.", + }); if (hosts.get(host.id) !== host) return yield* new DeviceHostUnavailableError({ hostId: host.id, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7e32137af..83677e417 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -170,6 +170,8 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.browserProfiles).toBe(true); expect(second.capabilities.projectDefaults).toBe(true); + expect(second.capabilities.projectSettingsOverrides).toBe(true); + expect(second.capabilities.defaultRuntimeMode).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequests).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index f48bf8a81..608ba770a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,8 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, + projectSettingsOverrides: true, + defaultRuntimeMode: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 75097815d..8bec757d1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -29,9 +29,16 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + type ProjectId, SourceControlProviderError, type SourceControlWritingStyleSettings, + type ThreadId, } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, @@ -661,6 +668,28 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + // Optional: git actions also run from the CLI and tests without orchestration. + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); + /** Environment settings with the acting project's overrides applied. */ + const projectSettingsFor = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly threadId?: ThreadId | undefined; + }) { + const settings = yield* serverSettingsService.getSettings; + if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; + const projectId = yield* ( + input.threadId !== undefined + ? projectionQuery.value + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.map((thread) => thread.projectId))) + : projectionQuery.value + .getActiveProjectByWorkspaceRoot(input.cwd) + .pipe(Effect.map(Option.map((project) => project.id))) + ).pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; + }); const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); @@ -2647,7 +2676,7 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( + const textGenerationSettings = yield* projectSettingsFor(input).pipe( Effect.flatMap((settings) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 5fcc32a34..e89b1a0a4 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; +import * as ProjectSettingsReactor from "../ProjectSettingsReactor.ts"; import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; @@ -67,6 +68,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ProjectSettingsReactor.ProjectSettingsReactor, { + start: () => + Effect.sync(() => { + started.push("project-settings-reactor"); + }), + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { start: () => { @@ -117,6 +127,7 @@ describe("OrchestrationReactor", () => { "checkpoint-reactor", "thread-deletion-reactor", "thread-pull-request-reactor", + "project-settings-reactor", "thread-settlement-reactor", "pull-request-sync-reactor", "agent-awareness-relay", diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index ff632240d..97618a04e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; +import * as ProjectSettingsReactor from "../ProjectSettingsReactor.ts"; import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -22,6 +23,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; + const projectSettingsReactor = yield* ProjectSettingsReactor.ProjectSettingsReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -30,6 +32,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); yield* threadPullRequestReactor.start(); + yield* projectSettingsReactor.start(); yield* threadSettlementReactor.start(); yield* pullRequestSyncReactor.start(); yield* agentAwarenessRelay.start(); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b7d7a9896..edfb9eff1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -721,6 +721,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (context._tag === "Some") { assert.deepEqual(context.value, { id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), title: "Thread 1", session: snapshot.threads[0]?.session, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5e1028e40..f16d578af 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -165,6 +165,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({ }); const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ id: ThreadId, + projectId: ProjectId, title: Schema.String, session: Schema.NullOr(ProjectionThreadSessionDbRowSchema), }); @@ -1491,6 +1492,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT threads.thread_id AS id, + threads.project_id AS "projectId", threads.title, sessions.thread_id AS "threadId", sessions.status, @@ -1530,6 +1532,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.threadId === null ? null : row, })), @@ -3641,6 +3644,7 @@ pending_approval_requests AS ( ); return Option.map(context, (row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.session === null ? null : mapSessionRow(row.session), })); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e51e83b84..8efe83312 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -62,6 +62,7 @@ import { resolveSourceControlWriterModelSelection, ServerSettingsService, } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -364,6 +365,16 @@ const make = Effect.gen(function* () { const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + /** Environment settings with the thread's project overrides applied. */ + const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const settings = yield* serverSettingsService.getSettings; + if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null) + .settings; + }); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -1341,7 +1352,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const settings = yield* serverSettingsService.getSettings; + const settings = yield* projectSettingsForThread(input.threadId); const modelSelection = settings.sourceControlWriterModelSelection === null ? settings.textGenerationModelSelection @@ -1392,8 +1403,9 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread( + input.threadId, + ); const generated = yield* textGeneration .generateThreadTitle({ @@ -1462,8 +1474,10 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }) ?? process.cwd(); - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = resolveProjectSettings( + yield* serverSettingsService.getSettings, + thread.projectId, + ).settings; const generated = yield* textGeneration.generateThreadTitle({ cwd, message, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 12072be71..0e90e4f83 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -66,6 +66,7 @@ import { import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { canReplaceThreadTitle } from "../threadTitles.ts"; class PrivateProviderRuntimeEventFence extends Context.Service< @@ -2710,7 +2711,10 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -2752,7 +2756,10 @@ const make = Effect.gen(function* () { }); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/server/src/orchestration/ProjectSettingsReactor.test.ts b/apps/server/src/orchestration/ProjectSettingsReactor.test.ts new file mode 100644 index 000000000..072b4a16c --- /dev/null +++ b/apps/server/src/orchestration/ProjectSettingsReactor.test.ts @@ -0,0 +1,74 @@ +import { + DEFAULT_SERVER_SETTINGS, + EventId, + ProjectId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { ServerActivation } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import * as ProjectSettingsReactor from "./ProjectSettingsReactor.ts"; + +it.effect("buffers legacy edits across activation and drains only meaningful settings events", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* PubSub.unbounded(); + const activation = yield* Deferred.make(); + const receipts = yield* Queue.unbounded(); + let updates = 0; + const dependencies = Layer.mergeAll( + Layer.mock(OrchestrationEngineService)({ + subscribeDomainEvents: PubSub.subscribe(events).pipe(Effect.map(Stream.fromSubscription)), + }), + Layer.mock(ServerSettingsService)({ + updateSettings: () => + Effect.sync(() => { + updates += 1; + }).pipe( + Effect.andThen(Queue.offer(receipts, undefined)), + Effect.as(DEFAULT_SERVER_SETTINGS), + ), + }), + Layer.succeed(ServerActivation, Deferred.await(activation)), + ); + yield* Effect.gen(function* () { + const reactor = yield* ProjectSettingsReactor.ProjectSettingsReactor; + yield* reactor.start(); + const base = { + eventId: EventId.make("legacy-edit"), + aggregateKind: "project" as const, + aggregateId: ProjectId.make("project"), + occurredAt: "2026-09-12T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }; + yield* PubSub.publish(events, { + ...base, + sequence: 1, + type: "project.meta-updated", + payload: { projectId: base.aggregateId, title: "Renamed", updatedAt: base.occurredAt }, + }); + yield* PubSub.publish(events, { + ...base, + sequence: 2, + type: "project.meta-updated", + payload: { projectId: base.aggregateId, scripts: [], updatedAt: base.occurredAt }, + }); + assert.equal(updates, 0); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(receipts); + yield* reactor.drain; + assert.equal(updates, 1); + }).pipe(Effect.provide(ProjectSettingsReactor.layer.pipe(Layer.provide(dependencies)))); + }), + ), +); diff --git a/apps/server/src/orchestration/ProjectSettingsReactor.ts b/apps/server/src/orchestration/ProjectSettingsReactor.ts new file mode 100644 index 000000000..82b298d22 --- /dev/null +++ b/apps/server/src/orchestration/ProjectSettingsReactor.ts @@ -0,0 +1,58 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { forkParked } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; + +export class ProjectSettingsReactor extends Context.Service< + ProjectSettingsReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ProjectSettingsReactor") {} + +/** Old clients write the project aggregate; the settings service replays its durable journal. */ +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const settings = yield* ServerSettingsService; + const worker = yield* makeDrainableWorker((_event: OrchestrationEvent) => + settings.updateSettings({}).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("legacy project settings synchronization failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + const start = Effect.fn("ProjectSettingsReactor.start")(function* () { + const events = yield* engine.subscribeDomainEvents; + yield* forkParked( + Stream.runForEach(events, (event) => { + if ( + event.type === "project.created" || + (event.type === "project.meta-updated" && + (event.payload.defaultModelSelection !== undefined || + event.payload.defaultThreadEnvMode !== undefined || + event.payload.autoPull !== undefined || + event.payload.scripts !== undefined)) + ) { + return worker.enqueue(event); + } + return Effect.void; + }), + ); + }); + return { start, drain: worker.drain } satisfies ProjectSettingsReactor["Service"]; +}); + +export const layer = Layer.effect(ProjectSettingsReactor, make); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 8e392af31..ca48c64b4 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -234,7 +234,7 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadRuntimeContext: ( threadId: ThreadId, ) => Effect.Effect< - Option.Option>, + Option.Option>, ProjectionRepositoryError >; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 14453a2b9..455cb37a6 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -356,6 +356,36 @@ describe("ThreadSettlementReactor", () => { }), ), ); + + it("distinguishes a project that inherits the threshold from one that disables it", () => { + const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } }, + }); + const never = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null }, + }, + }); + assert.notStrictEqual(inherits, never); + }); + + it("ignores project overrides that do not touch settlement", () => { + const base = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, + }); + const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, + [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, + }, + }); + assert.strictEqual(base, unrelated); + }); + it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => Effect.scoped( Effect.gen(function* () { @@ -489,6 +519,59 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("a project override settles only that project's inactive threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const overriddenProject = ProjectId.make("overridden-project"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inherits-thread"), + makeThread("overridden-thread", { projectId: overriddenProject }), + ], + [makeProject(), makeProject(overriddenProject, "/workspace/overridden")], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + projectSettingsOverrides: { + [overriddenProject]: { sidebarAutoSettleAfterDays: 1 }, + }, + }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Queue.take(fixture.settingsReads); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("overridden-thread")], + ); + + // Clearing the override is a settlement change, so the sweep re-arms. + yield* fixture.updateSettings({ + projectSettingsOverrides: { [overriddenProject]: null }, + sidebarAutoSettleAfterDays: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + // The static snapshot never records the first settlement, so the + // second sweep dispatches for both; the inheriting thread is new. + assert.include( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + ThreadId.make("inherits-thread"), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("starts without clients and skips protected threads before pull request lookup", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 61fc5d4ab..b9041d297 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,4 +1,5 @@ -import { CommandId } from "@t3tools/contracts"; +import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service< } >()("t3/orchestration/ThreadSettlementReactor") {} +/** @public Service construction is part of the canonical Effect module API. */ +/** Whether any environment default or project override can settle a thread. */ +function autoSettlementConfigured(settings: ServerSettingsValue): boolean { + if (settings.sidebarAutoSettleOnMerge || settings.sidebarAutoSettleAfterDays !== null) { + return true; + } + return Object.values(settings.projectSettingsOverrides).some( + (entry) => + entry.sidebarAutoSettleOnMerge === true || + (entry.sidebarAutoSettleAfterDays !== undefined && entry.sidebarAutoSettleAfterDays !== null), + ); +} + +/** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */ +/** @internal Exported for tests. */ +export function autoSettlementSettingsKey(settings: ServerSettingsValue): string { + return JSON.stringify([ + settings.sidebarAutoSettleOnMerge, + settings.sidebarAutoSettleAfterDays, + // Only entries that touch settlement, in a stable order, so a project + // override on an unrelated key does not queue a sweep. JSON drops + // undefined, so inherit (absent) and never (null) need distinct marks. + Object.entries(settings.projectSettingsOverrides) + .filter( + ([, entry]) => + entry.sidebarAutoSettleOnMerge !== undefined || + entry.sidebarAutoSettleAfterDays !== undefined, + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([projectId, entry]) => [ + projectId, + entry.sidebarAutoSettleOnMerge ?? "inherit", + entry.sidebarAutoSettleAfterDays === undefined + ? "inherit" + : entry.sidebarAutoSettleAfterDays, + ]), + ]); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -46,7 +86,7 @@ export const make = Effect.gen(function* () { mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, ) { const settings = yield* settingsService.getSettings; - if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) { + if (!autoSettlementConfigured(settings)) { return; } const snapshot = yield* snapshots.getShellSnapshot(); @@ -60,7 +100,10 @@ export const make = Effect.gen(function* () { // dispatch skips it for this snapshot instead of retrying through a lookup. const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { - const settings = yield* settingsService.getSettings; + const settings = resolveProjectSettings( + yield* settingsService.getSettings, + thread.projectId, + ).settings; const decisionNow = DateTime.formatIso(yield* DateTime.now); const settledAt = resolveAutoSettlementAt({ thread, @@ -254,8 +297,7 @@ export const make = Effect.gen(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); - let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; - let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( Effect.gen(function* () { yield* worker.enqueue(undefined); @@ -264,14 +306,11 @@ export const make = Effect.gen(function* () { ); yield* forkParked( Stream.runForEach(settingsChanges, (settings) => { - if ( - settings.sidebarAutoSettleAfterDays === lastAfterDays && - settings.sidebarAutoSettleOnMerge === lastOnMerge - ) { + const key = autoSettlementSettingsKey(settings); + if (key === lastSettlementSettings) { return Effect.void; } - lastAfterDays = settings.sidebarAutoSettleAfterDays; - lastOnMerge = settings.sidebarAutoSettleOnMerge; + lastSettlementSettings = key; return worker.enqueue(undefined); }), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 6a4ecbd67..a9b652a9f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -157,7 +157,8 @@ export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => - reasoningEffort === model.defaultReasoningEffort + reasoningEffort === + (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) ? { id: reasoningEffort, label: reasoningEffortLabel(reasoningEffort), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 279ac2443..f8503203f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -83,6 +83,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; +import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { RollbackSagaRepositoryLive } from "../../persistence/Layers/RollbackSagas.ts"; import { RollbackSagaRepository } from "../../persistence/Services/RollbackSagas.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -730,16 +731,83 @@ function makeProviderServiceLayer( }; } -for (const [enabled, completed, retainedDaemon] of [ - [false, false, false], - [true, false, false], - [true, true, false], - [false, false, true], - [true, false, true], - [true, true, true], +const decodeProjectSettingsThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); +const makeThreadProjectProjectionLayer = ( + threadId: ThreadId, + projectId: ProjectId, + projectionStatus: () => "found" | "missing" | "failed" = () => "found", +) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getPendingRequestActivities: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), + getThreadShellById: (requestedThreadId) => + Effect.gen(function* () { + assert.equal(requestedThreadId, threadId); + const status = projectionStatus(); + if (status === "missing") return Option.none(); + if (status === "failed") { + return yield* new PersistenceSqlError({ operation: "get-thread-shell" }); + } + return Option.some( + yield* decodeProjectSettingsThreadShell({ + id: threadId, + projectId, + title: "Project settings test", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }).pipe(Effect.orDie), + ); + }), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); + +for (const [enabled, completed, retainedDaemon, projectOverride, projectionStatus = "found"] of [ + [false, false, false, undefined], + [true, false, false, undefined], + [true, true, false, undefined], + [false, false, true, undefined], + [true, false, true, undefined], + [true, true, true, undefined], + [false, false, false, true], + [true, false, false, false], + [false, false, true, true], + [true, false, true, false], + [true, false, false, false, "missing"], + [true, false, false, false, "failed"], + [true, false, true, false, "missing"], + [true, false, true, false, "failed"], + [true, false, false, false, "unavailable"], + [true, false, true, false, "unavailable"], ] as const) { it.effect( - `persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}, retainedDaemon=${retainedDaemon}`, + `persists shutdown recovery before stopping providers when enabled=${enabled}, completed=${completed}, retainedDaemon=${retainedDaemon}, projectOverride=${projectOverride}, projection=${projectionStatus}`, () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -756,8 +824,10 @@ for (const [enabled, completed, retainedDaemon] of [ Effect.provide(persistence), ); const threadId = asThreadId("shutdown-recovery"); + const projectId = ProjectId.make("shutdown-project"); const turnId = asTurnId("shutdown-recovery-turn"); const scope = yield* Scope.make(); + let stopping = false; const services = yield* Layer.build( makeProviderServiceLive().pipe( Layer.provide( @@ -772,7 +842,22 @@ for (const [enabled, completed, retainedDaemon] of [ }), ), ), - Layer.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: enabled })), + Layer.provide( + ServerSettings.layerTest({ + continueThreadsAfterServerUpdate: enabled, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : { [projectId]: { continueThreadsAfterServerUpdate: projectOverride } }, + }), + ), + Layer.provide( + projectOverride === undefined || projectionStatus === "unavailable" + ? Layer.empty + : makeThreadProjectProjectionLayer(threadId, projectId, () => + stopping ? projectionStatus : "found", + ), + ), Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( @@ -828,6 +913,7 @@ for (const [enabled, completed, retainedDaemon] of [ markers.push(binding.value.runtimePayload); }).pipe(Effect.orDie), ); + stopping = true; yield* Scope.close(scope, Exit.void); const binding = yield* directory.getBinding(threadId); assert(Option.isSome(binding)); @@ -837,7 +923,11 @@ for (const [enabled, completed, retainedDaemon] of [ assert.deepStrictEqual(binding.value.resumeCursor, session.resumeCursor); assert.equal(binding.value.status, "stopped"); assert.propertyVal(markers[0], "activeTurnId", completed ? null : turnId); - if (enabled && !completed) { + const continuationEnabled = + projectionStatus === "unavailable" + ? enabled + : projectionStatus === "found" && (projectOverride ?? enabled); + if (continuationEnabled && !completed) { assert.propertyVal(markers[0], "continueAfterServerUpdate", turnId); assert.propertyVal(binding.value.runtimePayload, "continueAfterServerUpdate", turnId); } else if (completed) { @@ -6564,8 +6654,6 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { ); }); -const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); - class RuntimeRollbackAdmission extends Context.Service()( "t3/provider/Layers/ProviderService.test/RuntimeRollbackAdmission", ) {} @@ -6579,52 +6667,10 @@ class RuntimeReaper extends Context.Service()( describe("agent browser access", () => { const projectId = ProjectId.make("project-browser-access"); - const makeBrowserAccessProjectionLayer = (threadId: ThreadId) => - Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { - getPendingRequestActivities: () => Effect.die("unused"), - getUserInputActivity: () => Effect.die("unused"), - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.die("unused"), - getCounts: () => Effect.die("unused"), - getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), - getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), - getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.die("unused"), - getFullThreadDiffContext: () => Effect.die("unused"), - getThreadRuntimeContext: () => Effect.die("unused"), - getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: (requestedThreadId) => - Effect.gen(function* () { - assert.equal(requestedThreadId, threadId); - return Option.some( - yield* decodeBrowserAccessThreadShell({ - id: threadId, - projectId, - title: "Browser access test", - modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), - runtimeMode: "full-access", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }), - ); - }).pipe(Effect.orDie), - getThreadDetailById: () => Effect.die("unused"), - getThreadDetailSnapshot: () => Effect.die("unused"), - searchThreads: () => Effect.die("unused"), - }); + const makeBrowserAccessProjectionLayer = ( + threadId: ThreadId, + status: "found" | "missing" | "failed" = "found", + ) => makeThreadProjectProjectionLayer(threadId, projectId, () => status); const makeAgentBrowserProviderLayer = ( enableAgentBrowserAccess: boolean, @@ -6632,9 +6678,13 @@ describe("agent browser access", () => { options: NonNullable[0]>, project?: { readonly threadId: ThreadId; - readonly override?: boolean | undefined; + readonly override?: + | boolean + | { readonly browser?: boolean; readonly device?: boolean } + | undefined; /** False leaves the projection query to the surrounding runtime composition. */ readonly provideProjection?: boolean; + readonly projectionStatus?: "found" | "missing" | "failed"; }, enableAgentDeviceAccess = false, ) => { @@ -6652,15 +6702,29 @@ describe("agent browser access", () => { Layer.provideMerge(directoryLayer), Layer.provide( project && project.provideProjection !== false - ? makeBrowserAccessProjectionLayer(project.threadId) + ? makeBrowserAccessProjectionLayer(project.threadId, project.projectionStatus) : Layer.empty, ), Layer.provide( ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, enableAgentDeviceAccess, - projectAgentBrowserAccessOverrides: - projectOverride === undefined ? {} : { [projectId]: projectOverride }, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : { + [projectId]: + typeof projectOverride === "boolean" + ? { enableAgentBrowserAccess: projectOverride } + : { + ...(projectOverride.browser !== undefined + ? { enableAgentBrowserAccess: projectOverride.browser } + : {}), + ...(projectOverride.device !== undefined + ? { enableAgentDeviceAccess: projectOverride.device } + : {}), + }, + }, }), ), Layer.provide(serverConfigTestLayer), @@ -6761,6 +6825,56 @@ describe("agent browser access", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect( + "resolves project device overrides and withholds only overridden unresolved capabilities", + () => + Effect.gen(function* () { + for (const [ + browser, + device, + override, + provideProjection, + expected, + projectionStatus = "found", + ] of [ + [false, false, { device: true }, true, ["device"]], + [true, true, { device: false }, true, ["preview"]], + [true, true, { device: false }, false, ["preview"]], + [false, false, { device: true }, false, []], + [true, true, { device: false }, true, ["preview"], "failed"], + ] as const) { + const threadId = asThreadId( + `thread-project-device-${browser}-${device}-${provideProjection}`, + ); + const issued: string[][] = []; + const codex = makeFakeCodexAdapter(); + const layer = makeAgentBrowserProviderLayer( + browser, + codex, + { + issueMcpCredential: (request) => + Effect.sync(() => { + issued.push([...request.capabilities].sort()); + return undefined; + }), + }, + { threadId, override, provideProjection, projectionStatus }, + device, + ); + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(layer)); + assert.deepEqual(issued, [[...expected, "pull-requests"].sort()]); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("does not publish retired MCP ownership after device CLI preparation yields", () => Effect.gen(function* () { const threadId = asThreadId("thread-device-generation-fence"); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 10fd745aa..63461f0b3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -54,16 +54,18 @@ import { ProviderSessionStartInput, ProviderStopSessionInput, ProviderUploadFeedbackInput, + type ProjectId, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, + type ServerSettings as ServerSettingsValue, } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -1154,34 +1156,42 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")( function* (threadId: ThreadId) { const settings = yield* serverSettings.getSettings; - if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { - return settings.enableAgentBrowserAccess; - } + const entries = Object.values(settings.projectSettingsOverrides); + const browserOverridden = entries.some( + (entry) => entry.enableAgentBrowserAccess !== undefined, + ); + const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined); + const environment = { + browser: settings.enableAgentBrowserAccess, + device: settings.enableAgentDeviceAccess, + }; + if (!browserOverridden && !deviceOverridden) return environment; // Provider-only runtimes may omit orchestration. An unresolved project - // must not bypass an explicit browser override. - if (Option.isNone(projectionQuery)) return false; - const thread = yield* projectionQuery.value.getThreadShellById(threadId); - if (Option.isNone(thread)) return false; - return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + // must not bypass an explicit project override, but a capability no + // project overrides keeps its environment value. + const denied = { + browser: browserOverridden ? false : environment.browser, + device: deviceOverridden ? false : environment.device, + }; + if (Option.isNone(projectionQuery)) return denied; + const thread = yield* projectionQuery.value + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(thread)) return denied; + const resolved = resolveProjectSettings(settings, thread.value.projectId).settings; + return { + browser: resolved.enableAgentBrowserAccess, + device: resolved.enableAgentDeviceAccess, + }; }, Effect.catch((cause) => Effect.logWarning( - "Could not read server settings; withholding agent browser access for this session.", + "Could not read server settings; withholding agent browser and device access for this session.", { cause }, - ).pipe(Effect.as(false)), - ), - ); - - const agentDeviceAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentDeviceAccess), - Effect.catch((cause) => - Effect.logWarning( - "Could not read server settings; withholding agent device access for this session.", - { cause }, - ).pipe(Effect.as(false)), + ).pipe(Effect.as({ browser: false, device: false })), ), ); @@ -1189,8 +1199,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, ) { const capabilities = new Set(["pull-requests"]); - if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview"); - if (yield* agentDeviceAccessEnabled) capabilities.add("device"); + const access = yield* agentAccessSettings(threadId); + if (access.browser) capabilities.add("preview"); + if (access.device) capabilities.add("device"); return capabilities; }); @@ -4196,11 +4207,36 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); - const runStopAll = Effect.fn("runStopAll")(function* () { - const continueAfterRestart = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.continueThreadsAfterServerUpdate), - Effect.orElseSucceed(() => false), + // Snapshot settings once per stop operation, then resolve continuation against + // each session's project in both the ordinary and mixed-adapter shutdown paths. + const readStopSettings = serverSettings.getSettings.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* ( + stopSettings: Option.Option, + threadId: ThreadId, + ) { + if (Option.isNone(stopSettings)) return false; + const settings = stopSettings.value; + const overridden = Object.values(settings.projectSettingsOverrides).some( + (entry) => entry.continueThreadsAfterServerUpdate !== undefined, ); + if (!overridden || Option.isNone(projectionQuery)) { + return settings.continueThreadsAfterServerUpdate; + } + const thread = yield* projectionQuery.value + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none<{ projectId: ProjectId }>())); + // With project overrides present, a missing or failed lookup cannot establish + // that continuation was allowed for this session. + if (Option.isNone(thread)) return false; + return resolveProjectSettings(settings, thread.value.projectId).settings + .continueThreadsAfterServerUpdate; + }); + + const runStopAll = Effect.fn("runStopAll")(function* () { + const stopSettings = yield* readStopSettings; yield* flushAllTurnAnalytics; const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; @@ -4215,15 +4251,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { - ...(continueAfterRestart && session.status === "running" && session.activeTurnId + Effect.gen(function* () { + const continueAfterRestart = + session.status === "running" && session.activeTurnId + ? yield* continueAfterRestartFor(stopSettings, session.threadId) + : false; + const lastRuntimeEventAt = yield* nowIso; + yield* upsertSessionBinding(session, session.threadId, { + ...(continueAfterRestart && session.activeTurnId ? { continueAfterServerUpdate: session.activeTurnId } : {}), lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, - }), - ), + }); + }), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe( Effect.asVoid, @@ -4262,10 +4303,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (currentAdapters.every(([, adapter]) => adapter.shutdown === undefined)) { return yield* runStopAll(); } - const continueAfterRestart = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.continueThreadsAfterServerUpdate), - Effect.orElseSucceed(() => false), - ); + const stopSettings = yield* readStopSettings; const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach( currentAdapters, @@ -4275,21 +4313,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( : Effect.gen(function* () { const activeSessions = yield* adapter.listSessions(); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding( + Effect.gen(function* () { + const continueAfterRestart = + session.status === "running" && session.activeTurnId + ? yield* continueAfterRestartFor(stopSettings, session.threadId) + : false; + const lastRuntimeEventAt = yield* nowIso; + yield* upsertSessionBinding( { ...session, providerInstanceId: instanceId }, session.threadId, { - ...(continueAfterRestart && - session.status === "running" && - session.activeTurnId + ...(continueAfterRestart && session.activeTurnId ? { continueAfterServerUpdate: session.activeTurnId } : {}), lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, }, - ), - ), + ); + }), ); yield* adapter.stopAll().pipe( Effect.ensuring( diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 562841004..4d6adb4ed 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -15,7 +15,7 @@ "providers": { "claudeAgent": { "defaults": { - "chat": "claude-sonnet-5" + "chat": "claude-fable-5-1" }, "profiles": { "fable-5": { @@ -32,12 +32,12 @@ }, { "id": "medium", - "label": "Medium" + "label": "Medium", + "isDefault": true }, { "id": "high", - "label": "High", - "isDefault": true + "label": "High" }, { "id": "xhigh", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e8ca6c477..48e48a0c8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -73,6 +73,7 @@ import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletion import * as RollbackSagaRunner from "./rollback/RollbackSagaRunner.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; +import * as ProjectSettingsReactor from "./orchestration/ProjectSettingsReactor.ts"; import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -297,6 +298,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(PullRequestSyncReactor.layer), Layer.provideMerge(ThreadPullRequestReactor.layer), + Layer.provideMerge(ProjectSettingsReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 9e159d1a0..e31355333 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }; }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; - const project = (workspaceRoot: string, autoPull = true) => - ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; - - yield* ServerRuntimeStartup.autoPullProjects([ - project("/clean"), - project("/current"), - project("/dirty"), - project("/ahead"), - project("/feature"), - project("/disabled", false), - ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + const project = (workspaceRoot: string) => + ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never; + const overrides = (entries: Record) => ({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: Object.fromEntries( + Object.entries(entries).map(([root, defaultAutoPull]) => [ + ProjectId.make(root), + { defaultAutoPull }, + ]), + ), + }); + + yield* ServerRuntimeStartup.autoPullProjects( + [ + project("/clean"), + project("/current"), + project("/dirty"), + project("/ahead"), + project("/feature"), + project("/disabled"), + ], + overrides({ + "/clean": true, + "/current": true, + "/dirty": true, + "/ahead": true, + "/feature": true, + "/disabled": false, + }), + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); pulled.length = 0; yield* ServerRuntimeStartup.autoPullProjects( - [project("/inherited", false), project("/opted-out"), project("/dirty", false)], - { - defaultAutoPull: true, - projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, - }, + [project("/inherited"), project("/opted-out"), project("/dirty")], + { ...overrides({ "/opted-out": false }), defaultAutoPull: true }, ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/inherited"]); }), @@ -204,12 +226,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }); it.effect.each([ - { existing: false, machineModel: null, projectModel: null }, - { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, - { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, - { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, -])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => + { + existing: false, + machineModel: null, + projectModel: null, + machineMode: "full-access", + projectMode: null, + }, + { + existing: false, + machineModel: "claude-sonnet-4-6", + projectModel: null, + machineMode: "approval-required", + projectMode: null, + }, + { + existing: true, + machineModel: "claude-sonnet-4-6", + projectModel: null, + machineMode: "auto", + projectMode: null, + }, + { + existing: true, + machineModel: "claude-sonnet-4-6", + projectModel: "gpt-5.4", + machineMode: "full-access", + projectMode: "auto-accept-edits", + }, +] as const)("auto-bootstrap model and permissions precedence: %j", (options) => Effect.gen(function* () { + const { existing, machineModel, projectModel, machineMode, projectMode } = options; const machineSelection = machineModel ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } : null; @@ -221,10 +268,25 @@ it.effect.each([ readonly type: string; readonly defaultModelSelection?: unknown; readonly modelSelection?: unknown; + readonly runtimeMode?: unknown; }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), + Effect.provide( + ServerSettings.layerTest({ + defaultModelSelection: machineSelection, + defaultRuntimeMode: machineMode, + projectSettingsOverrides: + existing && projectSelection + ? { + [ProjectId.make("existing-project")]: { + defaultModelSelection: projectSelection, + ...(projectMode ? { defaultRuntimeMode: projectMode } : {}), + }, + } + : {}, + }), + ), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -246,7 +308,7 @@ it.effect.each([ id: ProjectId.make("existing-project"), title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: projectSelection, + defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -291,6 +353,7 @@ it.effect.each([ existing ? ["thread.create"] : ["project.create", "thread.create"], ); if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); + assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode); assert.deepStrictEqual( commands.at(-1)?.modelSelection, projectSelection ?? diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 3d8309120..87cd0b927 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, + type ServerSettings as ServerSettingsValue, type ModelSelection, type OrchestrationProjectShell, type OrchestrationSession, @@ -11,7 +12,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -232,7 +233,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? defaultModelSelection; + resolveProjectSettings(settings, nextProjectId, existingProject.value).settings + .defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { @@ -249,7 +251,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { title: "New thread", modelSelection: nextThreadModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", + runtimeMode: resolveProjectSettings(settings, nextProjectId).settings + .defaultRuntimeMode, branch: null, worktreePath: null, createdAt, @@ -485,14 +488,19 @@ export const reconcileProviderSessions = Effect.gen(function* () { const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const rollbackRepository = yield* Effect.serviceOption(RollbackSagaRepository); const settings = yield* ServerSettings.ServerSettingsService; - const continueAfterRestart = yield* settings.getSettings.pipe( - Effect.map((value) => value.continueThreadsAfterServerUpdate), + const restartSettings = yield* settings.getSettings.pipe( + Effect.map(Option.some), Effect.catch((cause) => Effect.logWarning("could not read restart continuation preference", { cause }).pipe( - Effect.as(false), + Effect.as(Option.none()), ), ), ); + const continueAfterRestartFor = (projectId: ProjectId) => + Option.isSome(restartSettings) + ? resolveProjectSettings(restartSettings.value, projectId).settings + .continueThreadsAfterServerUpdate + : false; // Prime restart adoption installs rollback quarantine before exact-incarnation // fencing releases any retained native frames. @@ -610,7 +618,7 @@ export const reconcileProviderSessions = Effect.gen(function* () { // Runtime events advance the projection's turn, but not the directory's // last admitted turn. Use the projection to identify interrupted work. const interruptedByRestart = - continueAfterRestart && + continueAfterRestartFor(thread.projectId) && session.status === "running" && session.activeTurnId !== null && Option.isSome(binding) && @@ -833,16 +841,13 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, - settings: Pick< - typeof DEFAULT_SERVER_SETTINGS, - "defaultAutoPull" | "projectAutoPullOverrides" - > = DEFAULT_SERVER_SETTINGS, + settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) + .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull) .map((project) => project.workspaceRoot), ), ]; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6139988b7..aa7660d5f 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ModelSelection, + ProjectId, + ProjectMetaUpdatedPayload, + ProjectScript, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -29,6 +33,39 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); +const encodeLegacyProjectEditJson = Schema.encodeEffect( + Schema.fromJsonString(ProjectMetaUpdatedPayload), +); +const encodeModelSelectionJson = Schema.encodeEffect(Schema.fromJsonString(ModelSelection)); +const encodeProjectScriptsJson = Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(ProjectScript)), +); + +const appendLegacyProjectEdit = Effect.fn("appendLegacyProjectEdit")(function* ( + version: number, + payload: typeof ProjectMetaUpdatedPayload.Type, +) { + const sql = yield* SqlClient.SqlClient; + const encoded = yield* encodeLegacyProjectEditJson(payload); + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json + ) VALUES ( + ${`${payload.projectId}-${version}`}, ${"project"}, ${payload.projectId}, ${version}, + ${"project.meta-updated"}, ${payload.updatedAt}, ${"client"}, ${encoded}, ${"{}"} + ) + `; +}); + +const reloadSettings = Effect.gen(function* () { + const fresh = yield* ServerSettingsModule.ServerSettingsService; + return yield* fresh.getSettings; +}).pipe( + Effect.provide( + Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), + ), +); let providerMutationSequence = 0; const updateSettingsWithProviderInstances = Effect.fn("updateSettingsWithProviderInstances")( @@ -1535,4 +1572,218 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.include(persisted, '"valueRedacted": true'); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("folds legacy project overrides into projectSettingsOverrides once", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const legacyProject = ProjectId.make("project-legacy"); + const scriptedProject = ProjectId.make("project-scripted"); + const script: ProjectScript = { + id: "check", + name: "Check", + command: "npm test", + icon: "play", + runOnWorktreeCreate: false, + }; + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + const modelJson = yield* encodeModelSelectionJson(model); + const scriptsJson = yield* encodeProjectScriptsJson([script]); + for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ + // The legacy project also carries aggregate scripts, but its stored + // null override reset them; the fold must not bring them back. + [legacyProject, modelJson, "worktree", 1, scriptsJson], + [scriptedProject, null, null, 0, scriptsJson], + ] as const) { + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn}, + ${envMode}, ${autoPull}, ${scripts}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + } + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`, + ); + + const settings = yield* serverSettings.getSettings; + assert.isTrue(settings.projectSettingsFolded); + assert.deepEqual( + settings.projectSettingsOverrides, + { + [legacyProject]: { + enableAgentBrowserAccess: false, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + }, + [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, + }, + ); + // Derived legacy views keep older clients reading the same values. + assert.deepEqual( + settings.projectAutoPullOverrides, + { + [legacyProject]: true, + [scriptedProject]: true, + }, + ); + assert.deepEqual(settings.projectScriptOverrides, { + [scriptedProject]: [script], + }); + + // A reset survives the next load: the fold does not run again. + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { [legacyProject]: null }, + }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + const persisted = yield* decodeServerSettings( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.parse(raw), + ); + assert.isTrue(persisted.projectSettingsFolded); + assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("replays a committed legacy edit before the project projection catches up", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("unprojected-project"); + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + // No projection row exists yet: journal persistence already accepted the edit. + yield* appendLegacyProjectEdit(1, { + projectId, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + updatedAt: "2026-09-12T00:00:00.000Z", + }); + const settings = yield* serverSettings.getSettings; + assert.deepEqual(settings.projectSettingsOverrides[projectId], { + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + }); + assert.equal(settings.projectSettingsLegacySequence, 1); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect( + "canonical reset consumes prior legacy events but a repeated later edit still applies", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("reset-project"); + const payload = { + projectId, + defaultThreadEnvMode: "worktree" as const, + updatedAt: "2026-09-12T00:00:00.000Z", + }; + yield* serverSettings.getSettings; + yield* appendLegacyProjectEdit(1, payload); + // The reactor has not handled this event yet. The canonical write observes it. + const reset = yield* serverSettings.updateSettings({ + projectSettingsOverrides: { [projectId]: null }, + }); + assert.isUndefined(reset.projectSettingsOverrides[projectId]); + assert.equal(reset.projectSettingsLegacySequence, 1); + const delayedReceipt = yield* serverSettings.updateSettings({}); + assert.isUndefined(delayedReceipt.projectSettingsOverrides[projectId]); + assert.isUndefined((yield* reloadSettings).projectSettingsOverrides[projectId]); + // Same old aggregate value is a new user edit, not a stale projection value. + yield* appendLegacyProjectEdit(2, payload); + const edited = yield* serverSettings.updateSettings({}); + assert.deepEqual(edited.projectSettingsOverrides[projectId], { + defaultThreadEnvMode: "worktree", + }); + assert.equal(edited.projectSettingsLegacySequence, 2); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("replays legacy edits after restart and clears only their legacy fields", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("restart-project"); + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + const script: ProjectScript = { + id: "check", + name: "Check", + command: "npm test", + icon: "play", + runOnWorktreeCreate: false, + }; + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + enableAgentBrowserAccess: false, + }, + }, + }); + yield* appendLegacyProjectEdit(1, { + projectId, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + autoPull: true, + scripts: [script], + updatedAt: "2026-09-12T00:00:00.000Z", + }); + // Simulate stopping after commit but before the live reactor handled it. + const recovered = yield* reloadSettings; + assert.deepEqual(recovered.projectSettingsOverrides[projectId], { + enableAgentBrowserAccess: false, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultProjectScripts: [script], + }); + yield* appendLegacyProjectEdit(2, { + projectId, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + updatedAt: "2026-09-12T00:00:01.000Z", + }); + const cleared = yield* reloadSettings; + assert.deepEqual(cleared.projectSettingsOverrides[projectId], { + enableAgentBrowserAccess: false, + }); + assert.isUndefined(cleared.projectScriptOverrides[projectId]); + assert.isUndefined(cleared.projectAutoPullOverrides[projectId]); + assert.equal(cleared.projectSettingsLegacySequence, 2); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("leaves an unreadable settings.json untouched instead of folding over it", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + const broken = '{"defaultAutoPull": tru'; + yield* fileSystem.writeFileString(serverConfig.settingsPath, broken); + + const settings = yield* serverSettings.getSettings; + assert.isFalse(settings.projectSettingsFolded); + assert.deepEqual(settings.projectSettingsOverrides, {}); + // The user's file is still there to repair; nothing was written over it. + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index d32d6bbea..0f45df960 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,7 +15,10 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - type ModelSelection, + ModelSelection, + ProjectScript, + ProjectMetaUpdatedPayload, + type ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type UsageLimitSourceConfig, @@ -63,6 +66,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, + deriveLegacyProjectOverrides, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -133,6 +137,7 @@ const normalizeServerSettings = ( encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), + Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => new ServerSettingsError({ @@ -622,6 +627,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "providerHealthRefreshInterval", "sourceControlWriterModelSelection", "textGenerationModelSelection", + "pullRequestMergeMethod", ]); // Preserve both enabled states because provider history cannot recover a new opt-in. @@ -669,6 +675,96 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow return Object.is(current, defaults) ? undefined : current; } +const decodeProjectScriptsJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(ProjectScript)), +); +const decodeModelSelectionJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.NullOr(ModelSelection)), +); +const decodeLegacyProjectEditJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(ProjectMetaUpdatedPayload), +); + +interface LegacyProjectSettingsRow { + readonly projectId: string; + readonly defaultModelSelection: string | null; + readonly defaultThreadEnvMode: string | null; + readonly autoPull: number; + readonly scripts: string; +} + +/** + * One-time fold of the legacy per-project fields into `projectSettingsOverrides`: + * the three `project*Overrides` maps and the settings columns on the project + * aggregate. Keys already present in the generic record win. Marked with + * `projectSettingsFolded` so a later reset in the UI survives restarts. + */ +function foldLegacyProjectSettings( + settings: ServerSettings, + rows: ReadonlyArray, +): ServerSettings { + if (settings.projectSettingsFolded) return settings; + // Nothing to fold yet (fresh install): leave the marker off so the file + // stays sparse, and check again on the next load. + if ( + rows.length === 0 && + Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 && + Object.keys(settings.projectAutoPullOverrides).length === 0 && + Object.keys(settings.projectScriptOverrides).length === 0 + ) { + return settings; + } + const entries: Record = { + ...settings.projectSettingsOverrides, + }; + const set = ( + projectId: string, + key: K, + value: ProjectSettingsOverrides[K] | undefined, + ) => { + if (value === undefined) return; + const entry = entries[projectId] ?? {}; + if (Object.hasOwn(entry, key)) return; + entries[projectId] = { ...entry, [key]: value }; + }; + for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) { + set(projectId, "enableAgentBrowserAccess", value); + } + for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { + set(projectId, "defaultAutoPull", value); + } + // A stored null meant "reset to machine defaults", which is now plain + // inheritance; the project's own aggregate scripts must not resurface. + const resetScripts = new Set(); + for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { + if (value === null) resetScripts.add(projectId); + else set(projectId, "defaultProjectScripts", value); + } + for (const row of rows) { + const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null"); + if (Option.isSome(model) && model.value !== null) { + set(row.projectId, "defaultModelSelection", model.value); + } + if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") { + set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode); + } + if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); + const scripts = decodeProjectScriptsJson(row.scripts); + if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) { + set(row.projectId, "defaultProjectScripts", scripts.value); + } + } + const projectSettingsOverrides = Object.fromEntries( + Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0), + ); + return { + ...settings, + projectSettingsOverrides, + projectSettingsFolded: true, + ...deriveLegacyProjectOverrides({ projectSettingsOverrides }), + }; +} + const make = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; @@ -715,9 +811,127 @@ const make = Effect.gen(function* () { ), ); + const writeSettingsAtomically = Effect.fnUntraced( + function* (settings: ServerSettings) { + const sparseSettingsJson = yield* encodeServerSettingsJson( + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, + ); + + 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, + operation: "write-file", + cause, + }), + ), + ); + + const readLegacyProjectRows = sql` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", + scripts_json AS "scripts" + FROM projection_projects + WHERE deleted_at IS NULL + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-project-settings", + cause, + }), + ), + ); + + const reconcileLegacyProjectSettings = Effect.fn("ServerSettings.reconcileLegacyProjectSettings")( + function* (settings: ServerSettings) { + // The journal is durable even if the server stops before its live reactor + // observes the event. Capture rows and cursors in one SQL read transaction. + return yield* sql.withTransaction( + Effect.gen(function* () { + const [latest] = yield* sql<{ + sequence: number; + }>`SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events`; + const head = latest?.sequence ?? 0; + const [projection] = yield* sql<{ + sequence: number; + }>`SELECT last_applied_sequence AS sequence FROM projection_state WHERE projector = 'projection.projects'`; + const cursor = settings.projectSettingsLegacySequence ?? projection?.sequence ?? 0; + const rows = settings.projectSettingsFolded ? [] : yield* readLegacyProjectRows; + const folded = foldLegacyProjectSettings(settings, rows); + const events = yield* sql<{ payload: string }>` + SELECT payload_json AS payload FROM orchestration_events + WHERE sequence > ${cursor} AND sequence <= ${head} + AND event_type IN ('project.created', 'project.meta-updated') + ORDER BY sequence + `; + const entries: Record = { + ...folded.projectSettingsOverrides, + }; + for (const event of events) { + const payload = yield* decodeLegacyProjectEditJson(event.payload); + const entry = { ...entries[payload.projectId] }; + // In the old aggregate, null/empty/false mean inherit. Explicit false + // and empty-list overrides remain representable in the legacy maps. + if (payload.defaultModelSelection !== undefined) { + if (payload.defaultModelSelection === null) delete entry.defaultModelSelection; + else entry.defaultModelSelection = payload.defaultModelSelection; + } + if (payload.defaultThreadEnvMode !== undefined) { + if (payload.defaultThreadEnvMode === null) delete entry.defaultThreadEnvMode; + else entry.defaultThreadEnvMode = payload.defaultThreadEnvMode; + } + if (payload.autoPull !== undefined) { + if (payload.autoPull) entry.defaultAutoPull = true; + else delete entry.defaultAutoPull; + } + if (payload.scripts !== undefined) { + if (payload.scripts.length === 0) delete entry.defaultProjectScripts; + else entry.defaultProjectScripts = payload.scripts; + } + if (Object.keys(entry).length === 0) delete entries[payload.projectId]; + else entries[payload.projectId] = entry; + } + if ( + folded === settings && + cursor === head && + (settings.projectSettingsLegacySequence !== null || head === 0) + ) + return settings; + return { + ...folded, + projectSettingsFolded: folded.projectSettingsFolded || head > 0, + projectSettingsLegacySequence: head, + projectSettingsOverrides: entries, + ...deriveLegacyProjectOverrides({ projectSettingsOverrides: entries }), + }; + }), + ); + }, + Effect.mapError( + (cause) => + new ServerSettingsError({ settingsPath, operation: "read-project-settings", cause }), + ), + ); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + // A file that failed to decode must stay on disk for the user to repair; + // the fold below only writes when it started from the file's real contents. + let settingsFileTrusted = true; if (yield* readConfigExists) { const raw = yield* readRawConfig; @@ -728,6 +942,7 @@ const make = Effect.gen(function* () { } if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + settingsFileTrusted = false; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, @@ -766,9 +981,14 @@ const make = Effect.gen(function* () { ), ); - return foldProviderInstanceEnabledFlags( + const loaded = foldProviderInstanceEnabledFlags( restoreUsedProviders(settings, persisted, providerHistory), ); + const folded = settingsFileTrusted ? yield* reconcileLegacyProjectSettings(loaded) : loaded; + if (folded !== loaded) { + yield* writeSettingsAtomically(folded); + } + return folded; }); const settingsCache = yield* Cache.make({ @@ -1021,30 +1241,6 @@ const make = Effect.gen(function* () { }; }); - const writeSettingsAtomically = Effect.fnUntraced( - function* (settings: ServerSettings) { - const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, - ); - - 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, - operation: "write-file", - cause, - }), - ), - ); - const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); @@ -1124,7 +1320,7 @@ const make = Effect.gen(function* () { writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* rejectLegacyProviderInstancesPatch(patch); - const current = yield* getSettingsFromCache; + const current = yield* reconcileLegacyProjectSettings(yield* getSettingsFromCache); const currentMaterialized = yield* materializeProviderEnvironmentSecrets(current); const candidate = yield* normalizeServerSettings( applyServerSettingsPatch(currentMaterialized, patch), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index ae5121324..c5751c6ef 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,7 +22,7 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect( const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); if (project._tag === "None") return false; const settings = yield* serverSettings.getSettings; - return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull; }, Effect.orElseSucceed(() => false), ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 11c840952..11f33a79f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -84,6 +84,7 @@ import { projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { truncate } from "@t3tools/shared/String"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; @@ -173,7 +174,6 @@ import { } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, @@ -354,7 +354,6 @@ import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, - primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -1650,7 +1649,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1941,17 +1939,14 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? - settings.defaultModelSelection ?? - NO_PROVIDER_MODEL_SELECTION, + resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [ - draftThread, - fallbackDraftProject?.defaultModelSelection, - settings.defaultModelSelection, - threadId, - ], + [draftThread, fallbackDraftProject, settings, threadId], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -1979,7 +1974,11 @@ export default function ChatView(props: ChatViewProps) { // session.lastError. Bump a tick so the banner hides immediately. Mirrors // the branch mismatch banner. const [, setThreadErrorBannerDismissTick] = useState(0); - const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) + .settings.defaultRuntimeMode; + // Implicit drafts follow their current project/environment, including retargets. + // Explicit composer choices and existing server threads retain their permissions. + const runtimeMode = composerRuntimeMode ?? activeServerThread?.runtimeMode ?? defaultRuntimeMode; const planModeEnabled = useClientSettings((clientSettings) => clientSettings.planModeEnabled); // With legacy plan mode off, force the effective mode to "default" so a // thread saved in plan mode is not stranded there with its toggle hidden. @@ -2184,12 +2183,16 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + // Environment settings with the active project's overrides applied. + const activeProjectSettings = useMemo( + () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined), + [activeProject, settings], + ); const activeProjectScripts = useMemo( () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), [activeProject, settings], ); - const activeProjectDefaultModelSelection = - activeProject?.defaultModelSelection ?? settings.defaultModelSelection; + const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2439,7 +2442,8 @@ export default function ChatView(props: ChatViewProps) { setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings + .defaultRuntimeMode, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -2458,6 +2462,7 @@ export default function ChatView(props: ChatViewProps) { navigate, projectGroupingSettings, routeKind, + settings, setDraftThreadContext, setLogicalProjectDraftThreadId, ], @@ -4227,6 +4232,9 @@ export default function ChatView(props: ChatViewProps) { ], ); + const supportsProjectSettingsOverrides = + environmentById.get(environmentId)?.serverConfig?.environment.capabilities + .projectSettingsOverrides === true; const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -4251,7 +4259,21 @@ export default function ChatView(props: ChatViewProps) { const updateResult = write.kind === "settings" ? mapAtomCommandResult( - await updateProjectScriptSettings({ environmentId, input: { patch: write.patch } }), + await updateProjectScriptSettings({ + environmentId, + input: { + patch: supportsProjectSettingsOverrides + ? { + projectSettingsOverrides: { + [input.projectId]: { + ...settings.projectSettingsOverrides[input.projectId], + defaultProjectScripts: input.nextScripts, + }, + }, + } + : write.patch, + }, + }), () => undefined, ) : mapAtomCommandResult( @@ -4281,7 +4303,15 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentById, environmentId, updateProject, updateProjectScriptSettings, upsertKeybinding], + [ + environmentById, + environmentId, + settings.projectSettingsOverrides, + supportsProjectSettingsOverrides, + updateProject, + updateProjectScriptSettings, + upsertKeybinding, + ], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -5700,7 +5730,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + activeProjectSettings.settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -8713,7 +8743,7 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, - runtimeMode, + runtimeMode: defaultRuntimeMode, interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, @@ -8736,7 +8766,7 @@ export default function ChatView(props: ChatViewProps) { }, modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, - runtimeMode, + runtimeMode: defaultRuntimeMode, interactionMode: "default", sourceEpoch: 0, sourceProposedPlan: { @@ -8811,7 +8841,7 @@ export default function ChatView(props: ChatViewProps) { isServerThread, navigate, resetLocalDispatch, - runtimeMode, + defaultRuntimeMode, startThreadTurn, environmentId, composerRef, @@ -9216,7 +9246,7 @@ export default function ChatView(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -9228,7 +9258,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + activeProjectSettings.settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 20a19edea..7afb0a9cb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1933,8 +1933,7 @@ function OpenCommandPaletteDialog(props: { }, }); - // There is no projects listing page; the action targets the contextual - // project (active thread/draft, falling back to the first sidebar group). + // Target the active thread or draft's project, falling back to the first sidebar group. const contextualProjectGroup = (contextualProjectRef ? projectGroupByTargetKey.get( @@ -1986,8 +1985,6 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, - search: (previous) => - item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/ProjectEnvironmentBadge.tsx b/apps/web/src/components/ProjectEnvironmentBadge.tsx new file mode 100644 index 000000000..22be8298a --- /dev/null +++ b/apps/web/src/components/ProjectEnvironmentBadge.tsx @@ -0,0 +1,54 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; + +import type { SidebarProjectSnapshot } from "~/sidebarProjectGrouping"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Machine icon for a project picker row whose group has a member on another + * environment, with the environment names in a tooltip. Projects that only + * live on this device render nothing, the rule thread rows use for their + * machine icon. Callers + * render it only while the catalog spans environments (see + * projectGroupsSpanEnvironments), so single-machine users see no change. + */ +export function ProjectEnvironmentBadge(props: { + readonly group: Pick; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly machineByEnvironmentId: ReadonlyMap; +}) { + // Member order follows registration order and can differ between sessions, + // so sort by label to keep the icon and tooltip stable. + const remoteMembers = props.group.memberProjects + .filter((member) => member.environmentId !== props.primaryEnvironmentId) + .map((member) => ({ ...member, environmentLabel: member.environmentLabel ?? "Remote" })) + .sort((a, b) => a.environmentLabel.localeCompare(b.environmentLabel)); + const first = remoteMembers[0]; + if (!first) return null; + const labels = remoteMembers + .map((member) => member.environmentLabel) + .filter((label, index, all) => all.indexOf(label) === index) + .join(", "); + const alsoHere = remoteMembers.length < props.group.memberProjects.length; + const description = `${alsoHere ? "Also on" : "On"} ${labels}`; + return ( + + + } + > + + + {description} + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 67fe9aeec..97b250fd3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -101,6 +101,7 @@ import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, + projectGroupsSpanEnvironments, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -138,6 +139,7 @@ import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animateSidebarLayoutChanges, @@ -2420,6 +2422,13 @@ export default function Sidebar() { ], [projectGroups], ); + // Same-named projects on two machines are only told apart by where they + // live, so rows on another machine carry its icon once the catalog spans + // more than one environment; a single-machine catalog stays as it was. + const showProjectEnvironments = useMemo( + () => projectGroupsSpanEnvironments(projectGroups), + [projectGroups], + ); const projectGroupByScopeKey = useMemo( () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), [projectGroups], @@ -4527,6 +4536,13 @@ export default function Sidebar() { {scopedProjectGroup?.displayName ?? "All projects"} + {scopedProjectGroup && showProjectEnvironments ? ( + + ) : null} )} {item.label} + {project && showProjectEnvironments ? ( + + ) : null} {project ? ( - ); - })} - - - + +
+
+ +
+ + + } + > + + + +
+ {SCRIPT_ICONS.map((entry) => { + const isSelected = entry.id === icon; + return ( + + ); + })} +
+
+
+ setName(event.target.value)} + /> +
+
+
+ setName(event.target.value)} + id="script-keybinding" + placeholder="Press shortcut" + value={keybinding} + readOnly + onKeyDown={captureKeybinding} /> +

+ Press a shortcut. Use Backspace to clear. Shortcuts are + environment-wide. Projects using the same action share its shortcut. +

- -
- - -

- Press a shortcut. Use Backspace to clear. -

-
-
- -