diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 1434aacc90d5b..a11bda8cae561 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -53,6 +53,24 @@ jobs: # the exact files below. Each one is a reviewed decision; every other # path under those roots fails. allowed_upstream_changes=( + # Managed Folder entry uses the shared native project and settings paths. + apps/web/src/hooks/useScratchProject.ts + apps/web/src/components/sidebar/SidebarThreadHeader.tsx + apps/web/src/components/Sidebar.tsx + apps/web/src/components/chat/DraftHeroHeadline.tsx + # Managed Lazurio Folder: server-owned start context and model recommendation. + apps/web/src/components/CommandPalette.tsx + apps/web/src/components/GitActionsControl.tsx + apps/web/src/components/onboarding/FirstRunGate.tsx + apps/web/src/hooks/useHandleNewThread.ts + apps/web/src/hooks/useHandleNewThread.test.ts + apps/web/src/routes/welcome.tsx + apps/web/src/lazurio/ManagedFolderWelcome.tsx + apps/web/src/lazurio/managedFolder.ts + apps/web/src/lazurio/managedFolder.test.ts + packages/contracts/src/server.ts + packages/contracts/src/settings.ts + packages/shared/src/serverSettings.ts # In-app update from the configured release channel; proposed upstream. apps/web/src/components/ChatView.tsx apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -154,6 +172,10 @@ jobs: working-directory: apps/server run: pnpm exec vp test run src/lazurio + - name: Test native managed Folder and preference boundaries + working-directory: apps/server + run: pnpm exec vp test run src/orchestration/Layers/OrchestrationEngine.test.ts src/serverSettings.test.ts src/vcs/VcsProvisioningService.test.ts + - name: Test the Lazurio web overlay working-directory: apps/web run: pnpm exec vp test run src/lazurio diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index e30870aac8738..46d199435d04e 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -23,6 +23,7 @@ import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { sshHelperCommand } from "./cli/sshHelper.ts"; +import { lazurioCommand } from "./cli/lazurio.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -72,6 +73,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => servicePreflightCommand, sshHelperCommand, themeCommand, + lazurioCommand, traceCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 426a909548523..81a6c0fcd25a5 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -92,6 +92,14 @@ export const traceMaxFilesConfig = Config.Int("T3CODE_TRACE_MAX_FILES").pipe( ); const EnvServerConfig = Config.all({ + lazurioToolsUrl: Config.URL("T3CODE_LAZURIO_TOOLS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + lazurioFolder: Config.String("T3CODE_LAZURIO_FOLDER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), logLevel: Config.LogLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), traceMinLevel: Config.LogLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), traceTimingEnabled: Config.Boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), @@ -369,6 +377,50 @@ export const resolveServerConfig = ( const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); yield* fs.makeDirectory(cwd, { recursive: true }); + const declaredFolder = env.lazurioFolder; + const lazurioFolder = + declaredFolder === undefined + ? undefined + : yield* Effect.gen(function* () { + if (!path.isAbsolute(declaredFolder)) + return yield* Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_LAZURIO_FOLDER must be an absolute existing directory.", + }), + ), + ), + ); + const root = yield* fs.realPath(declaredFolder); + const info = yield* fs.stat(root); + if (info.type !== "Directory") + return yield* Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_LAZURIO_FOLDER must be a directory.", + }), + ), + ), + ); + return root; + }); + if ( + env.lazurioToolsUrl !== undefined && + (env.lazurioToolsUrl.protocol !== "https:" || + env.lazurioToolsUrl.username || + env.lazurioToolsUrl.password) + ) + return yield* Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_LAZURIO_TOOLS_URL must be an HTTPS URL without credentials.", + }), + ), + ), + ); const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: Option.isSome(explicitBaseDir), }); @@ -483,7 +535,11 @@ export const resolveServerConfig = ( otelEnvironment: otel, mode, port, - cwd, + cwd: lazurioFolder ?? cwd, + ...(lazurioFolder === undefined ? {} : { lazurioFolder }), + ...(env.lazurioToolsUrl === undefined + ? {} + : { lazurioToolsUrl: env.lazurioToolsUrl.toString() }), baseDir, ...derivedPaths, serverTracePath, diff --git a/apps/server/src/cli/lazurio.ts b/apps/server/src/cli/lazurio.ts new file mode 100644 index 0000000000000..57cb66c3bf2b1 --- /dev/null +++ b/apps/server/src/cli/lazurio.ts @@ -0,0 +1,124 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Config from "effect/Config"; +import { AuthOrchestrationOperateScope, ServerSettings } from "@t3tools/contracts"; +import * as FileSystem from "effect/FileSystem"; +import { resolveBaseDir } from "../os-jank.ts"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Argument, Command, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient, HttpBody, HttpClient } from "effect/unstable/http"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { + MODEL_RECOMMENDATION_ROUTE, + ModelRecommendationRequest, +} from "../lazurio/modelRecommendation.ts"; + +class RecommendationCommandError extends Schema.TaggedError()( + "RecommendationCommandError", + { detail: Schema.String }, +) { + override get message() { + return this.detail; + } +} + +const modelCommand = Command.make("model", { + ...projectLocationFlags, + selection: Argument.String("selection-json").pipe(Argument.optional), +}).pipe( + Command.withDescription( + "Read saved model metadata without changing state, or reconcile a Platform recommendation through the running service.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + // Metadata-only status must not mint a session, create directories or + // start a second settings writer. The server remains the sole writer. + if (Option.isNone(flags.selection)) { + const configuredHome = yield* Config.String("T3CODE_HOME").pipe(Config.option); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined(Option.orElse(flags.baseDir, () => configuredHome)), + ); + const paths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { + baseDirIsExplicit: true, + }); + const fs = yield* FileSystem.FileSystem; + const settings = (yield* fs.exists(paths.settingsPath)) + ? yield* Schema.decodeEffect(fromLenientJson(ServerSettings))( + yield* fs.readFileString(paths.settingsPath), + ) + : { defaultModelSelection: null, lazurioManagedModelSelection: null }; + yield* Console.log( + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + version: 1, + current: settings.defaultModelSelection, + lastManaged: settings.lazurioManagedModelSelection, + }), + ); + return; + } + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const selection = yield* Schema.decodeEffect( + Schema.fromJsonString(ModelRecommendationRequest), + )(flags.selection.value); + const state = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(state) || !isProcessAlive(state.value.pid)) + return yield* new RecommendationCommandError({ + detail: "The T3 service must be running. No settings were changed.", + }); + return yield* Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const client = yield* HttpClient.HttpClient; + const output = yield* Effect.acquireUseRelease( + auth.issueSession({ + scopes: [AuthOrchestrationOperateScope], + label: "lazurio model reconciliation", + }), + (issued) => + Effect.gen(function* () { + const headers = { authorization: `Bearer ${issued.token}` }; + const url = `${state.value.origin}${MODEL_RECOMMENDATION_ROUTE}`; + const response = yield* client.post(url, { + headers, + body: yield* HttpBody.json(selection), + }); + if (response.status !== 200) + return yield* new RecommendationCommandError({ + detail: `The live T3 service refused the recommendation (${response.status}). No offline fallback was attempted.`, + }); + return yield* response.json; + }).pipe(Effect.timeout("10 seconds")), + (issued) => auth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + yield* Console.log( + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(output), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + EnvironmentAuth.runtimeLayer.pipe(Layer.provide(ServerConfig.layer(config))), + FetchHttpClient.layer, + ), + ), + ); + }), + ), +); +const capabilitiesCommand = Command.make("capabilities").pipe( + Command.withDescription( + "Report the versioned Lazurio hosting contract without reading or changing user state.", + ), + Command.withHandler(() => + Console.log(JSON.stringify({ version: 1, managedFolder: true, modelRecommendation: true })), + ), +); + +export const lazurioCommand = Command.make("lazurio").pipe( + Command.withSubcommands([modelCommand, capabilitiesCommand]), +); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bebeb57f22f46..b588adaeb079e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -89,6 +89,9 @@ export class ServerConfig extends Context.Service< /** Canonical HTTPS browser origin of a reverse-proxied server (`T3CODE_EXTERNAL_ORIGIN`). */ readonly externalOrigin?: URL | undefined; readonly cwd: string; + /** Opt-in hosted Folder entry contract, declared by Machines. */ + readonly lazurioFolder?: string; + readonly lazurioToolsUrl?: string; readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 7a81d757c0ac0..27cf0ff1dbc32 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -144,6 +144,7 @@ const fixture = Effect.fn("fixture")(function* ( start: Effect.void, ready: Effect.void, getSettings: Ref.get(settings), + reconcileRecommendedModel: () => Effect.die("unexpected recommendation in this fixture"), updateSettings: (patch) => Ref.updateAndGet(settings, (current) => ({ ...current, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 30b368e62167f..20ded5f9d3574 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,6 @@ import * as Mime from "effect/unstable/http/Mime"; import { + type AuthEnvironmentScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, @@ -272,9 +273,7 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = ( - scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, -) => +export const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/lazurio/managedFolder.ts b/apps/server/src/lazurio/managedFolder.ts new file mode 100644 index 0000000000000..341bc631bae23 --- /dev/null +++ b/apps/server/src/lazurio/managedFolder.ts @@ -0,0 +1,54 @@ +import type { OrchestrationCommand, OrchestrationReadModel } from "@t3tools/contracts"; + +// This is an entry-context contract, not a filesystem sandbox. Agents start +// at the Folder and can then work in its repositories with their normal tools. +export function managedFolderRefusal( + folder: string | undefined, + command: OrchestrationCommand, + state: Pick, +): string | null { + if (folder === undefined) return null; + const message = + "Start a new chat in the Lazurio project. This Environment starts agents in its Lazurio Folder; existing conversations remain available as history."; + const project = (id: string) => state.projects.find((item) => item.id === id); + const allowedProject = (id: string) => project(id)?.workspaceRoot === folder; + switch (command.type) { + case "project.create": + return command.workspaceRoot === folder + ? null + : "This Environment provides the Lazurio project at its Folder root."; + case "project.meta.update": + if ( + allowedProject(command.projectId) && + command.title !== undefined && + command.title !== "Lazurio" + ) + return "The managed project is named Lazurio."; + return command.workspaceRoot !== undefined && + command.workspaceRoot !== project(command.projectId)?.workspaceRoot + ? "A managed Environment does not retarget projects. Use the Lazurio project." + : null; + case "project.delete": + return allowedProject(command.projectId) + ? "The Lazurio project is provided by this Environment and cannot be removed." + : null; + case "thread.create": + return allowedProject(command.projectId) && command.worktreePath === null ? null : message; + case "thread.meta.update": + return command.worktreePath != null + ? "Chats in this Environment start at the Lazurio Folder root, without a T3 worktree." + : null; + case "thread.turn.start": { + const thread = state.threads.find((item) => item.id === command.threadId); + const target = command.bootstrap?.createThread ?? thread; + return target !== undefined && + allowedProject(target.projectId) && + target.worktreePath === null && + command.bootstrap?.prepareWorktree === undefined + ? null + : message; + } + default: + return null; + } +} diff --git a/apps/server/src/lazurio/managedFolderStartup.ts b/apps/server/src/lazurio/managedFolderStartup.ts new file mode 100644 index 0000000000000..146c60cf872ae --- /dev/null +++ b/apps/server/src/lazurio/managedFolderStartup.ts @@ -0,0 +1,40 @@ +import { CommandId, ProjectId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as ServerConfig from "../config.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; + +// The server is the only writer. Machines declares the Folder in its unit; +// this uses native commands, never the CLI's offline fallback or a DB edit. +export const ensureManagedFolderProject = Effect.gen(function* () { + const { lazurioFolder } = yield* ServerConfig.ServerConfig; + if (lazurioFolder === undefined) return {}; + const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + const existing = yield* query.getActiveProjectByWorkspaceRoot(lazurioFolder); + const projectId = Option.isSome(existing) + ? existing.value.id + : ProjectId.make(yield* crypto.randomUUIDv4); + if (Option.isNone(existing)) { + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + projectId, + title: "Lazurio", + workspaceRoot: lazurioFolder, + createdAt: DateTime.formatIso(yield* DateTime.now), + }); + } else if (existing.value.title !== "Lazurio") { + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + projectId, + title: "Lazurio", + }); + } + return { bootstrapProjectId: projectId, bootstrapProjectCreated: Option.isNone(existing) }; +}); diff --git a/apps/server/src/lazurio/modelRecommendation.ts b/apps/server/src/lazurio/modelRecommendation.ts new file mode 100644 index 0000000000000..3598b8bdc8ab3 --- /dev/null +++ b/apps/server/src/lazurio/modelRecommendation.ts @@ -0,0 +1,7 @@ +import { ModelSelection } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +export const MODEL_RECOMMENDATION_ROUTE = "/api/lazurio/model-recommendation"; +export const ModelRecommendationRequest = Schema.Struct({ + folder: Schema.String, + selection: ModelSelection, +}); diff --git a/apps/server/src/lazurio/modelRecommendationRoute.test.ts b/apps/server/src/lazurio/modelRecommendationRoute.test.ts new file mode 100644 index 0000000000000..d0e561fe21604 --- /dev/null +++ b/apps/server/src/lazurio/modelRecommendationRoute.test.ts @@ -0,0 +1,96 @@ +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter } from "effect/unstable/http"; +import * as ServerConfig from "../config.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as Settings from "../serverSettings.ts"; +import * as Route from "./modelRecommendationRoute.ts"; +import { MODEL_RECOMMENDATION_ROUTE } from "./modelRecommendation.ts"; +const configLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + lazurioFolder: "/workspace/Lazurio", + })), +).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-model-route-" }))); +const authLayer = EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provideMerge(configLayer), +); +it.layer(Layer.mergeAll(authLayer, Settings.layerTest()).pipe(Layer.provide(NodeServices.layer)), { + excludeTestServices: true, +})("native model recommendation route", (it) => { + it.effect( + "requires native authorization and the exact Folder; preserves custom preferences", + () => + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const settings = yield* Settings.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const handler = HttpRouter.toWebHandler( + Route.layer.pipe( + Layer.provideMerge(Layer.succeed(EnvironmentAuth.EnvironmentAuth, auth)), + Layer.provideMerge(Layer.succeed(Settings.ServerSettingsService, settings)), + Layer.provideMerge(ServerConfig.layer(config)), + Layer.provideMerge(NodeHttpPlatform.layer), + Layer.provideMerge(NodeServices.layer), + ), + { disableLogger: true }, + ); + yield* Effect.addFinalizer(() => Effect.promise(() => handler.dispose())); + const readOnly = yield* auth.issueSession({ + scopes: [AuthOrchestrationReadScope], + label: "test", + }); + const writer = yield* auth.issueSession({ + scopes: [AuthOrchestrationOperateScope], + label: "test", + }); + const selection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6.1-sol", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const post = (body: unknown, token?: string) => + handler.handler( + new Request(`http://localhost${MODEL_RECOMMENDATION_ROUTE}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }), + ); + yield* Effect.promise(async () => { + expect((await post({})).status).toBe(401); + expect((await post({}, readOnly.token)).status).toBe(403); + expect((await post({}, writer.token)).status).toBe(400); + expect((await post({ folder: "/elsewhere", selection }, writer.token)).status).toBe(409); + expect( + (await post({ folder: config.lazurioFolder, selection }, writer.token)).status, + ).toBe(200); + }); + expect((yield* settings.getSettings).defaultModelSelection).toEqual(selection); + const custom = { ...selection, options: [{ id: "reasoningEffort", value: "low" }] }; + yield* settings.updateSettings({ defaultModelSelection: custom }); + yield* Effect.promise(() => + post({ folder: config.lazurioFolder, selection }, writer.token), + ); + expect((yield* settings.getSettings).defaultModelSelection).toEqual(custom); + }), + ); +}); diff --git a/apps/server/src/lazurio/modelRecommendationRoute.ts b/apps/server/src/lazurio/modelRecommendationRoute.ts new file mode 100644 index 0000000000000..12811cf6cd432 --- /dev/null +++ b/apps/server/src/lazurio/modelRecommendationRoute.ts @@ -0,0 +1,52 @@ +import { type ModelSelection, WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { authenticateRawRouteWithScope } from "../http.ts"; +import { requiredScopeForRpcMethod } from "../auth/RpcAuthorization.ts"; +import { failEnvironmentInternal } from "../auth/http.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +import { MODEL_RECOMMENDATION_ROUTE, ModelRecommendationRequest } from "./modelRecommendation.ts"; +import { ServerConfig } from "../config.ts"; +// Platform supplies product recommendations. T3 only owns reconciliation and +// persistence of its own preference, including evidence of its last managed write. +export const layer = HttpRouter.use((router) => + Effect.gen(function* () { + const settings = yield* ServerSettingsService; + const config = yield* ServerConfig; + const answer = (value: { + defaultModelSelection: ModelSelection | null; + lazurioManagedModelSelection: ModelSelection | null; + }) => + HttpServerResponse.jsonUnsafe({ + version: 1, + current: value.defaultModelSelection, + lastManaged: value.lazurioManagedModelSelection, + }); + yield* router.add( + "POST", + MODEL_RECOMMENDATION_ROUTE, + Effect.gen(function* () { + yield* authenticateRawRouteWithScope( + requiredScopeForRpcMethod(WS_METHODS.serverUpdateSettings), + ); + const selection = yield* HttpServerRequest.schemaBodyJson(ModelRecommendationRequest).pipe( + Effect.option, + ); + if (Option.isNone(selection)) + return HttpServerResponse.jsonUnsafe( + { error: "invalid-recommendation" }, + { status: 400 }, + ); + if (config.lazurioFolder === undefined || selection.value.folder !== config.lazurioFolder) + return HttpServerResponse.jsonUnsafe({ error: "folder-mismatch" }, { status: 409 }); + return answer( + yield* settings + .reconcileRecommendedModel(selection.value.selection) + .pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause))), + ); + }), + ); + }), +); diff --git a/apps/server/src/lazurio/overlay.ts b/apps/server/src/lazurio/overlay.ts index 185232d156ba0..eeb675aadb613 100644 --- a/apps/server/src/lazurio/overlay.ts +++ b/apps/server/src/lazurio/overlay.ts @@ -6,6 +6,7 @@ * threadTabsRoute.ts, and archiving or deleting a thread lets go of them (threadLifecycle.ts). */ +import * as ModelRecommendationRoute from "./modelRecommendationRoute.ts"; import * as Layer from "effect/Layer"; import * as EnvironmentBrowser from "./environmentBrowser.ts"; @@ -13,6 +14,8 @@ import * as ThreadLifecycle from "./threadLifecycle.ts"; import * as ThreadTabs from "./threadTabs.ts"; import * as ThreadTabsRoute from "./threadTabsRoute.ts"; -export const layer = Layer.mergeAll(EnvironmentBrowser.layer, ThreadTabsRoute.layer).pipe( - Layer.provide(ThreadTabs.layer.pipe(Layer.provide(ThreadLifecycle.layer))), -); +export const layer = Layer.mergeAll( + EnvironmentBrowser.layer, + ThreadTabsRoute.layer, + ModelRecommendationRoute.layer, +).pipe(Layer.provide(ThreadTabs.layer.pipe(Layer.provide(ThreadLifecycle.layer)))); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 9ccf78ca1744c..d01c4a3da8acf 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -54,6 +54,7 @@ import { } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ServerConfig } from "../../config.ts"; +import { ensureManagedFolderProject } from "../../lazurio/managedFolderStartup.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); @@ -63,13 +64,24 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val function makeOrchestrationLayer( databasePath?: string, repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], + lazurioFolder?: string, ) { const persistence = databasePath ? makeSqlitePersistenceLive(databasePath) : SqlitePersistenceMemory; - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-orchestration-engine-test-", - }); + const ServerConfigLayer = Layer.effect( + ServerConfig, + Effect.service(ServerConfig).pipe( + Effect.map((config) => ({ + ...config, + ...(lazurioFolder === undefined ? {} : { lazurioFolder }), + })), + ), + ).pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-" }), + ), + ); return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), @@ -130,6 +142,127 @@ const hasMetricSnapshot = ( ); describe("OrchestrationEngine", () => { + it("adopts an existing Folder project on restart and retains other project history", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "managed-upgrade-")); + const databasePath = NodePath.join(directory, "state.sqlite"); + const folder = "/workspace/Lazurio"; + const previous = await createOrchestrationSystem(databasePath); + try { + for (const [name, path] of [ + ["folder", folder], + ["history", "/old/repository"], + ] as const) { + await previous.run( + previous.engine.dispatch({ + type: "project.create", + commandId: CommandId.make(name), + projectId: ProjectId.make(name), + title: name, + workspaceRoot: path, + createdAt: now(), + }), + ); + } + } finally { + await previous.dispose(); + } + const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath, undefined, folder)); + try { + const adopted = await runtime.runPromise(ensureManagedFolderProject); + expect(adopted.bootstrapProjectId).toBe("folder"); + expect(adopted.bootstrapProjectCreated).toBe(false); + await runtime.runPromise(ensureManagedFolderProject); + const snapshot = await runtime.runPromise( + Effect.service(ProjectionSnapshotQuery).pipe( + Effect.flatMap((query) => query.getSnapshot()), + ), + ); + expect(snapshot.projects).toHaveLength(2); + expect(snapshot.projects.find((p) => p.id === "history")?.workspaceRoot).toBe( + "/old/repository", + ); + } finally { + await runtime.dispose(); + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }); + + it("managed startup adopts or creates one native project and the engine enforces its entry context", async () => { + const folder = "/tmp/managed-lazurio"; + const runtime = ManagedRuntime.make(makeOrchestrationLayer(undefined, undefined, folder)); + try { + const ensure = ensureManagedFolderProject; + const first = await runtime.runPromise(ensure); + const second = await runtime.runPromise(ensure); + expect(first.bootstrapProjectCreated).toBe(true); + expect(second).toEqual({ ...first, bootstrapProjectCreated: false }); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const snapshot = await runtime.runPromise( + Effect.service(ProjectionSnapshotQuery).pipe( + Effect.flatMap((query) => query.getSnapshot()), + ), + ); + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.projects[0]?.workspaceRoot).toBe(folder); + const id = first.bootstrapProjectId!; + const dispatch = (command: OrchestrationCommand) => + runtime.runPromise(engine.dispatch(command)); + await expect( + dispatch({ + type: "project.create", + commandId: CommandId.make("other-root"), + projectId: ProjectId.make("other"), + title: "Other", + workspaceRoot: `${folder}/organizations/example`, + createdAt: now(), + }), + ).rejects.toThrow("Lazurio"); + await expect( + dispatch({ + type: "project.meta.update", + commandId: CommandId.make("retarget"), + projectId: id, + workspaceRoot: "/tmp/other", + }), + ).rejects.toThrow("retarget"); + await expect( + dispatch({ type: "project.delete", commandId: CommandId.make("delete"), projectId: id }), + ).rejects.toThrow("cannot be removed"); + const thread = { + type: "thread.create" as const, + commandId: CommandId.make("root-thread"), + threadId: ThreadId.make("root-thread"), + projectId: id, + title: "New thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt: now(), + }; + await dispatch(thread); + await expect( + dispatch({ + ...thread, + commandId: CommandId.make("worktree-thread"), + threadId: ThreadId.make("worktree-thread"), + worktreePath: "/tmp/worktree", + }), + ).rejects.toThrow("Lazurio"); + await expect( + dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("move-thread"), + threadId: thread.threadId, + worktreePath: "/tmp/worktree", + }), + ).rejects.toThrow("Lazurio"); + } finally { + await runtime.dispose(); + } + }); + it.each(["running", "stopped"] as const)( "sends async answers with a %s session and rejects old duplicate replies", async (status) => { @@ -473,6 +606,7 @@ describe("OrchestrationEngine", () => { Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-engine-managed-" })), Layer.provideMerge(NodeServices.layer), ); @@ -1628,6 +1762,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-engine-managed-" })), Layer.provide(NodeServices.layer), ), ); @@ -1777,6 +1912,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-engine-managed-" })), Layer.provide(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 9136d080c1c37..90b2ed9462b6e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -40,6 +40,8 @@ import { type OrchestrationDispatchError, type OrchestrationProjectorDecodeError, } from "../Errors.ts"; +import { ServerConfig } from "../../config.ts"; +import { managedFolderRefusal } from "../../lazurio/managedFolder.ts"; import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; @@ -89,6 +91,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; + const { lazurioFolder } = yield* ServerConfig; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -242,6 +245,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { envelope.command.type === "thread.user-input.dismiss" ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command) : Option.none(); + const refusal = managedFolderRefusal(lazurioFolder, envelope.command, commandReadModel); + if (refusal !== null) + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: refusal, + }); const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 77022a8518d7f..ffb6cdc8b9814 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,3 +1,4 @@ +import { ServerConfig } from "../../config.ts"; import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; import { type ChatAttachment, @@ -211,6 +212,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { } const make = Effect.gen(function* () { + const { lazurioFolder } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -710,6 +712,20 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + if (lazurioFolder !== undefined) { + const canonicalCwd = + effectiveCwd === undefined + ? null + : yield* fileSystem.realPath(effectiveCwd).pipe(Effect.orElseSucceed(() => null)); + if (canonicalCwd !== lazurioFolder) { + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: + "This Environment starts agents in its Lazurio Folder. Start a new chat in the Lazurio project.", + }); + } + } const refreshWorkspaceSnapshot = effectiveCwd ? providerRegistry .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index cad45007665ca..9ed1f5dab77de 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -259,6 +259,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ready: Effect.void, getSettings: Ref.get(settings).pipe(Effect.tap((value) => Queue.offer(settingsReads, value))), updateSettings, + reconcileRecommendedModel: () => Effect.die("unexpected recommendation in this fixture"), streamChanges: Stream.fromPubSub(settingsChanges), subscribeChanges: PubSub.subscribe(settingsChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0970ea88748b3..f38c45249d3c9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -337,6 +337,7 @@ function makeMutableServerSettingsService( start: Effect.void, ready: Effect.void, getSettings: Ref.get(settingsRef), + reconcileRecommendedModel: () => Effect.die("unexpected recommendation in this fixture"), updateSettings: (patch) => Effect.gen(function* () { const current = yield* Ref.get(settingsRef); diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 292ef66ff2eec..87d460b6a8c90 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -299,6 +299,8 @@ describe("makeManagedServerProvider", () => { start: Effect.void, ready: Effect.void, getSettings: Ref.get(serverSettingsRef), + reconcileRecommendedModel: () => + Effect.die("unexpected recommendation in this fixture"), updateSettings: () => Effect.die(new Error("unused in this test")), streamChanges: Stream.empty, subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 138dbcedfa850..c7c9797598148 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -33,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as ServerConfig from "./config.ts"; +import { ensureManagedFolderProject } from "./lazurio/managedFolderStartup.ts"; import { flushCompileCache } from "./compileCache.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; @@ -205,7 +206,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectCreated = false; let bootstrapThreadCreated = false; - if (serverConfig.autoBootstrapProjectFromCwd) { + if (serverConfig.autoBootstrapProjectFromCwd && serverConfig.lazurioFolder === undefined) { const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; const defaultModelSelection = settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); @@ -972,11 +973,15 @@ export const make = (options?: StartupOptions) => yield* Effect.logDebug("startup phase: syncing clean projects"); yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects); + const managedWelcome = yield* runStartupPhase( + "lazurio.project", + ensureManagedFolderProject.pipe(Effect.provideService(Crypto.Crypto, crypto)), + ); const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); - if (serverConfig.autoBootstrapProjectFromCwd) { + if (serverConfig.autoBootstrapProjectFromCwd && serverConfig.lazurioFolder === undefined) { yield* forkParked( runStartupPhase( "welcome.autobootstrap", @@ -1054,7 +1059,11 @@ export const make = (options?: StartupOptions) => payload: { environment, ...welcomeBase, - bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", + ...managedWelcome, + bootstrapStatus: + serverConfig.autoBootstrapProjectFromCwd && serverConfig.lazurioFolder === undefined + ? "pending" + : "complete", }, }), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 502ae3430e27d..8887d51449147 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1733,3 +1733,37 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); }); + +it.effect( + "Lazurio recommendations preserve personal choices and atomically record managed defaults", + () => + Effect.gen(function* () { + const settings = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const first = { + instanceId: ProviderInstanceId.make("codex"), + model: "model-one", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const second = { ...first, model: "model-two" }; + const personal = { ...first, options: [{ id: "reasoningEffort", value: "low" }] }; + yield* settings.reconcileRecommendedModel(first); + assert.deepStrictEqual((yield* settings.getSettings).defaultModelSelection, first); + yield* settings.reconcileRecommendedModel(second); + assert.deepStrictEqual((yield* settings.getSettings).defaultModelSelection, second); + const disk = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))( + yield* fs.readFileString(config.settingsPath), + ); + assert.deepStrictEqual(disk.defaultModelSelection, second); + assert.deepStrictEqual(disk.lazurioManagedModelSelection, second); + yield* settings.updateSettings({ defaultModelSelection: personal }); + yield* settings.reconcileRecommendedModel(first); + const preserved = yield* settings.getSettings; + assert.deepStrictEqual(preserved.defaultModelSelection, personal); + assert.deepStrictEqual(preserved.lazurioManagedModelSelection, second); + yield* settings.updateSettings({ defaultModelSelection: null }); + yield* settings.reconcileRecommendedModel(first); + assert.deepStrictEqual((yield* settings.getSettings).defaultModelSelection, first); + }).pipe(Effect.provide(makeServerSettingsLayer().pipe(Layer.provideMerge(NodeServices.layer)))), +); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6949a66d981be..eba486efa3de5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -53,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, + reconcileRecommendedModel, deriveLegacyProjectOverrides, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; @@ -219,6 +220,11 @@ export class ServerSettingsService extends Context.Service< patch: ServerSettingsPatch, ) => Effect.Effect; + /** Hosting recommendation: the same native writer preserves a custom default. */ + readonly reconcileRecommendedModel: ( + selection: NonNullable, + ) => Effect.Effect; + /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; @@ -261,6 +267,13 @@ const makeTest = (overrides: DeepPartial = {}) => Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), Effect.map(resolveTextGenerationProvider), ), + reconcileRecommendedModel: (selection) => + Ref.get(currentSettingsRef).pipe( + Effect.map((current) => reconcileRecommendedModel(current, selection)), + Effect.flatMap(normalizeServerSettings), + Effect.tap((next) => Ref.set(currentSettingsRef, next)), + Effect.map(resolveTextGenerationProvider), + ), streamChanges: Stream.empty, subscribeChanges: Effect.succeed(Stream.empty), } satisfies ServerSettingsService["Service"]; @@ -1019,11 +1032,15 @@ const make = Effect.gen(function* () { const updateSettings = ( patch: ServerSettingsPatch, + recommendation?: NonNullable, ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const updated = applyServerSettingsPatch(current, patch); + const updated = + recommendation === undefined + ? applyServerSettingsPatch(current, patch) + : reconcileRecommendedModel(current, recommendation); const persisted = yield* persistProviderEnvironmentSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); const materialized = yield* Effect.uninterruptibleMask(() => @@ -1128,6 +1145,7 @@ const make = Effect.gen(function* () { Effect.map(resolveTextGenerationProvider), ), updateSettings, + reconcileRecommendedModel: (selection) => updateSettings({}, selection), get streamChanges() { return materializeChanges(Stream.fromPubSub(changesPubSub)); }, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 325233bf59d98..186e7d865c707 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2123,6 +2123,7 @@ it.layer( start: Effect.void, ready: Effect.void, getSettings: Effect.fail(settingsError), + reconcileRecommendedModel: () => Effect.die("unexpected recommendation in this fixture"), updateSettings: () => Effect.fail(settingsError), streamChanges: Stream.empty, subscribeChanges: Effect.succeed(Stream.empty), diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts index 0a28f9c9b2cee..386cc1db29def 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -1,3 +1,5 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerConfig } from "../config.ts"; import { assert, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -74,7 +76,14 @@ it.effect("routes repository initialization through an explicit VCS driver kind" yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); assert.deepStrictEqual(calls, ["git:/repo"]); - }).pipe(Effect.provide(testLayer)); + }).pipe( + Effect.provide( + testLayer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-vcs-test-" })), + Layer.provide(NodeServices.layer), + ), + ), + ); }); it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { @@ -93,5 +102,12 @@ it.effect("defaults repository initialization to Git until callers choose a VCS yield* provisioning.initRepository({ cwd: "/repo" }); assert.deepStrictEqual(calls, ["default:/repo"]); - }).pipe(Effect.provide(testLayer)); + }).pipe( + Effect.provide( + testLayer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-vcs-test-" })), + Layer.provide(NodeServices.layer), + ), + ), + ); }); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index e58099b615dbd..1c6ef72b84b3a 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -1,3 +1,5 @@ +import { ServerConfig } from "../config.ts"; +import * as FileSystem from "effect/FileSystem"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -38,10 +40,22 @@ function resolveRequestedKind( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + const { lazurioFolder } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; const initRepository: VcsProvisioningService["Service"]["initRepository"] = Effect.fn( "VcsProvisioningService.initRepository", )(function* (input) { + if (lazurioFolder !== undefined) { + const cwd = yield* fs.realPath(input.cwd).pipe(Effect.orElseSucceed(() => input.cwd)); + if (cwd === lazurioFolder) + return yield* new VcsUnsupportedOperationError({ + operation: "VcsProvisioningService.initRepository", + kind: input.kind ?? "git", + detail: + "The Lazurio Folder contains separate repositories and must not become a repository itself.", + }); + } const kind = yield* resolveRequestedKind(input.kind); const driver = yield* registry.get(kind); return yield* driver.initRepository(input); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bcc19be00da5f..4c46bcb3ab1c6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2091,6 +2091,10 @@ const makeWsRpcLayer = ( environment, auth, cwd: config.cwd, + ...(config.lazurioFolder === undefined ? {} : { lazurioFolder: config.lazurioFolder }), + ...(config.lazurioToolsUrl === undefined + ? {} + : { lazurioToolsUrl: config.lazurioToolsUrl }), keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 95f2df5928ae9..384803d40472d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -977,11 +977,17 @@ function OpenCommandPaletteDialog(props: { ); const pickerProjects = useMemo( () => - projectPickerEntries.map(({ group, targetProject }) => ({ - ...targetProject, - displayName: group.displayName, - })), - [projectPickerEntries], + projectPickerEntries + .filter(({ targetProject }) => { + const folder = environments.find((e) => e.environmentId === targetProject.environmentId) + ?.serverConfig?.lazurioFolder; + return folder === undefined || targetProject.workspaceRoot === folder; + }) + .map(({ group, targetProject }) => ({ + ...targetProject, + displayName: group.displayName, + })), + [projectPickerEntries, environments], ); const projectGroupByTargetKey = useMemo( () => @@ -996,7 +1002,11 @@ function OpenCommandPaletteDialog(props: { const addProjectEnvironmentOptions = useMemo(() => { const options = environments - .filter((environment) => canCreateProjectInEnvironment(environment.connection.phase)) + .filter( + (environment) => + environment.serverConfig?.lazurioFolder === undefined && + canCreateProjectInEnvironment(environment.connection.phase), + ) .map((environment): AddProjectEnvironmentOption => { const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; return { @@ -2056,35 +2066,36 @@ function OpenCommandPaletteDialog(props: { }); } - actionItems.push({ - kind: "action", - value: "action:add-project", - searchTerms: [ - "add project", - "folder", - "directory", - "browse", - "clone", - "remote", - "repository", - "repo", - "git", - "github", - "gitlab", - "forgejo", - "bitbucket", - "azure", - "devops", - "url", - "environment", - ], - title: "Add project", - icon: , - keepOpen: true, - run: async () => { - openAddProjectFlow(); - }, - }); + if (addProjectEnvironmentOptions.length > 0) + actionItems.push({ + kind: "action", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "forgejo", + "bitbucket", + "azure", + "devops", + "url", + "environment", + ], + title: "Add project", + icon: , + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }); if (wslAddProjectEnvironmentOption) { actionItems.push({ diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c26f1e69b1d7..92aefd463e665 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1735,7 +1735,7 @@ export default function GitActionsControl({ ); - if (!gitCwd) return null; + if (!gitCwd || serverConfig?.lazurioFolder === gitCwd) return null; return ( <> diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 29849561efe24..175edf6e55b0f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4716,7 +4716,11 @@ export default function Sidebar() { } - onNewProject={openAddProjectCommandPalette} + onNewProject={ + [...serverConfigs.values()].some((config) => config.lazurioFolder === undefined) + ? openAddProjectCommandPalette + : undefined + } onNewThread={handleNewThreadClick} newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index ac995a47a6703..884e32037ae16 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -225,98 +225,104 @@ export function DraftHeroHeadline({ return true; }; - const projectSelector = shouldShowProjectMenu ? ( - - - } - data-draft-project-trigger="" - className="pointer-events-auto max-w-64 align-baseline" - /> - } - > - - {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")} - - - {activeProjectDisplayName && !isScratchDraft ? ( - {activeProjectDisplayName} - ) : null} - - - { - if (value === NO_PROJECT_VALUE) { - void startScratch(); - return; + const managedFolder = environments.find( + (environment) => environment.environmentId === activeProjectRef?.environmentId, + )?.serverConfig?.lazurioFolder; + const projectSelector = + managedFolder !== undefined ? ( + Lazurio + ) : shouldShowProjectMenu ? ( + + + } + data-draft-project-trigger="" + className="pointer-events-auto max-w-64 align-baseline" + /> } - const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { - return; - } - selectProject(entry.targetProject, entry.group.projectKey); - }} - > - {scratchWorkspaceRoot === null ? null : ( - - - {/* Boxed like ProjectFavicon so the label lines up with project rows. */} - - No project - - - )} - {menuEntries.map(({ group }) => { - return ( - + > + + {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")} + + + {activeProjectDisplayName && !isScratchDraft ? ( + {activeProjectDisplayName} + ) : null} + + + { + if (value === NO_PROJECT_VALUE) { + void startScratch(); + return; + } + const entry = projectEntryByKey.get(value as string); + if (!entry || value === activeProjectKey) { + return; + } + selectProject(entry.targetProject, entry.group.projectKey); + }} + > + {scratchWorkspaceRoot === null ? null : ( + - - - }> - {group.displayName} - - {group.displayName} - - {showProjectEnvironments ? ( - - ) : null} + {/* Boxed like ProjectFavicon so the label lines up with project rows. */} + + No project - ); - })} - - {projectPickerEntries.length > 0 ? : null} - - - Add project - - - - ) : ( - - ); + )} + {menuEntries.map(({ group }) => { + return ( + + + + + }> + {group.displayName} + + {group.displayName} + + {showProjectEnvironments ? ( + + ) : null} + + + ); + })} + + {projectPickerEntries.length > 0 ? : null} + + + Add project + + + + ) : ( + + ); // The composer hero is a sentence, so the heading's accessible name must be // a complete sentence too. The project picker is a control rendered inline diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index 0fda2ee2cecc7..1197e23fd64a7 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -1,3 +1,4 @@ +import { managedFirstRunDecision } from "../../lazurio/managedFolder"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; @@ -125,31 +126,43 @@ export function FirstRunGate({ threads, }); - const { decision: nextDecision, persistCompletion } = hostedStatic - ? resolveHostedFirstRunDecision({ - localEnvironmentDisabled: isLocalEnvironmentDisabled(), - hydrated, - completed: onboardingCompletedAt !== null, - catalogReady: environmentCatalogReady, - environmentCount: environments.length, - }) - : resolveFirstRunDecision({ - enabled, - hydrated, - completed: onboardingCompletedAt !== null, - bootstrapped, - authoritative: primaryShellLive, - workspaceAuthoritative: workspaceEvidenceLive, - workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ - welcomeReceived: serverWelcome !== null, - bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, - }), - catalogReady: environmentCatalogReady, - serverConfigAvailable: serverConfig !== null, - workspaceFresh, - projectCount: projects.length, - threadCount: threads.length, - }); + const { decision: nextDecision, persistCompletion } = + serverConfig?.lazurioFolder !== undefined + ? managedFirstRunDecision({ + hydrated, + live: primaryShellLive, + completed: onboardingCompletedAt !== null, + projectReady: projects.some( + (p) => + p.environmentId === primaryEnvironmentId && + p.workspaceRoot === serverConfig.lazurioFolder, + ), + }) + : hostedStatic + ? resolveHostedFirstRunDecision({ + localEnvironmentDisabled: isLocalEnvironmentDisabled(), + hydrated, + completed: onboardingCompletedAt !== null, + catalogReady: environmentCatalogReady, + environmentCount: environments.length, + }) + : resolveFirstRunDecision({ + enabled, + hydrated, + completed: onboardingCompletedAt !== null, + bootstrapped, + authoritative: primaryShellLive, + workspaceAuthoritative: workspaceEvidenceLive, + workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: serverWelcome !== null, + bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, + }), + catalogReady: environmentCatalogReady, + serverConfigAvailable: serverConfig !== null, + workspaceFresh, + projectCount: projects.length, + threadCount: threads.length, + }); useEffect(() => { if (decision === "wizard" || !hydrated) return; diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e2bd4bfb74e58..cef46d59e24ae 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -31,7 +31,7 @@ export interface SidebarThreadHeaderProps { hasProjects: boolean; /** The project scope combobox, rendered as the first icon of the group. */ projectScope: ReactNode; - onNewProject: () => void; + onNewProject?: (() => void) | undefined; /** Receives the click so Shift+click can skip the project picker. */ onNewThread: (event: ReactMouseEvent) => void; newThreadDisabled: boolean; @@ -127,9 +127,11 @@ export function SidebarThreadHeader({ {hasProjects ? ( <> {projectScope} - - - + {onNewProject ? ( + + + + ) : null} ) : null} { defaultModelSelection: null, defaultRuntimeMode: "full-access" as RuntimeMode, }; + let managedFolder: string | undefined; let storedDraft: { readonly draftId: string; readonly environmentId: string; @@ -37,6 +38,12 @@ const testState = vi.hoisted(() => { }; return { + get managedFolder() { + return managedFolder; + }, + setManagedFolder: (value: string) => { + managedFolder = value; + }, completeProjectFileRead: (value: null) => completeProjectFileRead(value), draftStore, get projectFileRead() { @@ -52,6 +59,7 @@ const testState = vi.hoisted(() => { startFromOrigin: false, }, ) { + managedFolder = undefined; storedDraft = nextStoredDraft; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, @@ -85,7 +93,10 @@ vi.mock("@effect/atom-react", () => ({ }, }, ], - ["environment-ssh", { settings: testState.targetSettings }], + [ + "environment-ssh", + { settings: testState.targetSettings, lazurioFolder: testState.managedFolder }, + ], ]), })); vi.mock("@t3tools/client-runtime/environment", () => ({ @@ -287,3 +298,51 @@ describe.each([ }, ); }); + +describe("managed Lazurio entry", () => { + it("starts at the Folder even when a caller and saved settings request a worktree", async () => { + testState.reset(null, { envMode: "worktree", startFromOrigin: true }); + testState.setManagedFolder("/remote/project"); + const result = await useNewThreadHandler()( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { branch: "feature", worktreePath: "/tmp/worktree", envMode: "worktree" }, + ); + expect(result).not.toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + expect.anything(), + expect.anything(), + expect.objectContaining({ + worktreePath: null, + branch: null, + envMode: "local", + startFromOrigin: false, + }), + ); + }); + it("routes a New chat request from historical context to the Folder project", async () => { + testState.reset(null); + testState.setManagedFolder("/remote/project"); + await useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "historical-project", + } as never); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + { environmentId: "environment-ssh", projectId: "project-remote" }, + expect.anything(), + expect.anything(), + ); + }); + it("waits until the managed project is available", async () => { + testState.reset(null); + testState.setManagedFolder("/remote/Lazurio"); + await expect( + useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never), + ).resolves.toBeNull(); + expect(testState.router.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 327f180800088..ee946518bef16 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -78,6 +78,21 @@ export function useNewThreadHandler() { // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); + const managedFolder = environmentServerConfigs.get(projectRef.environmentId)?.lazurioFolder; + if (managedFolder !== undefined) { + const managedProject = projects.find( + (p) => p.environmentId === projectRef.environmentId && p.workspaceRoot === managedFolder, + ); + if (!managedProject) return Promise.resolve(null); + projectRef = scopeProjectRef(managedProject.environmentId, managedProject.id); + options = { + ...options, + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }; + } const targetServerSettings = environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { @@ -149,6 +164,7 @@ export function useNewThreadHandler() { // skipped entirely when a higher-priority source decides, and its // query atom caches per project after the first call. const resolveDefaultEnvMode = async (): Promise => { + if (managedFolder !== undefined) return "local"; const consultProjectFile = project !== undefined && projectSettings.settings.defaultThreadEnvMode === null; const projectFile = consultProjectFile diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 0d25f33858554..2cf3942c6de7c 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -39,7 +39,8 @@ export function useScratchProject() { const scratchWorkspaceRootFor = useCallback( (environmentId: EnvironmentId | null): string | null => { const environment = environments.find((entry) => entry.environmentId === environmentId); - return environment?.connection.phase === "connected" + return environment?.connection.phase === "connected" && + environment.serverConfig?.lazurioFolder === undefined ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) : null; }, diff --git a/apps/web/src/lazurio/ManagedFolderWelcome.tsx b/apps/web/src/lazurio/ManagedFolderWelcome.tsx new file mode 100644 index 0000000000000..a9a25885972f7 --- /dev/null +++ b/apps/web/src/lazurio/ManagedFolderWelcome.tsx @@ -0,0 +1,78 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import { useState } from "react"; +import { Button } from "../components/ui/button"; +import { useCompleteOnboarding } from "../onboarding/firstRun"; +import { useProjects } from "../state/entities"; +import { primaryServerConfigAtom } from "../state/server"; + +export function ManagedFolderWelcome({ + onDone, +}: { + onDone: (project: ScopedProjectRef) => Promise; +}) { + const config = useAtomValue(primaryServerConfigAtom); + const projects = useProjects(); + const complete = useCompleteOnboarding(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const project = projects.find( + (p) => + p.environmentId === config?.environment.environmentId && + p.workspaceRoot === config?.lazurioFolder, + ); + return ( +
+
+
+

Lazurio Environment

+

Your workspace is ready

+
+

+ Every new chat starts in your Lazurio Folder. It gives your agent the context for this + Environment and the Organizations you can work with. +

+
+ Lazurio +

{config?.lazurioFolder}

+

+ Your projects and repositories live inside this Folder. There is nothing to import. +

+
+ {config?.lazurioToolsUrl ? ( +

+ Connect your agent account in{" "} + + Environment settings + + , then return here. An account already connected there is ready to use. +

+ ) : ( +

Use the agent account connected to this Environment.

+ )} + {error ? ( +

+ {error} +

+ ) : null} + +
+
+ ); +} diff --git a/apps/web/src/lazurio/managedFolder.test.ts b/apps/web/src/lazurio/managedFolder.test.ts new file mode 100644 index 0000000000000..9b9e4d3bf1b61 --- /dev/null +++ b/apps/web/src/lazurio/managedFolder.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; +import { managedFirstRunDecision } from "./managedFolder"; + +describe("managed first run", () => { + it("waits for live server evidence of the native Folder project, even with saved onboarding", () => { + for (const completed of [true, false]) { + expect( + managedFirstRunDecision({ hydrated: true, live: false, projectReady: true, completed }) + .decision, + ).toBe("pending"); + expect( + managedFirstRunDecision({ hydrated: true, live: true, projectReady: false, completed }) + .decision, + ).toBe("pending"); + } + }); + it("offers the Lazurio welcome once and then opens the app, never imports or silently completes", () => { + expect( + managedFirstRunDecision({ hydrated: true, live: true, projectReady: true, completed: false }), + ).toEqual({ decision: "wizard", persistCompletion: false }); + expect( + managedFirstRunDecision({ hydrated: true, live: true, projectReady: true, completed: true }), + ).toEqual({ decision: "app", persistCompletion: false }); + }); +}); diff --git a/apps/web/src/lazurio/managedFolder.ts b/apps/web/src/lazurio/managedFolder.ts new file mode 100644 index 0000000000000..800c6c7113fdc --- /dev/null +++ b/apps/web/src/lazurio/managedFolder.ts @@ -0,0 +1,18 @@ +import type { FirstRunDecision } from "../onboarding/firstRun.logic"; + +export function managedFirstRunDecision(input: { + hydrated: boolean; + live: boolean; + projectReady: boolean; + completed: boolean; +}): { decision: FirstRunDecision; persistCompletion: boolean } { + return { + decision: + !input.hydrated || !input.live || !input.projectReady + ? "pending" + : input.completed + ? "app" + : "wizard", + persistCompletion: false, + }; +} diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx index b69911c0abcb9..f47735b50085b 100644 --- a/apps/web/src/routes/welcome.tsx +++ b/apps/web/src/routes/welcome.tsx @@ -1,3 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; +import { primaryServerConfigAtom } from "../state/server"; +import { ManagedFolderWelcome } from "../lazurio/ManagedFolderWelcome"; import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { EnvironmentId } from "@t3tools/contracts"; @@ -33,11 +36,20 @@ function WelcomeRouteView() { const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" }); const [dismissed, setDismissed] = useState(false); const openNewThread = useNewThreadHandler(); + const config = useAtomValue(primaryServerConfigAtom); // An authenticated gate means a primary server is serving this app — // desktop, `npx t3`, or a dev server — and that server is "this machine" // no matter what hostname the browser used. Only hosted-static has no // local server to offer. const localAvailable = authGateState.status === "authenticated"; + if (config?.lazurioFolder !== undefined) + return ( + { + await openNewThread(projectRef, { replace: true }); + }} + /> + ); return ( <> diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index 6ba73639231c0..014093e246a88 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -899,3 +899,23 @@ ostatních) a ručně spouštěný `lazurio-release.yml`. Do září 2026 vycházely jen OCI image pod tagy `lazurio-vX.Y.Z-rN`, bez CLI archivů. Tyto tagy a vydání zůstávají jako neměnný archiv. Launcher je ignoruje, protože tagy nezačínají na `v`. Nové verze je nepoužívají. + +## Managed Lazurio Folder contract + +`T3CODE_LAZURIO_FOLDER` selects the canonical existing Folder as the entry context. +Startup adopts or creates its native project before readiness. Other projects and +conversations remain history; new sessions start at the Folder root. This is not +a filesystem sandbox. Without the variable, upstream behavior is unchanged. +`T3CODE_LAZURIO_TOOLS_URL` points the managed welcome to the Environment's own +agent sign-in page. + +Machines checks `t3 lazurio capabilities` for contract version 1 and invokes +Platform's `lazurio chat defaults --apply` after Folder provisioning. Platform owns +the recommendation; T3's settings service atomically preserves explicit user +choices. `t3 lazurio model --base-dir ` reads metadata without mutation; its +JSON argument reconciles only through the running service. Never use an offline +settings or database edit as a deployment fallback. Release both owners together. + +The fork CI lists every changed upstream client/contract seam. During an upstream +refresh, retain the managed startup, native command guard, and managed welcome +consumer tests; the model selection itself is deliberately outside this fork. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c8983988ead11..9f732e4844da2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -576,6 +576,9 @@ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boole } export const ServerConfig = Schema.Struct({ + /** The one initial project of a managed Lazurio Environment. Older clients still follow server enforcement. */ + lazurioFolder: Schema.optionalKey(TrimmedNonEmptyString), + lazurioToolsUrl: Schema.optionalKey(TrimmedNonEmptyString), environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, cwd: TrimmedNonEmptyString, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 65ac8c04e20cd..6a429a65734b1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1148,6 +1148,10 @@ export const ServerSettings = Schema.Struct({ projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** Last default written by the Environment's recommendation owner. Not a user preference. */ + lazurioManagedModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), @@ -1510,6 +1514,7 @@ export const ServerSettingsPatch = Schema.Struct({ projectAutoPullOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), + /** Compare-and-set recommendation; preserves a differing personal choice. */ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), /** diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index d847cb2b0795f..823c9f549a9b1 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -11,6 +11,7 @@ import { ServerSettings, type ServerSettingsPatch, } from "@t3tools/contracts"; +import * as Equal from "effect/Equal"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; @@ -262,6 +263,16 @@ function translateLegacyProjectOverridePatch( } as ServerSettingsPatch; } +export function reconcileRecommendedModel( + current: ServerSettings, + selection: NonNullable, +): ServerSettings { + return current.defaultModelSelection === null || + Equal.equals(current.defaultModelSelection, current.lazurioManagedModelSelection) + ? { ...current, defaultModelSelection: selection, lazurioManagedModelSelection: selection } + : current; +} + export function applyServerSettingsPatch( current: ServerSettings, rawPatch: ServerSettingsPatch, diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index fa4014c561261..8eabf6f19b697 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -643,7 +643,10 @@ NodeTest.test("a thread's tabs live in its holder file behind an overlay route", service, /connectEnvironmentBrowser|Target\.closeTarget|agent-browser.*close/, ); - NodeAssert.match(overlay, /Layer\.mergeAll\(EnvironmentBrowser\.layer, ThreadTabsRoute\.layer\)/); + NodeAssert.match( + overlay, + /Layer\.mergeAll\(\s*EnvironmentBrowser\.layer,\s*ThreadTabsRoute\.layer,\s*ModelRecommendationRoute\.layer,?\s*\)/, + ); }); // Plan DEV-6646: T3's browser tools drive the Environment browser. The host is server overlay