diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 6e55185e9cb84..db3dcf27ca7ce 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -138,26 +138,20 @@ jobs: apps/web/src/rightPanelStore.test.ts apps/web/src/routes/_chat.pull-requests.tsx # The managed Lazurio Folder (LazurioPlatform F51): with T3CODE_LAZURIO_FOLDER, Chat - # starts in the Environment's Folder. The server adopts or creates its project at - # startup, keeps commands and agent turns there, never makes the Folder root a - # repository and tells clients the Folder; `t3 lazurio` reports its capabilities and - # reconciles the Platform's recommended model where the person chose none. The web - # starts new threads there, hides other projects' entry points and shows the managed - # welcome. Five server test fixtures gain the settings service's new method. + # starts in the Environment's Folder. Its code lives in apps/*/src/lazurio/; these are + # the hooks. The server adopts or creates the Folder's project at startup, the engine, + # the provider reactor and the VCS service keep commands, agent turns and repositories + # there, the settings writer reconciles the Platform's recommended model where the + # person chose none, `t3 lazurio` reports both, and clients learn the Folder. The web + # starts new threads there, leaves out what leads elsewhere and shows the managed + # welcome. The engine's, the reactor's and the VCS service's tests cover the hooks. apps/server/src/bin.ts - apps/server/src/cli/lazurio.ts - apps/server/src/device/DeviceService.test.ts - apps/server/src/http.ts apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts apps/server/src/orchestration/Layers/OrchestrationEngine.ts + apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts apps/server/src/orchestration/Layers/ProviderCommandReactor.ts - apps/server/src/orchestration/ThreadSettlementReactor.test.ts - apps/server/src/provider/Layers/ProviderRegistry.test.ts - apps/server/src/provider/makeManagedServerProvider.test.ts apps/server/src/serverRuntimeStartup.ts - apps/server/src/serverSettings.test.ts apps/server/src/serverSettings.ts - apps/server/src/terminal/Manager.test.ts apps/server/src/vcs/VcsProvisioningService.test.ts apps/server/src/vcs/VcsProvisioningService.ts apps/server/src/ws.ts @@ -173,7 +167,6 @@ jobs: apps/web/src/routes/welcome.tsx packages/contracts/src/server.ts packages/contracts/src/settings.ts - packages/shared/src/serverSettings.ts ) unexpected="$(git diff --name-only "$UPSTREAM_SHA..HEAD" | grep -E '^(apps|packages|scripts)/' | @@ -203,6 +196,11 @@ jobs: - name: Check formatting run: pnpm exec vp fmt --check + # Upstream's lint script with upstream's rules, including the per-file ceilings on + # ManagedRuntime.make in tests that its own `vp check` holds. + - name: Lint + run: pnpm run lint + - name: Typecheck server and web run: | pnpm exec vp run --filter t3 typecheck diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 46d199435d04e..881764e20ae09 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -23,7 +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 { lazurioCommand } from "./lazurio/cli.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 81a6c0fcd25a5..912077381a9aa 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -22,6 +22,7 @@ import { Argument, Flag } from "effect/unstable/cli"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; +import { resolveManagedFolderConfig } from "../lazurio/managedFolderConfig.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe( @@ -92,14 +93,6 @@ 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)), @@ -377,50 +370,7 @@ 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 managedFolder = yield* resolveManagedFolderConfig; const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: Option.isSome(explicitBaseDir), }); @@ -535,11 +485,7 @@ export const resolveServerConfig = ( otelEnvironment: otel, mode, port, - cwd: lazurioFolder ?? cwd, - ...(lazurioFolder === undefined ? {} : { lazurioFolder }), - ...(env.lazurioToolsUrl === undefined - ? {} - : { lazurioToolsUrl: env.lazurioToolsUrl.toString() }), + cwd, baseDir, ...derivedPaths, serverTracePath, @@ -560,6 +506,8 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + // Lazurio (LazurioPlatform F51): T3CODE_LAZURIO_FOLDER makes the Folder the cwd, unbootstrapped. + ...managedFolder, }; return config; diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 27cf0ff1dbc32..7a81d757c0ac0 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -144,7 +144,6 @@ 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 20ded5f9d3574..30b368e62167f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,6 +1,5 @@ import * as Mime from "effect/unstable/http/Mime"; import { - type AuthEnvironmentScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, @@ -273,7 +272,9 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -export const authenticateRawRouteWithScope = (scope: AuthEnvironmentScope) => +const authenticateRawRouteWithScope = ( + scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, +) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/lazurio/agentBrowserSession.ts b/apps/server/src/lazurio/agentBrowserSession.ts index 36219f447ec6c..3bfe08a80ed01 100644 --- a/apps/server/src/lazurio/agentBrowserSession.ts +++ b/apps/server/src/lazurio/agentBrowserSession.ts @@ -4,11 +4,8 @@ * On a Lazurio Environment the agents of every thread work in the Environment browser, one * Chromium on the Environment, through the agent-browser CLI, each thread in a window of its * own. The CLI takes its session from AGENT_BROWSER_SESSION, so every provider process T3 starts - * for a thread carries the thread's name. The web client asks the Environment for the view of - * the same session, so `apps/web/src/lazurio/agentBrowserSession.ts` holds a copy of - * `agentBrowserSessionName`: sharing it through packages/shared would take an entry in that - * package's export map, which upstream rewrites every few days. The release contract test keeps - * the two copies equal. + * for a thread carries the thread's name. The same name is the thread's strip, whose holder file + * keeps its tabs (threadTabs.ts); the web client reaches them only through the server. */ import * as NodeCrypto from "node:crypto"; @@ -21,8 +18,7 @@ const SESSION_NAME_LIMIT = 64; /** Hex digits of the id's SHA-256 kept in a cut or rewritten name. */ const DIGEST_LENGTH = 32; -/** SHA-256 of the UTF-8 bytes, as hex (node:crypto; the web copy computes the same with - * @noble/hashes, and the release contract test compares the two). */ +/** SHA-256 of the UTF-8 bytes, as hex. */ const sha256Hex = (value: string): string => NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex"); diff --git a/apps/server/src/lazurio/cli.test.ts b/apps/server/src/lazurio/cli.test.ts new file mode 100644 index 0000000000000..2034ae0ee3e04 --- /dev/null +++ b/apps/server/src/lazurio/cli.test.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cli } from "../bin.ts"; +import * as ServerConfig from "../config.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; +import * as Settings from "../serverSettings.ts"; +import * as ModelRecommendationRoute from "./modelRecommendationRoute.ts"; + +// `t3 lazurio` is a contract: Machines checks `capabilities` and the Platform's +// `lazurio chat defaults --apply` reads `model`'s answer. These pin their output byte for byte. + +const run = (args: ReadonlyArray) => + Command.runWith(cli, { version: "0.0.0" })(args).pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), + ); +const printed = Effect.map(TestConsole.logLines, (lines) => + lines.filter((line): line is string => typeof line === "string"), +); +const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-lazurio-cli-")); +// The recommendation as the Platform sends it, and as T3 prints it back. +const selectionJson = + '{"instanceId":"codex","model":"gpt-6.1-sol","options":[{"id":"reasoningEffort","value":"high"}]}'; +// The Folder exists, as Machines' T3CODE_LAZURIO_FOLDER does; the Platform sends its realpath. +const folder = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-lazurio-cli-folder-")), +); +const request = `{"folder":"${folder}","selection":${selectionJson}}`; + +/** The model route on 127.0.0.1 over `baseDir`'s auth and settings, as the running service has it. */ +const withLiveService = (baseDir: string, body: Effect.Effect) => + Effect.gen(function* () { + const configLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + host: "127.0.0.1", + lazurioFolder: folder, + })), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + const serviceLayer = HttpRouter.serve(ModelRecommendationRoute.layer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(Settings.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provideMerge(EnvironmentAuth.runtimeLayer), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 0 }), + ), + Layer.provideMerge(configLayer), + Layer.provideMerge(NodeServices.layer), + ); + return yield* Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const address = (yield* HttpServer.HttpServer).address; + if (typeof address === "string" || !("port" in address)) { + return yield* Effect.die(`Expected a TCP address, got ${String(address)}`); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ config, port: address.port }), + }); + return yield* body; + }).pipe(Effect.provide(serviceLayer)), + ); + }); + +describe("t3 lazurio", () => { + it.effect("capabilities: contract version 1, without reading any state", () => + Effect.gen(function* () { + yield* run(["lazurio", "capabilities"]); + assert.deepStrictEqual(yield* printed, [ + '{"version":1,"managedFolder":true,"modelRecommendation":true}', + ]); + }).pipe(Effect.provide(TestConsole.layer)), + ); + + it.effect("model: reads the saved model without changing or creating anything", () => + Effect.gen(function* () { + const empty = makeBaseDir(); + yield* run(["lazurio", "model", "--base-dir", empty]); + assert.deepStrictEqual(NodeFS.readdirSync(empty), []); + + const saved = makeBaseDir(); + NodeFS.mkdirSync(NodePath.join(saved, "userdata")); + const settingsPath = NodePath.join(saved, "userdata", "settings.json"); + const settingsText = `{"defaultModelSelection":${selectionJson},"lazurioManagedModelSelection":${selectionJson}}`; + NodeFS.writeFileSync(settingsPath, settingsText); + yield* run(["lazurio", "model", "--base-dir", saved]); + assert.strictEqual(NodeFS.readFileSync(settingsPath, "utf8"), settingsText); + + assert.deepStrictEqual(yield* printed, [ + '{"version":1,"current":null,"lastManaged":null}', + `{"version":1,"current":${selectionJson},"lastManaged":${selectionJson}}`, + ]); + }).pipe(Effect.provide(TestConsole.layer)), + ); + + it.effect("model : only through the running service, never offline", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const error = yield* run(["lazurio", "model", "--base-dir", baseDir, request]).pipe( + Effect.flip, + ); + assert.strictEqual( + error.message, + "The T3 service must be running. No settings were changed.", + ); + assert.isFalse(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "settings.json"))); + assert.deepStrictEqual(yield* printed, []); + }).pipe(Effect.provide(TestConsole.layer)), + ); + + it.effect("model : prints the running service's answer as it is", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + // The service runs in this process here, so only what the command printed counts. + const output = yield* withLiveService( + baseDir, + Effect.gen(function* () { + const before = (yield* printed).length; + yield* run(["lazurio", "model", "--base-dir", baseDir, request]); + return (yield* printed).slice(before); + }), + ); + assert.deepStrictEqual(output, [ + `{"version":1,"current":${selectionJson},"lastManaged":${selectionJson}}`, + ]); + }).pipe(Effect.provide(TestConsole.layer)), + ); +}); diff --git a/apps/server/src/cli/lazurio.ts b/apps/server/src/lazurio/cli.ts similarity index 91% rename from apps/server/src/cli/lazurio.ts rename to apps/server/src/lazurio/cli.ts index 57cb66c3bf2b1..2f6cc315c0938 100644 --- a/apps/server/src/cli/lazurio.ts +++ b/apps/server/src/lazurio/cli.ts @@ -1,3 +1,8 @@ +/** + * Lazurio overlay (LazurioPlatform F51): `t3 lazurio`. Machines checks `t3 lazurio capabilities` + * (contract version 1) and the Platform's `lazurio chat defaults --apply` runs `t3 lazurio model`, + * so both commands' output is a contract: cli.test.ts pins it byte for byte. + */ import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Config from "effect/Config"; import { AuthOrchestrationOperateScope, ServerSettings } from "@t3tools/contracts"; @@ -13,11 +18,8 @@ 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"; +import { projectLocationFlags, resolveCliAuthConfig } from "../cli/config.ts"; +import { MODEL_RECOMMENDATION_ROUTE, ModelRecommendationRequest } from "./modelRecommendation.ts"; class RecommendationCommandError extends Schema.TaggedError()( "RecommendationCommandError", diff --git a/apps/server/src/lazurio/environmentBrowser.ts b/apps/server/src/lazurio/environmentBrowser.ts index 90e312ee5790d..fb007bf7047d4 100644 --- a/apps/server/src/lazurio/environmentBrowser.ts +++ b/apps/server/src/lazurio/environmentBrowser.ts @@ -84,6 +84,8 @@ const DECLARATION_CHECK_INTERVAL = Duration.seconds(60); /** Upstream's pause before a host registers again after the broker ended its stream. */ const HOST_RECONNECT_DELAY = Duration.seconds(1); const LINK_CHECK_TIMEOUT = Duration.seconds(10); +/** Other Environments' tabs the host remembers, so it never drives one in this browser. */ +const MAX_FOREIGN_TABS = 256; /** * `lazurio browser link --json` on an Environment that declares its browser. Its `link` is a new @@ -204,7 +206,8 @@ const responseError = (error: ServerBrowserOperationError) => ({ ...(error.detail === undefined ? {} : { detail: error.detail }), }); -interface ThreadTabs { +/** What the host keeps of a thread's work in this browser. */ +interface HostThread { /** Tabs the thread opened or adopted, reported to the broker as its live tabs. */ readonly tabs: Set; current: string | undefined; @@ -243,16 +246,21 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => Option.none()), ); - const threads = new Map(); + const threads = new Map(); let hostConnectionId: string | undefined; /** The origin of this Environment's own view, from its declaration. */ let ownViewOrigin: string | undefined; - /** Tabs of other Environments' browsers that preview_open answered with, by target id. */ + /** Tabs of other Environments' browsers that preview_open answered with, by target id, the + * latest last; only the latest MAX_FOREIGN_TABS are kept. */ const foreignTabs = new Map(); - /** - * Another Environment's view of one remote tab: a Lazurio browser view on another origin than - * this Environment's. Without a declared origin, every Lazurio browser view is another's. - */ + const rememberForeign = (view: LazurioBrowserView) => { + foreignTabs.delete(view.targetId); + foreignTabs.set(view.targetId, view); + for (const oldest of foreignTabs.keys()) { + if (foreignTabs.size <= MAX_FOREIGN_TABS) break; + foreignTabs.delete(oldest); + } + }; /** * The host of another Environment's browser that `url` is in, whatever its path, query, fragment * or port: `browser..lazurio.io` on another host than this Environment's view. This @@ -271,6 +279,10 @@ export const make = Effect.gen(function* () { ? address.hostname : undefined; }; + /** + * Another Environment's view of one remote tab: a Lazurio browser view on another origin than + * this Environment's. Without a declared origin, every Lazurio browser view is another's. + */ const foreignView = (url: string) => { const view = lazurioBrowserView(url); return view !== null && view.origin !== ownViewOrigin ? view : undefined; @@ -288,7 +300,7 @@ export const make = Effect.gen(function* () { */ const foreignOpen = (threadId: ThreadId, view: LazurioBrowserView, visible: boolean) => Effect.sync(() => { - foreignTabs.set(view.targetId, view); + rememberForeign(view); return { ...statusOf(view.targetId, { url: null, title: null, loading: false }, visible), view: view.view, @@ -384,18 +396,26 @@ export const make = Effect.gen(function* () { tab === undefined ? Effect.as(forget(targetId), undefined) : Effect.succeed(targetId), ); - const tabLocks = new Map(); + /** Each tab's lock while operations run or wait in it; the last one done removes it. */ + const tabLocks = new Map(); /** One operation at a time per tab, in the order they arrive. */ const oneAtATime = (targetId: string) => (effect: Effect.Effect) => Effect.suspend(() => { - let lock = tabLocks.get(targetId); - if (lock === undefined) { - lock = Semaphore.makeUnsafe(1); - tabLocks.set(targetId, lock); - } - return lock.withPermits(1)(effect); + const entry = tabLocks.get(targetId) ?? { lock: Semaphore.makeUnsafe(1), users: 0 }; + entry.users += 1; + tabLocks.set(targetId, entry); + return entry.lock + .withPermits(1)(effect) + .pipe( + Effect.ensuring( + Effect.sync(() => { + entry.users -= 1; + if (entry.users === 0) tabLocks.delete(targetId); + }), + ), + ); }); /** The time left to answer in. None means the request was answered already, as timed out. */ const timeLeft = (answerBy: number) => diff --git a/apps/server/src/lazurio/holders.ts b/apps/server/src/lazurio/holders.ts index 2339be80c80da..2f0c05d2cf39d 100644 --- a/apps/server/src/lazurio/holders.ts +++ b/apps/server/src/lazurio/holders.ts @@ -189,6 +189,9 @@ export function keptTabs( .map((tab) => tab.id); } +/** A strip's label as a holder file keeps it: trimmed, at most 200 characters. */ +export const holderLabel = (label: string) => label.trim().slice(0, maxLabelLength); + /** The hold one holder file records, or null when the file is not one: its * `strip` must be its file name, and only target ids count. */ export function parseHolder( @@ -207,8 +210,7 @@ export function parseHolder( if (typeof value !== "object" || value === null) return null; const record = value as Record; if (record.strip !== name || !Array.isArray(record.tabs)) return null; - const label = - typeof record.label === "string" ? record.label.trim().slice(0, maxLabelLength) : ""; + const label = typeof record.label === "string" ? holderLabel(record.label) : ""; const tabs = [ ...new Set( record.tabs @@ -276,7 +278,7 @@ export function processAlive(pid: number): boolean { } /** A `.pid` file's process id when it is a well-formed one of at least 2, else null. */ -export function daemonProcessId(text: string): number | null { +function daemonProcessId(text: string): number | null { const digits = text.trim(); const pid = /^[0-9]{1,10}$/.test(digits) ? Number(digits) : Number.NaN; return Number.isSafeInteger(pid) && pid >= 2 ? pid : null; @@ -367,7 +369,6 @@ export type DaemonAnswer = /** What the daemon stop and the bindings need of the host: overridable in tests. */ export type DaemonSeams = Readonly<{ alive?: (pid: number) => boolean; - read?: (path: string) => Promise; /** Sends one command line to a socket and answers the reply line. */ ask?: (socketPath: string, line: string) => Promise; /** Whether a file is still there, polled while the daemon exits. */ @@ -393,7 +394,7 @@ const absentSocketCodes = new Set(["ENOENT", "ECONNREFUSED", "ENOTSOCK"]); /** One command line to a Unix socket and its reply line. Nothing listening (no socket, a stale * one) is `absent`; no reply within 5 s, or a socket that cannot be reached for any other reason, * is `no-answer`. The Platform's `askDaemonSocket`. */ -export function askDaemonSocket(socketPath: string, line: string): Promise { +function askDaemonSocket(socketPath: string, line: string): Promise { return new Promise((resolve) => { let settled = false; let connected = false; diff --git a/apps/server/src/lazurio/managedFolder.test.ts b/apps/server/src/lazurio/managedFolder.test.ts new file mode 100644 index 0000000000000..df50198390584 --- /dev/null +++ b/apps/server/src/lazurio/managedFolder.test.ts @@ -0,0 +1,89 @@ +// @effect-diagnostics nodeBuiltinImport:off - the guard resolves real paths and symlinks. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import { makeFolderGuard } from "./managedFolder.ts"; + +const parent = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-lazurio-guard-")), +); +const folder = NodePath.join(parent, "Lazurio"); +const repository = NodePath.join(folder, "organizations", "example"); +const link = NodePath.join(parent, "link-to-Lazurio"); +NodeFS.mkdirSync(repository, { recursive: true }); +NodeFS.symlinkSync(folder, link); + +const withConfig = (lazurioFolder: string | undefined) => + Effect.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (config) => ({ + ...config, + ...(lazurioFolder === undefined ? {} : { lazurioFolder }), + })), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-lazurio-guard-" })), + Layer.provideMerge(NodeServices.layer), + ), + ); + +const turnOutsideFolder = + "Agents in this Environment work in its Lazurio Folder. Start a new chat in the Lazurio project."; +const repositoryAtFolder = + "The Lazurio Folder holds separate repositories, so it cannot become a repository itself."; + +it.effect("refuses nothing without T3CODE_LAZURIO_FOLDER", () => + Effect.gen(function* () { + const guard = yield* makeFolderGuard; + for (const cwd of [undefined, folder, repository, "/nowhere"]) { + assert.isNull(yield* guard.turnRefusal(cwd)); + } + assert.isNull(yield* guard.repositoryRefusal(folder)); + }).pipe(withConfig(undefined)), +); + +it.effect("runs an agent turn only in the Folder itself, a symlink to it included", () => + Effect.gen(function* () { + const guard = yield* makeFolderGuard; + assert.isNull(yield* guard.turnRefusal(folder)); + assert.isNull(yield* guard.turnRefusal(link)); + for (const cwd of [undefined, repository, parent, NodePath.join(folder, "missing")]) { + assert.strictEqual(yield* guard.turnRefusal(cwd), turnOutsideFolder, String(cwd)); + } + }).pipe(withConfig(folder)), +); + +it.effect("knows the Folder by its canonical path, never without a managed Folder", () => + Effect.gen(function* () { + const guard = yield* makeFolderGuard; + for (const path of [folder, link, `${folder}/`]) { + assert.isTrue(yield* guard.isFolder(path), path); + } + for (const path of [parent, repository, NodePath.join(parent, "missing"), "Lazurio"]) { + assert.isFalse(yield* guard.isFolder(path), path); + } + assert.isFalse( + yield* makeFolderGuard.pipe( + Effect.flatMap((other) => other.isFolder(folder)), + withConfig(undefined), + ), + ); + }).pipe(withConfig(folder)), +); + +it.effect("never makes the Folder a repository, while repositories inside it are fine", () => + Effect.gen(function* () { + const guard = yield* makeFolderGuard; + assert.strictEqual(yield* guard.repositoryRefusal(folder), repositoryAtFolder); + assert.strictEqual(yield* guard.repositoryRefusal(link), repositoryAtFolder); + assert.isNull(yield* guard.repositoryRefusal(repository)); + assert.isNull(yield* guard.repositoryRefusal(NodePath.join(folder, "new-repository"))); + }).pipe(withConfig(folder)), +); diff --git a/apps/server/src/lazurio/managedFolder.ts b/apps/server/src/lazurio/managedFolder.ts index 1857fee3319ba..4162826521fc0 100644 --- a/apps/server/src/lazurio/managedFolder.ts +++ b/apps/server/src/lazurio/managedFolder.ts @@ -1,11 +1,26 @@ +/** + * Lazurio overlay, the managed Lazurio Folder (LazurioPlatform F51). With `T3CODE_LAZURIO_FOLDER` + * (managedFolderConfig.ts) a hosted Environment's chats start in its Lazurio Folder: the engine, + * the provider reactor and the VCS service keep commands, agent turns and repositories there + * through the guards below, and the startup (managedFolderStartup.ts) gives the Folder its + * project. 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. Without the variable nothing here + * refuses anything. + */ import type { OrchestrationCommand, OrchestrationReadModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; -// 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. +import { ServerConfig } from "../config.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; + +type CommandState = Pick; + +/** Why the engine refuses a command in a managed Environment, or null. */ export function managedFolderRefusal( folder: string | undefined, command: OrchestrationCommand, - state: Pick, + state: CommandState, ): string | null { if (folder === undefined) return null; const message = @@ -52,3 +67,65 @@ export function managedFolderRefusal( return null; } } + +/** The engine's guard: fails a command `managedFolderRefusal` refuses, before it is decided. */ +export const makeCommandGuard = Effect.map( + ServerConfig, + ({ lazurioFolder }) => + (command: OrchestrationCommand, state: CommandState) => { + const refusal = managedFolderRefusal(lazurioFolder, command, state); + return refusal === null + ? Effect.void + : Effect.fail( + new OrchestrationCommandInvariantError({ commandType: command.type, detail: refusal }), + ); + }, +); + +const turnOutsideFolder = + "Agents in this Environment work in its Lazurio Folder. Start a new chat in the Lazurio project."; +const repositoryAtFolder = + "The Lazurio Folder holds separate repositories, so it cannot become a repository itself."; + +/** + * The guard of the provider reactor, the VCS service and the model recommendation route, read once + * from the server config: why an agent turn or a new repository is refused in a managed + * Environment, or null, and whether a path names the Folder. A path counts as the Folder when it + * resolves to it, symlinks followed. + */ +export const makeFolderGuard = Effect.gen(function* () { + const { lazurioFolder: folder } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + /** `path` with its symlinks resolved, or null when it does not resolve. */ + const canonical = (path: string) => fs.realPath(path).pipe(Effect.orElseSucceed(() => null)); + return { + /** An agent turn runs only in the Folder itself; anywhere else, or nowhere, is refused. */ + turnRefusal: (cwd: string | undefined): Effect.Effect => + folder === undefined + ? Effect.succeed(null) + : (cwd === undefined ? Effect.succeed(null) : canonical(cwd)).pipe( + Effect.map((path) => (path === folder ? null : turnOutsideFolder)), + ), + /** The Folder holds repositories, so it never becomes one; a path that does not resolve is compared as it is. */ + repositoryRefusal: (cwd: string): Effect.Effect => + folder === undefined + ? Effect.succeed(null) + : canonical(cwd).pipe( + Effect.map((path) => ((path ?? cwd) === folder ? repositoryAtFolder : null)), + ), + /** Whether `path` is the Folder itself; never without a managed Folder. */ + isFolder: (path: string): Effect.Effect => + folder === undefined + ? Effect.succeed(false) + : canonical(path).pipe(Effect.map((resolved) => resolved === folder)), + }; +}); + +/** The managed Folder in the config the server gives its clients (`server.getConfig`), if any. */ +export const managedFolderClientConfig = (config: { + readonly lazurioFolder?: string; + readonly lazurioToolsUrl?: string; +}) => ({ + ...(config.lazurioFolder === undefined ? {} : { lazurioFolder: config.lazurioFolder }), + ...(config.lazurioToolsUrl === undefined ? {} : { lazurioToolsUrl: config.lazurioToolsUrl }), +}); diff --git a/apps/server/src/lazurio/managedFolderConfig.test.ts b/apps/server/src/lazurio/managedFolderConfig.test.ts new file mode 100644 index 0000000000000..8fdd0414ed163 --- /dev/null +++ b/apps/server/src/lazurio/managedFolderConfig.test.ts @@ -0,0 +1,118 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Folder is a real directory, reached through a symlink. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { resolveServerConfig } from "../cli/config.ts"; +import { resolveManagedFolderConfig } from "./managedFolderConfig.ts"; + +const withEnv = (env: Record) => + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + NetService.layer, + NodeServices.layer, + ), + ); + +const makeFolder = () => { + const parent = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-lazurio-folder-")), + ); + const folder = NodePath.join(parent, "Lazurio"); + NodeFS.mkdirSync(folder); + const link = NodePath.join(parent, "link-to-Lazurio"); + NodeFS.symlinkSync(folder, link); + return { parent, folder, link }; +}; + +it.effect( + "leaves the config alone without T3CODE_LAZURIO_FOLDER and T3CODE_LAZURIO_TOOLS_URL", + () => + Effect.gen(function* () { + assert.deepStrictEqual(yield* resolveManagedFolderConfig.pipe(withEnv({})), {}); + }), +); + +it.effect("makes the Folder's canonical path the cwd, without a cwd bootstrap", () => + Effect.gen(function* () { + const { folder, link } = makeFolder(); + assert.deepStrictEqual( + yield* resolveManagedFolderConfig.pipe( + withEnv({ + T3CODE_LAZURIO_FOLDER: link, + T3CODE_LAZURIO_TOOLS_URL: "https://launchpad.example.test/tools", + }), + ), + { + cwd: folder, + lazurioFolder: folder, + autoBootstrapProjectFromCwd: false, + lazurioToolsUrl: "https://launchpad.example.test/tools", + }, + ); + }), +); + +it.effect("refuses a relative path, a file, an insecure or credentialed tools URL", () => + Effect.gen(function* () { + const { parent, folder } = makeFolder(); + const file = NodePath.join(parent, "not-a-folder"); + NodeFS.writeFileSync(file, ""); + for (const [env, expected] of [ + [{ T3CODE_LAZURIO_FOLDER: "Lazurio" }, "T3CODE_LAZURIO_FOLDER must be an absolute"], + [{ T3CODE_LAZURIO_FOLDER: file }, "T3CODE_LAZURIO_FOLDER must be a directory"], + [ + { T3CODE_LAZURIO_FOLDER: folder, T3CODE_LAZURIO_TOOLS_URL: "http://launchpad.test/" }, + "T3CODE_LAZURIO_TOOLS_URL must be an HTTPS URL", + ], + [ + { T3CODE_LAZURIO_TOOLS_URL: "https://user:secret@launchpad.test/" }, + "T3CODE_LAZURIO_TOOLS_URL must be an HTTPS URL", + ], + ] as const) { + const error = yield* resolveManagedFolderConfig.pipe(withEnv(env), Effect.flip); + assert.include(String(error), expected); + } + }), +); + +it.effect("reaches the resolved server config: the Folder replaces cwd and its bootstrap", () => + Effect.gen(function* () { + const { folder, parent } = makeFolder(); + const flags = { + mode: Option.some("web" as const), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(NodePath.join(parent, "t3-home")), + cwd: Option.some(parent), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.some(true), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const upstream = yield* resolveServerConfig(flags, Option.none()).pipe(withEnv({})); + assert.strictEqual(upstream.cwd, parent); + assert.isTrue(upstream.autoBootstrapProjectFromCwd); + assert.isUndefined(upstream.lazurioFolder); + assert.isUndefined(upstream.lazurioToolsUrl); + + const managed = yield* resolveServerConfig(flags, Option.none()).pipe( + withEnv({ T3CODE_LAZURIO_FOLDER: folder }), + ); + assert.strictEqual(managed.cwd, folder); + assert.strictEqual(managed.lazurioFolder, folder); + assert.isFalse(managed.autoBootstrapProjectFromCwd); + }), +); diff --git a/apps/server/src/lazurio/managedFolderConfig.ts b/apps/server/src/lazurio/managedFolderConfig.ts new file mode 100644 index 0000000000000..99546e4ae5744 --- /dev/null +++ b/apps/server/src/lazurio/managedFolderConfig.ts @@ -0,0 +1,66 @@ +/** + * Lazurio overlay, the managed Lazurio Folder (LazurioPlatform F51): its part of the server's + * config, which `resolveServerConfig` (cli/config.ts) spreads over its own. + * + * - `T3CODE_LAZURIO_FOLDER`: the Environment's Lazurio Folder, an absolute existing directory, + * declared by Machines. Its canonical path (symlinks resolved) becomes the server's cwd and + * `lazurioFolder`, and the server bootstraps no project from the cwd: the managed startup + * (managedFolderStartup.ts) adopts or creates the Folder's own project instead. + * - `T3CODE_LAZURIO_TOOLS_URL`: the Environment's page where people connect agent accounts, an + * HTTPS URL without credentials, linked from the managed welcome. + * + * Unset, both leave the config as upstream resolves it. + */ +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; + +const invalid = (message: string) => + Effect.fail( + new Config.ConfigError(new Schema.SchemaError(new SchemaIssue.InvalidValue({ message }))), + ); + +const ManagedFolderEnv = Config.all({ + folder: Config.String("T3CODE_LAZURIO_FOLDER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + toolsUrl: Config.URL("T3CODE_LAZURIO_TOOLS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + +/** The declared Folder's canonical path: absolute, existing, a directory, symlinks resolved. */ +const canonicalFolder = (declared: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (!path.isAbsolute(declared)) + return yield* invalid("T3CODE_LAZURIO_FOLDER must be an absolute existing directory."); + const root = yield* fs.realPath(declared); + const info = yield* fs.stat(root); + if (info.type !== "Directory") + return yield* invalid("T3CODE_LAZURIO_FOLDER must be a directory."); + return root; + }); + +export const resolveManagedFolderConfig = Effect.gen(function* () { + const env = yield* ManagedFolderEnv; + const folder = env.folder === undefined ? undefined : yield* canonicalFolder(env.folder); + if ( + env.toolsUrl !== undefined && + (env.toolsUrl.protocol !== "https:" || env.toolsUrl.username || env.toolsUrl.password) + ) + return yield* invalid("T3CODE_LAZURIO_TOOLS_URL must be an HTTPS URL without credentials."); + return { + ...(folder === undefined + ? {} + : { cwd: folder, lazurioFolder: folder, autoBootstrapProjectFromCwd: false }), + ...(env.toolsUrl === undefined ? {} : { lazurioToolsUrl: env.toolsUrl.toString() }), + }; +}); diff --git a/apps/server/src/lazurio/modelRecommendation.test.ts b/apps/server/src/lazurio/modelRecommendation.test.ts new file mode 100644 index 0000000000000..2e16801390845 --- /dev/null +++ b/apps/server/src/lazurio/modelRecommendation.test.ts @@ -0,0 +1,68 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId, ServerSettings } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as Settings from "../serverSettings.ts"; +import { reconcileRecommendedModel } from "./modelRecommendation.ts"; + +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" }] }; + +it("a recommendation fills an unset default and follows its own last write, nothing else", () => { + const unset = reconcileRecommendedModel(DEFAULT_SERVER_SETTINGS, first); + assert.deepStrictEqual(unset.defaultModelSelection, first); + assert.deepStrictEqual(unset.lazurioManagedModelSelection, first); + const followed = reconcileRecommendedModel(unset, second); + assert.deepStrictEqual(followed.defaultModelSelection, second); + assert.deepStrictEqual(followed.lazurioManagedModelSelection, second); + const chosen = { ...followed, defaultModelSelection: personal }; + assert.strictEqual(reconcileRecommendedModel(chosen, first), chosen); +}); + +const settingsLayer = Settings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3-model-recommendation-" })), + ), + Layer.provideMerge(NodeServices.layer), +); + +it.effect("the settings writer records recommendations atomically and keeps personal choices", () => + Effect.gen(function* () { + const settings = yield* Settings.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const reconcile = settings.reconcileRecommendedModel; + assert.isDefined(reconcile); + yield* reconcile(first); + assert.deepStrictEqual((yield* settings.getSettings).defaultModelSelection, first); + yield* reconcile(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* reconcile(first); + const preserved = yield* settings.getSettings; + assert.deepStrictEqual(preserved.defaultModelSelection, personal); + assert.deepStrictEqual(preserved.lazurioManagedModelSelection, second); + yield* settings.updateSettings({ defaultModelSelection: null }); + yield* reconcile(first); + assert.deepStrictEqual((yield* settings.getSettings).defaultModelSelection, first); + }).pipe(Effect.provide(settingsLayer)), +); diff --git a/apps/server/src/lazurio/modelRecommendation.ts b/apps/server/src/lazurio/modelRecommendation.ts index 3598b8bdc8ab3..9f34489708eb2 100644 --- a/apps/server/src/lazurio/modelRecommendation.ts +++ b/apps/server/src/lazurio/modelRecommendation.ts @@ -1,7 +1,26 @@ -import { ModelSelection } from "@t3tools/contracts"; +/** + * Lazurio overlay (LazurioPlatform F51): the Platform recommends Chat's model, T3 keeps the + * person's preference. `lazurioManagedModelSelection` records the last default this wrote, so a + * default the person set differs from it and stays, while an unset default or the previous + * recommendation follows the new one. + */ +import { ModelSelection, type ServerSettings } from "@t3tools/contracts"; +import * as Equal from "effect/Equal"; 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, }); + +/** The settings with the recommended model applied where the person chose none of their own. */ +export function reconcileRecommendedModel( + current: ServerSettings, + selection: NonNullable, +): ServerSettings { + return current.defaultModelSelection === null || + Equal.equals(current.defaultModelSelection, current.lazurioManagedModelSelection) + ? { ...current, defaultModelSelection: selection, lazurioManagedModelSelection: selection } + : current; +} diff --git a/apps/server/src/lazurio/modelRecommendationRoute.test.ts b/apps/server/src/lazurio/modelRecommendationRoute.test.ts index d0e561fe21604..3400b27277c37 100644 --- a/apps/server/src/lazurio/modelRecommendationRoute.test.ts +++ b/apps/server/src/lazurio/modelRecommendationRoute.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Folder is a real directory, also reached through a symlink. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -17,12 +22,16 @@ 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 parent = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-model-route-folder-")), +); +const folder = NodePath.join(parent, "Lazurio"); +const link = NodePath.join(parent, "link-to-Lazurio"); +NodeFS.mkdirSync(folder); +NodeFS.symlinkSync(folder, link); const configLayer = Layer.effect( ServerConfig.ServerConfig, - Effect.map(ServerConfig.ServerConfig, (config) => ({ - ...config, - lazurioFolder: "/workspace/Lazurio", - })), + Effect.map(ServerConfig.ServerConfig, (config) => ({ ...config, lazurioFolder: folder })), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-model-route-" }))); const authLayer = EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceMemory), @@ -30,7 +39,12 @@ const authLayer = EnvironmentAuth.layer.pipe( Layer.provide(ServerEnvironment.identityLayer), Layer.provideMerge(configLayer), ); -it.layer(Layer.mergeAll(authLayer, Settings.layerTest()).pipe(Layer.provide(NodeServices.layer)), { +// The server's own settings layer: only it reconciles a recommendation. +const settingsLayer = Settings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(authLayer), +); +it.layer(settingsLayer.pipe(Layer.provide(NodeServices.layer)), { excludeTestServices: true, })("native model recommendation route", (it) => { it.effect( @@ -38,18 +52,20 @@ it.layer(Layer.mergeAll(authLayer, Settings.layerTest()).pipe(Layer.provide(Node () => 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 }, - ); + const handlerWith = (settings: Settings.ServerSettingsService["Service"]) => + 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 }, + ); + const settings = yield* Settings.ServerSettingsService; + const handler = handlerWith(settings); yield* Effect.addFinalizer(() => Effect.promise(() => handler.dispose())); const readOnly = yield* auth.issueSession({ scopes: [AuthOrchestrationReadScope], @@ -64,33 +80,50 @@ it.layer(Layer.mergeAll(authLayer, Settings.layerTest()).pipe(Layer.provide(Node 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), - }), - ); + const request = (body: unknown, token?: string) => + new Request(`http://localhost${MODEL_RECOMMENDATION_ROUTE}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + const post = (body: unknown, token?: string) => handler.handler(request(body, token)); 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); + for (const elsewhere of [parent, NodePath.join(folder, "organizations"), "relative"]) { + expect((await post({ folder: elsewhere, selection }, writer.token)).status).toBe(409); + } + // The Platform sends the Folder's realpath; a symlink or a trailing slash names it too. + for (const sameFolder of [link, `${folder}/`]) { + expect((await post({ folder: sameFolder, selection }, writer.token)).status).toBe(200); + } + const applied = await post({ folder, selection }, writer.token); + expect(applied.status).toBe(200); + // `t3 lazurio model` prints this answer as it is, and the Platform reads it. + expect(await applied.text()).toBe( + '{"version":1,"current":{"instanceId":"codex","model":"gpt-6.1-sol","options":[{"id":"reasoningEffort","value":"high"}]},"lastManaged":{"instanceId":"codex","model":"gpt-6.1-sol","options":[{"id":"reasoningEffort","value":"high"}]}}', + ); }); 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), - ); + yield* Effect.promise(() => post({ folder, selection }, writer.token)); expect((yield* settings.getSettings).defaultModelSelection).toEqual(custom); + + // A settings service that cannot reconcile, such as upstream's test layer, refuses. + const testSettings = yield* Effect.service(Settings.ServerSettingsService).pipe( + Effect.provide(Settings.layerTest()), + ); + const unavailable = handlerWith(testSettings); + yield* Effect.addFinalizer(() => Effect.promise(() => unavailable.dispose())); + const refused = yield* Effect.promise(() => + unavailable.handler(request({ folder, selection }, writer.token)), + ); + expect(refused.status).toBe(503); }), ); }); diff --git a/apps/server/src/lazurio/modelRecommendationRoute.ts b/apps/server/src/lazurio/modelRecommendationRoute.ts index 12811cf6cd432..600e5d2899622 100644 --- a/apps/server/src/lazurio/modelRecommendationRoute.ts +++ b/apps/server/src/lazurio/modelRecommendationRoute.ts @@ -1,20 +1,29 @@ +/** + * Lazurio overlay (LazurioPlatform F51): `POST /api/lazurio/model-recommendation`, which + * `t3 lazurio model ` (cli.ts) calls on the running service. The Platform supplies + * the recommendation; T3 owns reconciling it with the person's own choice and persisting both + * (modelRecommendation.ts). The scope is upstream's for writing settings. + * + * Answers `{"version": 1, "current", "lastManaged"}`; 400 for another body, 409 when the request + * names another folder than this Environment's managed Folder (a path that resolves to the Folder + * is the Folder), 503 when the settings service cannot reconcile (only its test layer). + */ 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 { requiredScopeForRpcMethod } from "../auth/RpcAuthorization.ts"; import { ServerSettingsService } from "../serverSettings.ts"; - +import { makeFolderGuard } from "./managedFolder.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. +import { authenticateOverlayRoute } from "./routeAuth.ts"; + export const layer = HttpRouter.use((router) => Effect.gen(function* () { const settings = yield* ServerSettingsService; - const config = yield* ServerConfig; + const folderGuard = yield* makeFolderGuard; const answer = (value: { defaultModelSelection: ModelSelection | null; lazurioManagedModelSelection: ModelSelection | null; @@ -28,9 +37,7 @@ export const layer = HttpRouter.use((router) => "POST", MODEL_RECOMMENDATION_ROUTE, Effect.gen(function* () { - yield* authenticateRawRouteWithScope( - requiredScopeForRpcMethod(WS_METHODS.serverUpdateSettings), - ); + yield* authenticateOverlayRoute(requiredScopeForRpcMethod(WS_METHODS.serverUpdateSettings)); const selection = yield* HttpServerRequest.schemaBodyJson(ModelRecommendationRequest).pipe( Effect.option, ); @@ -39,8 +46,10 @@ export const layer = HttpRouter.use((router) => { error: "invalid-recommendation" }, { status: 400 }, ); - if (config.lazurioFolder === undefined || selection.value.folder !== config.lazurioFolder) + if (!(yield* folderGuard.isFolder(selection.value.folder))) return HttpServerResponse.jsonUnsafe({ error: "folder-mismatch" }, { status: 409 }); + if (settings.reconcileRecommendedModel === undefined) + return HttpServerResponse.jsonUnsafe({ error: "unavailable" }, { status: 503 }); return answer( yield* settings .reconcileRecommendedModel(selection.value.selection) diff --git a/apps/server/src/lazurio/overlay.ts b/apps/server/src/lazurio/overlay.ts index eeb675aadb613..1fa2a185eb045 100644 --- a/apps/server/src/lazurio/overlay.ts +++ b/apps/server/src/lazurio/overlay.ts @@ -1,15 +1,17 @@ /** - * Lazurio overlay (root decision 0191, plan DEV-6646): the server overlay's one layer, which one - * line of `server.ts` joins into upstream's routes. The Environment browser host drives the - * Environment browser for T3's browser tools (environmentBrowser.ts); each thread keeps its tabs - * of that browser in its holder file (threadTabs.ts), which the web reaches through - * threadTabsRoute.ts, and archiving or deleting a thread lets go of them (threadLifecycle.ts). + * Lazurio overlay (root decision 0191, plan DEV-6646; LazurioPlatform F51): the server overlay's + * one layer, which one line of `server.ts` joins into upstream's routes. The Environment browser + * host drives the Environment browser for T3's browser tools (environmentBrowser.ts); each thread + * keeps its tabs of that browser in its holder file (threadTabs.ts), which the web reaches through + * threadTabsRoute.ts, and archiving or deleting a thread lets go of them (threadLifecycle.ts). The + * model recommendation route takes the Platform's recommended model for the managed Folder + * (modelRecommendationRoute.ts). */ -import * as ModelRecommendationRoute from "./modelRecommendationRoute.ts"; import * as Layer from "effect/Layer"; import * as EnvironmentBrowser from "./environmentBrowser.ts"; +import * as ModelRecommendationRoute from "./modelRecommendationRoute.ts"; import * as ThreadLifecycle from "./threadLifecycle.ts"; import * as ThreadTabs from "./threadTabs.ts"; import * as ThreadTabsRoute from "./threadTabsRoute.ts"; diff --git a/apps/server/src/lazurio/routeAuth.ts b/apps/server/src/lazurio/routeAuth.ts new file mode 100644 index 0000000000000..8d7aa751b05a6 --- /dev/null +++ b/apps/server/src/lazurio/routeAuth.ts @@ -0,0 +1,35 @@ +/** + * Lazurio overlay: how the overlay's HTTP routes (threadTabsRoute.ts, modelRecommendationRoute.ts) + * authenticate, the way upstream's own raw routes do (`authenticateRawRouteWithScope` in http.ts, + * which upstream keeps private): EnvironmentAuth checks the session cookie or the bearer or DPoP + * token, then the session must hold the route's scope. A missing or bad credential answers 401, a + * missing scope 403. + */ +import type { AuthEnvironmentScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { + failEnvironmentAuthInvalid, + failEnvironmentInternal, + failEnvironmentScopeRequired, +} from "../auth/http.ts"; + +export const authenticateOverlayRoute = (scope: AuthEnvironmentScope) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateHttpRequest(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), + ); + if (!session.scopes.includes(scope)) return yield* failEnvironmentScopeRequired(scope); + }); diff --git a/apps/server/src/lazurio/threadTabs.test.ts b/apps/server/src/lazurio/threadTabs.test.ts index 70feea8e2681c..db6cdc7050ede 100644 --- a/apps/server/src/lazurio/threadTabs.test.ts +++ b/apps/server/src/lazurio/threadTabs.test.ts @@ -672,6 +672,20 @@ describe("tabs an agent opens", () => { expect(await w.stored(stripB)).toBeNull(); }); + it("a session whose thread was not live yet is asked again when it binds anew", async () => { + const w = await world(); + const threadC = "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a"; + const stripC = agentBrowserSessionName(threadC); + await w.bind(stripC, id(5)); + await w.core.refresh(); + expect(await w.stored(stripC)).toBeNull(); + // Its thread arrives (or comes back from the archive) and its session binds another tab. + w.threads.set(threadC, "Later"); + await w.bind(stripC, id(6)); + await w.core.refresh(); + expect(await w.stored(stripC)).toMatchObject([{ id: id(6) }]); + }); + it("another Environment's tab goes to the thread's subscribers only, live", async () => { const w = await world(); const other: ThreadTabsEvent[] = []; @@ -697,7 +711,6 @@ describe("what a snapshot says of each tab", () => { const [tab] = ((await w.core.read(threadA)) as ThreadTabsSnapshot).tabs; expect(tab).toEqual({ tabId: id(1), - view: `${viewOrigin}/t/${id(1)}`, agent: false, alsoHeldBy: [ { strip: "launchpad", label: "Launchpad" }, diff --git a/apps/server/src/lazurio/threadTabs.ts b/apps/server/src/lazurio/threadTabs.ts index c37da31d53142..83f91f39bcea9 100644 --- a/apps/server/src/lazurio/threadTabs.ts +++ b/apps/server/src/lazurio/threadTabs.ts @@ -50,6 +50,7 @@ import { fileText, holderDirectory, holderFileText, + holderLabel, isMissing, isStrip, isTargetId, @@ -72,7 +73,6 @@ import { type LiveThread, ThreadLifecycle, type ThreadLifecycleEvent } from "./t export const MAX_THREAD_TABS = 128; /** A defensive bound well inside the 64 KiB the hub reads of a holder file. */ const MAX_FILE_BYTES = 60 * 1024; -const MAX_LABEL_LENGTH = 200; const REASONS = [ "invalid-request", @@ -99,10 +99,9 @@ export interface TabHolder { readonly label: string; } +/** A tab of the thread; its view is `/t/`, which the web builds itself. */ export interface ThreadTab { readonly tabId: string; - /** The view of the tab, `/t/`. */ - readonly view: string; /** An agent works in it: a session bound to it has a live daemon. */ readonly agent: boolean; readonly alsoHeldBy: ReadonlyArray; @@ -192,7 +191,6 @@ interface Held { const isThreadTabsError = Schema.is(ThreadTabsError); const isThreadStrip = (strip: string) => isStrip(strip) && strip.startsWith("t3-"); const refusal = (reason: ThreadTabsErrorReason) => new ThreadTabsError({ reason }); -const cutLabel = (label: string) => label.trim().slice(0, MAX_LABEL_LENGTH); export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { const lazurioDirectory = NodePath.join(seams.runtimeDirectory, "lazurio-browser"); @@ -215,7 +213,7 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { let working = new Set(); /** The tab each `t3-` session was last seen bound to: only another one is recorded. */ const lastBound = new Map(); - const threadOfSessionCache = new Map(); + const threadOfSessionCache = new Map(); const subscribers = new Map< string, Set<{ readonly threadId: string; readonly listener: (event: ThreadTabsEvent) => void }> @@ -275,7 +273,7 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { return { list, boot }; } - /** The ids of `held` the hub's list lets go (`keptTabs`). */ + /** Lets go of the tabs of `held` that the hub's list lets go (`keptTabs`), and names them. */ function letGo( held: Held, { list, boot }: Awaited>, @@ -288,7 +286,9 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { boot, ), ); - return [...held.tabs.keys()].filter((id) => !kept.has(id)); + const gone = [...held.tabs.keys()].filter((id) => !kept.has(id)); + for (const id of gone) held.tabs.delete(id); + return gone; } /** Writes the file, or removes it once its last tab went, so the hub reads fewer files. */ @@ -335,14 +335,13 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { if (live === null) throw refusal("thread-not-found"); const held = await current(strip); const gone = letGo(held, await listAndBoot()); - for (const id of gone) held.tabs.delete(id); let changed = gone.length > 0; if (!held.tabs.has(tab)) { if (held.tabs.size >= MAX_THREAD_TABS) throw refusal("too-many-tabs"); held.tabs.set(tab, await seams.bootClock()); changed = true; } - if (held.label === "") held.label = cutLabel(live.title); + if (held.label === "") held.label = holderLabel(live.title); if (retired.has(strip)) throw refusal("thread-not-found"); if (changed) await write(strip, held); forgetReveals(strip, gone); @@ -354,7 +353,6 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { serialized(async () => { const held = await current(strip); const gone = letGo(held, await listAndBoot()); - for (const id of gone) held.tabs.delete(id); const had = held.tabs.delete(tab); if (had || gone.length > 0) await write(strip, held); forgetReveals(strip, [...gone, tab]); @@ -380,7 +378,6 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { try { const held = await current(strip); const gone = letGo(held, listed); - for (const id of gone) held.tabs.delete(id); if (gone.length > 0) await write(strip, held); forgetReveals(strip, gone); remember(strip, held); @@ -438,7 +435,8 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { } /** The live thread whose session is `session`: its id when the name is the id unchanged, else - * by the names of the live threads. Looked up once. */ + * by the names of the live threads. A thread found is looked up once; a session of no live + * thread is asked again next time, since its thread may come back (unarchived) or arrive. */ async function threadOfSession(session: string): Promise { if (!isThreadStrip(session)) return null; const cached = threadOfSessionCache.get(session); @@ -457,7 +455,7 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { } catch { return null; } - threadOfSessionCache.set(session, threadId); + if (threadId !== null) threadOfSessionCache.set(session, threadId); return threadId; } @@ -525,7 +523,6 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { const revealId = own?.get(tabId); return { tabId, - view: `${origin}/t/${tabId}`, agent: working.has(tabId), alsoHeldBy: (holdsOf.get(tabId) ?? []).filter((holder) => holder.strip !== strip), ...(revealId === undefined ? {} : { revealRequest: { id: revealId } }), @@ -697,7 +694,7 @@ export function createThreadTabs(seams: ThreadTabsCoreSeams): ThreadTabsCore { log("thread-tabs-label-skipped", { strip }); return; } - const label = cutLabel(title); + const label = holderLabel(title); if (held.tabs.size === 0 || held.label === label) return; held.label = label; await write(strip, held); diff --git a/apps/server/src/lazurio/threadTabsRoute.test.ts b/apps/server/src/lazurio/threadTabsRoute.test.ts index 1eb6e4eb12249..1e1aca4915b25 100644 --- a/apps/server/src/lazurio/threadTabsRoute.test.ts +++ b/apps/server/src/lazurio/threadTabsRoute.test.ts @@ -200,7 +200,7 @@ it.layer(authLayer.pipe(Layer.provide(NodeServices.layer)), { excludeTestService ); expect(held.status).toBe(200); expect(((await held.json()) as ThreadTabs.ThreadTabsSnapshot).tabs).toEqual([ - { tabId: tab, view: `${viewOrigin}/t/${tab}`, agent: false, alsoHeldBy: [] }, + { tabId: tab, agent: false, alsoHeldBy: [] }, ]); for (const threadId of [archived, "5c4b3a29-1807-4f6e-9d5c-4b3a2918f7e6"]) { const missing = await on.handler( @@ -328,7 +328,7 @@ it.layer(authLayer.pipe(Layer.provide(NodeServices.layer)), { excludeTestService ); expect(await events.next()).toMatchObject({ event: "snapshot", - data: { tabs: [{ tabId: tab, view: `${viewOrigin}/t/${tab}` }] }, + data: { viewOrigin, tabs: [{ tabId: tab }] }, }); await events.cancel(); // An unknown thread is a 404, on which an EventSource stops. diff --git a/apps/server/src/lazurio/threadTabsRoute.ts b/apps/server/src/lazurio/threadTabsRoute.ts index 82ffc14a12fae..63e2a394c65d7 100644 --- a/apps/server/src/lazurio/threadTabsRoute.ts +++ b/apps/server/src/lazurio/threadTabsRoute.ts @@ -10,14 +10,14 @@ * * Errors are `{"error": }`: 400 invalid-request, 404 thread-not-found, 409 too-many-tabs, * 503 unavailable | holder-unreadable | hold-failed. Requests authenticate as any of T3 Code's - * HTTP routes do (a session cookie, or a bearer or DPoP token); a cookie-authenticated POST must - * come from the external origin (EnvironmentAuth), while a same-origin EventSource, which sends no - * Origin, passes. The scopes are upstream's for listing, opening and closing a preview + * HTTP routes do (a session cookie, or a bearer or DPoP token; routeAuth.ts); a cookie-authenticated + * POST must come from the external origin (EnvironmentAuth), while a same-origin EventSource, which + * sends no Origin, passes. The scopes are upstream's for listing, opening and closing a preview * (`requiredScopeForRpcMethod`), so they follow upstream. The stream is never compressed or * buffered on the way: `cache-control: no-transform` keeps the compression middleware off it. */ -import { ThreadId, WS_METHODS, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { ThreadId, WS_METHODS } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -25,13 +25,8 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import { - failEnvironmentAuthInvalid, - failEnvironmentInternal, - failEnvironmentScopeRequired, -} from "../auth/http.ts"; import { requiredScopeForRpcMethod } from "../auth/RpcAuthorization.ts"; +import { authenticateOverlayRoute as authenticate } from "./routeAuth.ts"; import * as ThreadTabs from "./threadTabs.ts"; export const THREAD_TABS_ROUTE = "/api/lazurio/thread-tabs"; @@ -76,26 +71,6 @@ const serverSentEvent = (event: ThreadTabs.ThreadTabsEvent) => export const layer = HttpRouter.use((router) => Effect.gen(function* () { const threadTabs = yield* ThreadTabs.ThreadTabs; - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - - const authenticate = (scope: AuthEnvironmentScope) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catch((error) => - Effect.gen(function* () { - if (EnvironmentAuth.isServerAuthCredentialError(error)) { - return yield* failEnvironmentAuthInvalid( - EnvironmentAuth.serverAuthCredentialReason(error), - EnvironmentAuth.serverAuthDpopFailureReason(error), - ); - } - return yield* failEnvironmentInternal("internal_error", error); - }), - ), - ); - if (!session.scopes.includes(scope)) return yield* failEnvironmentScopeRequired(scope); - }); const threadIdOfQuery = Effect.map(HttpServerRequest.HttpServerRequest, (request) => Option.flatMap(HttpServerRequest.toURL(request), (url) => diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 3ef633ab01d67..6786e67cb4cf1 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -142,126 +142,131 @@ 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({ + effectIt.live( + "adopts an existing Folder project on restart and retains other project history", + () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "managed-upgrade-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })), + ); + const databasePath = NodePath.join(directory, "state.sqlite"); + const folder = "/workspace/Lazurio"; + // Before the Environment was managed: the Folder's project and another one. + yield* Effect.forEach( + [ + ["folder", folder], + ["history", "/old/repository"], + ] as const, + ([name, path]) => + Effect.flatMap(OrchestrationEngineService, (engine) => + engine.dispatch({ + type: "project.create", + commandId: CommandId.make(name), + projectId: ProjectId.make(name), + title: name, + workspaceRoot: path, + createdAt: now(), + }), + ), + ).pipe(Effect.provide(makeOrchestrationLayer(databasePath))); + // Managed from here on, over the same database. + yield* Effect.gen(function* () { + const adopted = yield* ensureManagedFolderProject; + expect(adopted.bootstrapProjectId).toBe("folder"); + expect(adopted.bootstrapProjectCreated).toBe(false); + yield* ensureManagedFolderProject; + const snapshot = yield* Effect.flatMap(ProjectionSnapshotQuery, (query) => + query.getSnapshot(), + ); + expect(snapshot.projects).toHaveLength(2); + expect(snapshot.projects.find((p) => p.id === "history")?.workspaceRoot).toBe( + "/old/repository", + ); + }).pipe(Effect.provide(makeOrchestrationLayer(databasePath, undefined, folder))); + }), + ); + + effectIt.live( + "managed startup adopts or creates one native project and the engine enforces its entry context", + () => + Effect.gen(function* () { + const folder = "/tmp/managed-lazurio"; + const first = yield* ensureManagedFolderProject; + const second = yield* ensureManagedFolderProject; + expect(first.bootstrapProjectCreated).toBe(true); + expect(second).toEqual({ ...first, bootstrapProjectCreated: false }); + const engine = yield* OrchestrationEngineService; + const snapshot = yield* Effect.flatMap(ProjectionSnapshotQuery, (query) => + query.getSnapshot(), + ); + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.projects[0]?.workspaceRoot).toBe(folder); + const id = first.bootstrapProjectId!; + const refusal = (command: OrchestrationCommand) => + engine.dispatch(command).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect( + yield* refusal({ type: "project.create", - commandId: CommandId.make(name), - projectId: ProjectId.make(name), - title: name, - workspaceRoot: path, + commandId: CommandId.make("other-root"), + projectId: ProjectId.make("other"), + title: "Other", + workspaceRoot: `${folder}/organizations/example`, 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"), + ).toContain("Lazurio"); + expect( + yield* refusal({ + type: "project.meta.update", + commandId: CommandId.make("retarget"), + projectId: id, + workspaceRoot: "/tmp/other", + }), + ).toContain("cannot point to another folder"); + expect( + yield* refusal({ + type: "project.delete", + commandId: CommandId.make("delete"), + projectId: id, + }), + ).toContain("cannot be removed"); + const thread = { + type: "thread.create" as const, + commandId: CommandId.make("root-thread"), + threadId: ThreadId.make("root-thread"), projectId: id, - workspaceRoot: "/tmp/other", - }), - ).rejects.toThrow("cannot point to another folder"); - 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(); - } - }); + 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(), + }; + yield* engine.dispatch(thread); + expect( + yield* refusal({ + ...thread, + commandId: CommandId.make("worktree-thread"), + threadId: ThreadId.make("worktree-thread"), + worktreePath: "/tmp/worktree", + }), + ).toContain("Lazurio"); + expect( + yield* refusal({ + type: "thread.meta.update", + commandId: CommandId.make("move-thread"), + threadId: thread.threadId, + worktreePath: "/tmp/worktree", + }), + ).toContain("Lazurio"); + }).pipe(Effect.provide(makeOrchestrationLayer(undefined, undefined, "/tmp/managed-lazurio"))), + ); it.each(["running", "stopped"] as const)( "sends async answers with a %s session and rejects old duplicate replies", diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 90b2ed9462b6e..40bc9060414be 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -40,8 +40,7 @@ import { type OrchestrationDispatchError, type OrchestrationProjectorDecodeError, } from "../Errors.ts"; -import { ServerConfig } from "../../config.ts"; -import { managedFolderRefusal } from "../../lazurio/managedFolder.ts"; +import { makeCommandGuard } from "../../lazurio/managedFolder.ts"; import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; @@ -91,7 +90,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; - const { lazurioFolder } = yield* ServerConfig; + const lazurioCommandGuard = yield* makeCommandGuard; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -245,12 +244,7 @@ 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, - }); + yield* lazurioCommandGuard(envelope.command, commandReadModel); const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 87f9bd03d46c3..f3848a889ac5f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -188,6 +188,8 @@ describe("ProviderCommandReactor", () => { session: ProviderSession, ) => Effect.Effect; readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"]; + /** Lazurio overlay: a managed Environment whose Folder is the project's root. */ + readonly lazurioFolder?: string; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -495,7 +497,15 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + Layer.provideMerge( + Layer.effect( + ServerConfig, + Effect.map(ServerConfig, (config) => ({ + ...config, + ...(input?.lazurioFolder === undefined ? {} : { lazurioFolder: input.lazurioFolder }), + })), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))), + ), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); @@ -511,7 +521,7 @@ describe("ProviderCommandReactor", () => { commandId: CommandId.make("cmd-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", - workspaceRoot: "/tmp/provider-project", + workspaceRoot: input?.lazurioFolder ?? "/tmp/provider-project", defaultModelSelection: modelSelection, createdAt: now, }), @@ -4500,4 +4510,70 @@ describe("ProviderCommandReactor", () => { expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1); }), ); + + describe("in a managed Lazurio Environment", () => { + const startTurn = (harness: Awaited>, id: string) => + Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-start-${id}`), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId(`user-message-${id}`), + role: "user", + text: "hello Lazurio", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + const makeFolder = () => { + const parent = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-lazurio-")), + ); + createdBaseDirs.add(parent); + const folder = NodePath.join(parent, "Lazurio"); + NodeFS.mkdirSync(folder); + return folder; + }; + + it("starts an agent turn in the Lazurio Folder", async () => { + const folder = makeFolder(); + const harness = await createHarness({ lazurioFolder: folder }); + await startTurn(harness, "in-folder"); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: folder }); + }); + + it("refuses an agent turn whose workspace no longer resolves to the Lazurio Folder", async () => { + const folder = makeFolder(); + const harness = await createHarness({ lazurioFolder: folder }); + // The project still names the Folder, but on disk that path now leads somewhere else. + NodeFS.renameSync(folder, `${folder}-moved`); + NodeFS.symlinkSync(`${folder}-moved`, folder); + await startTurn(harness, "moved-folder"); + await waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.activities.some((a) => a.kind === "provider.turn.start.failed") ?? false; + }); + expect(harness.startSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(0); + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining( + "Agents in this Environment work in its Lazurio Folder. Start a new chat in the Lazurio project.", + ), + }, + }); + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7b00d044842f9..f85f7fd08bc05 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,4 +1,4 @@ -import { ServerConfig } from "../../config.ts"; +import { makeFolderGuard } from "../../lazurio/managedFolder.ts"; import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; import { type ChatAttachment, @@ -212,7 +212,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { } const make = Effect.gen(function* () { - const { lazurioFolder } = yield* ServerConfig; + const lazurioFolderGuard = yield* makeFolderGuard; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -712,20 +712,13 @@ 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: - "Agents in this Environment work in its Lazurio Folder. Start a new chat in the Lazurio project.", - }); - } - } + const lazurioRefusal = yield* lazurioFolderGuard.turnRefusal(effectiveCwd); + if (lazurioRefusal !== null) + return yield* new ProviderAdapterRequestError({ + provider: preferredProvider, + method: "thread.turn.start", + detail: lazurioRefusal, + }); 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 9ed1f5dab77de..cad45007665ca 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -259,7 +259,6 @@ 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 f38c45249d3c9..0970ea88748b3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -337,7 +337,6 @@ 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 87d460b6a8c90..292ef66ff2eec 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -299,8 +299,6 @@ 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/server.ts b/apps/server/src/server.ts index 963e4aec5dcf0..59e8ad11490d2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -613,8 +613,9 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), - // Lazurio overlay (plan DEV-6646): T3's browser tools drive the Environment browser, and each - // thread keeps its tabs of it. It shares the routes' one preview broker, provided below. + // Lazurio overlay (plan DEV-6646): T3's browser tools drive the Environment browser, each thread + // keeps its tabs of it, and the Platform's recommended model reaches the managed Folder's + // settings. It shares the routes' one preview broker, provided below. LazurioOverlay.layer.pipe(Layer.provide(ProcessRunner.layer)), // Last, so no route layer can replace the server's one TracerDisabledWhen. untracedRequestsLayer, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c7c9797598148..156e6e6f65bda 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -206,7 +206,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectCreated = false; let bootstrapThreadCreated = false; - if (serverConfig.autoBootstrapProjectFromCwd && serverConfig.lazurioFolder === undefined) { + if (serverConfig.autoBootstrapProjectFromCwd) { const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; const defaultModelSelection = settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); @@ -981,7 +981,7 @@ export const make = (options?: StartupOptions) => const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); - if (serverConfig.autoBootstrapProjectFromCwd && serverConfig.lazurioFolder === undefined) { + if (serverConfig.autoBootstrapProjectFromCwd) { yield* forkParked( runStartupPhase( "welcome.autobootstrap", @@ -1060,10 +1060,7 @@ export const make = (options?: StartupOptions) => environment, ...welcomeBase, ...managedWelcome, - bootstrapStatus: - serverConfig.autoBootstrapProjectFromCwd && serverConfig.lazurioFolder === undefined - ? "pending" - : "complete", + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", }, }), ); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 8887d51449147..502ae3430e27d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1733,37 +1733,3 @@ 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 eba486efa3de5..50e5cda8208c1 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -53,10 +53,10 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, - reconcileRecommendedModel, deriveLegacyProjectOverrides, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; +import { reconcileRecommendedModel } from "./lazurio/modelRecommendation.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; export { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; @@ -220,8 +220,11 @@ export class ServerSettingsService extends Context.Service< patch: ServerSettingsPatch, ) => Effect.Effect; - /** Hosting recommendation: the same native writer preserves a custom default. */ - readonly reconcileRecommendedModel: ( + /** + * Lazurio overlay (LazurioPlatform F51): the Platform's recommended model, written by the same + * writer and atomically keeping a person's own choice. Only the server's own layer has it. + */ + readonly reconcileRecommendedModel?: ( selection: NonNullable, ) => Effect.Effect; @@ -267,13 +270,6 @@ 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"]; @@ -1032,15 +1028,12 @@ const make = Effect.gen(function* () { const updateSettings = ( patch: ServerSettingsPatch, - recommendation?: NonNullable, + apply = applyServerSettingsPatch, ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const updated = - recommendation === undefined - ? applyServerSettingsPatch(current, patch) - : reconcileRecommendedModel(current, recommendation); + const updated = apply(current, patch); const persisted = yield* persistProviderEnvironmentSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); const materialized = yield* Effect.uninterruptibleMask(() => @@ -1145,7 +1138,8 @@ const make = Effect.gen(function* () { Effect.map(resolveTextGenerationProvider), ), updateSettings, - reconcileRecommendedModel: (selection) => updateSettings({}, selection), + reconcileRecommendedModel: (selection) => + updateSettings({}, (current) => reconcileRecommendedModel(current, 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 186e7d865c707..325233bf59d98 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2123,7 +2123,6 @@ 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 386cc1db29def..9cc0c4335557e 100644 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -3,6 +3,7 @@ import { ServerConfig } from "../config.ts"; import { assert, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -111,3 +112,41 @@ it.effect("defaults repository initialization to Git until callers choose a VCS ), ); }); + +it.effect("never makes a managed Environment's Lazurio Folder a repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const folder = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-lazurio-" }), + ); + const repository = `${folder}/organizations/example`; + const calls: string[] = []; + const driver = makeDriver(calls); + const testLayer = VcsProvisioningService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + }), + ), + Layer.provide( + Layer.effect( + ServerConfig, + Effect.map(ServerConfig, (config) => ({ ...config, lazurioFolder: folder })), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-vcs-test-" }))), + ), + Layer.provide(NodeServices.layer), + ); + + yield* Effect.gen(function* () { + const provisioning = yield* VcsProvisioningService.VcsProvisioningService; + const refused = yield* provisioning.initRepository({ cwd: folder }).pipe(Effect.flip); + if (refused._tag !== "VcsUnsupportedOperationError") return assert.fail(refused._tag); + assert.strictEqual( + refused.detail, + "The Lazurio Folder holds separate repositories, so it cannot become a repository itself.", + ); + yield* provisioning.initRepository({ cwd: repository }); + assert.deepStrictEqual(calls, [`default:${repository}`]); + }).pipe(Effect.provide(testLayer)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 8f16c5b0b4fc0..0632a2345afcd 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -1,5 +1,4 @@ -import { ServerConfig } from "../config.ts"; -import * as FileSystem from "effect/FileSystem"; +import { makeFolderGuard } from "../lazurio/managedFolder.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -40,22 +39,18 @@ 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 lazurioFolderGuard = yield* makeFolderGuard; 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 holds separate repositories, so it cannot become a repository itself.", - }); - } + const lazurioRefusal = yield* lazurioFolderGuard.repositoryRefusal(input.cwd); + if (lazurioRefusal !== null) + return yield* new VcsUnsupportedOperationError({ + operation: "VcsProvisioningService.initRepository", + kind: input.kind ?? "git", + detail: lazurioRefusal, + }); 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 4c46bcb3ab1c6..369ac9eb7225a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2,6 +2,7 @@ import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, } from "@t3tools/shared/usageLimits"; +import { managedFolderClientConfig } from "./lazurio/managedFolder.ts"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -2091,10 +2092,7 @@ const makeWsRpcLayer = ( environment, auth, cwd: config.cwd, - ...(config.lazurioFolder === undefined ? {} : { lazurioFolder: config.lazurioFolder }), - ...(config.lazurioToolsUrl === undefined - ? {} - : { lazurioToolsUrl: config.lazurioToolsUrl }), + ...managedFolderClientConfig(config), 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 384803d40472d..cf6eb0276145c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ "use client"; +import { everyEnvironmentManaged, offeredInProjectPicker } from "../lazurio/managedFolder"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -972,22 +973,16 @@ function OpenCommandPaletteDialog(props: { buildSidebarProjectPickerEntries({ groups: projectGroups, preferredProjectRef: contextualProjectRef, - }), - [contextualProjectRef, projectGroups], + }).filter(offeredInProjectPicker(environments)), + [contextualProjectRef, environments, projectGroups], ); const pickerProjects = useMemo( () => - 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], + projectPickerEntries.map(({ group, targetProject }) => ({ + ...targetProject, + displayName: group.displayName, + })), + [projectPickerEntries], ); const projectGroupByTargetKey = useMemo( () => @@ -1002,11 +997,8 @@ function OpenCommandPaletteDialog(props: { const addProjectEnvironmentOptions = useMemo(() => { const options = environments - .filter( - (environment) => - environment.serverConfig?.lazurioFolder === undefined && - canCreateProjectInEnvironment(environment.connection.phase), - ) + .filter((environment) => canCreateProjectInEnvironment(environment.connection.phase)) + .filter((environment) => environment.serverConfig?.lazurioFolder === undefined) .map((environment): AddProjectEnvironmentOption => { const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; return { @@ -2066,36 +2058,37 @@ function OpenCommandPaletteDialog(props: { }); } - 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(); - }, - }); + const addProjectItem: CommandPaletteActionItem = { + 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(); + }, + }; + // Lazurio (LazurioPlatform F51): when every Environment is managed, none takes another project. + if (!everyEnvironmentManaged(environments)) actionItems.push(addProjectItem); if (wslAddProjectEnvironmentOption) { actionItems.push({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 175edf6e55b0f..154ade953ec70 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { everyEnvironmentManaged } from "../lazurio/managedFolder"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; @@ -4717,9 +4718,7 @@ export default function Sidebar() { } onNewProject={ - [...serverConfigs.values()].some((config) => config.lazurioFolder === undefined) - ? openAddProjectCommandPalette - : undefined + everyEnvironmentManaged(environments) ? undefined : openAddProjectCommandPalette } onNewThread={handleNewThreadClick} newThreadDisabled={projects.length === 0} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 884e32037ae16..451610d1e0118 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,3 +1,4 @@ +import { lazurioFolderOf } from "../../lazurio/managedFolder"; import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; @@ -225,104 +226,102 @@ export function DraftHeroHeadline({ return true; }; - 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 projectPicker = 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; } - > - - {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 : ( - + 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 ( + - {/* Boxed like ProjectFavicon so the label lines up with project rows. */} - - No project + + + }> + {group.displayName} + + {group.displayName} + + {showProjectEnvironments ? ( + + ) : null} - )} - {menuEntries.map(({ group }) => { - return ( - - - - - }> - {group.displayName} - - {group.displayName} - - {showProjectEnvironments ? ( - - ) : null} - - - ); - })} - - {projectPickerEntries.length > 0 ? : null} - - - Add project - - - - ) : ( - - ); + ); + })} + + {projectPickerEntries.length > 0 ? : null} + + + Add project + + + + ) : ( + + ); + // Lazurio (LazurioPlatform F51): new chats of a managed Environment start in its Folder, so the + // hero names the Lazurio project instead of offering a picker or "Add a project". + const managedFolder = lazurioFolderOf(environments, activeProjectRef?.environmentId); + const projectSelector = managedFolder === undefined ? projectPicker : Lazurio; // 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 1197e23fd64a7..55acfa4d879f7 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -1,4 +1,4 @@ -import { managedFirstRunDecision } from "../../lazurio/managedFolder"; +import { useManagedFirstRunDecision } from "../../lazurio/ManagedFolderWelcome"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; @@ -126,43 +126,34 @@ export function FirstRunGate({ threads, }); + const upstreamDecision = 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, + }); + // Lazurio (LazurioPlatform F51): a managed Environment decides its own first run. 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, - }); + useManagedFirstRunDecision(upstreamDecision); useEffect(() => { if (decision === "wizard" || !hydrated) return; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ee946518bef16..ba99b881bb6db 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,3 +1,4 @@ +import { managedNewThread } from "../lazurio/managedFolder"; import { useAtomValue } from "@effect/atom-react"; import { scopedProjectKey, @@ -78,21 +79,10 @@ 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, - }; - } + // Lazurio (LazurioPlatform F51): a managed Environment starts every thread in its Folder. + const managed = managedNewThread(environmentServerConfigs, projects, projectRef, options); + if (managed === null) return Promise.resolve(null); + ({ projectRef, options } = managed); const targetServerSettings = environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { @@ -164,7 +154,6 @@ 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/lazurio/LazurioEnvironmentBrowser.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx index 825bc59d449c2..b9f2cb002c450 100644 --- a/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx +++ b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx @@ -46,6 +46,7 @@ import { rememberTitle, showForeignTab, showOwnTab, + snapshotOf, syncOf, tabEnded, tabIdOfSurface, @@ -105,7 +106,7 @@ function watchClosedTabs(threadRef: ScopedThreadRef): () => void { const after = state.byThreadKey[key]; if (before === undefined || before === after) return; const sync = syncOf(threadRef); - const snapshot = useThreadTabsStore.getState().snapshots[key]; + const snapshot = snapshotOf(threadRef); const listed = new Set(snapshot?.tabs.map((tab) => tab.tabId) ?? []); const remaining = new Set((after?.surfaces ?? []).map((surface) => surface.id)); const closed = before.surfaces.filter((surface): surface is EnvironmentBrowserSurface => { diff --git a/apps/web/src/lazurio/LazurioNewTab.tsx b/apps/web/src/lazurio/LazurioNewTab.tsx index 8aa88239aa34b..53a7ff9e2d36d 100644 --- a/apps/web/src/lazurio/LazurioNewTab.tsx +++ b/apps/web/src/lazurio/LazurioNewTab.tsx @@ -26,12 +26,15 @@ import { EmptyMedia, EmptyTitle, } from "~/components/ui/empty"; -import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import { surfaceShortcutTargetsTypingContext } from "../components/RightPanelTabs"; +import { useRightPanelStore } from "../rightPanelStore"; import { chooseTab, type EnvironmentBrowserSurface, newTabSurface, + panelOf, prefersCzech, + snapshotOf, useThreadTabsStore, } from "./threadTabs"; @@ -126,15 +129,12 @@ export const useNewTabDefined = () => useNewTabStore((state) => state.defined); /** Upstream's actions as RightPanelTabs last rendered them: each runs in Nová karta's place. */ let publishedActions: ReadonlyArray = []; -const panelOf = (threadRef: ScopedThreadRef) => - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); - /** * Runs upstream's action for `id`. In a Nová karta tab (`newTabId`), the surface it opens takes * that tab's place; one it only brings to the front leaves Nová karta out; one that opens nothing * (a setup dialog) leaves Nová karta as it is. An id that is no published tool is ignored. */ -export function runTool(threadRef: ScopedThreadRef, newTabId: string | null, id: string): void { +function runTool(threadRef: ScopedThreadRef, newTabId: string | null, id: string): void { const action = publishedActions.find( (entry) => entry.available && entry.label !== "Browser" && toolId(entry.label) === id, ); @@ -159,7 +159,7 @@ export function runTool(threadRef: ScopedThreadRef, newTabId: string | null, id: } /** What Nová karta became: a page or tab on the thread's view origin, or a tool. */ -export function chooseInNewTab( +function chooseInNewTab( threadRef: ScopedThreadRef, newTabId: string | null, choice: NewTabChoice, @@ -168,7 +168,7 @@ export function chooseInNewTab( runTool(threadRef, newTabId, choice.id); return; } - const snapshot = useThreadTabsStore.getState().snapshots[scopedThreadKey(threadRef)]; + const snapshot = snapshotOf(threadRef); if (snapshot === undefined || snapshot === null) return; if (!isViewOf(choice.view, choice.tab, snapshot.viewOrigin)) return; chooseTab(threadRef, newTabId, choice.tab); @@ -239,12 +239,6 @@ const LETTER_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** Upstream's typing context (RightPanelTabs `surfaceShortcutTargetsTypingContext`). */ -const typingIn = (target: EventTarget | null) => - target instanceof Element && - target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== - null; - /** Nová karta for a thread: the element, its choices, and the tools' letters while it is shown and * the focus is in T3 Code's document (inside the frame, the page passes letters on itself). */ function NewTabView(props: { @@ -283,7 +277,7 @@ function NewTabView(props: { if ( tool === undefined || document.querySelector(LETTER_BLOCKING_LAYERS) || - typingIn(event.target) + (event.target instanceof Element && surfaceShortcutTargetsTypingContext(event.target)) ) { return; } diff --git a/apps/web/src/lazurio/LazurioPromptDraft.tsx b/apps/web/src/lazurio/LazurioPromptDraft.tsx index d5c3240789229..15348a885b5e9 100644 --- a/apps/web/src/lazurio/LazurioPromptDraft.tsx +++ b/apps/web/src/lazurio/LazurioPromptDraft.tsx @@ -30,12 +30,13 @@ const livePrimaryEnvironmentIdAtom = Atom.make((get) => { /** * Lazurio overlay (Lazurio/t3code#35): once the primary environment's shell * is live, opens the prompt a Lazurio link named in a new thread of the - * primary environment's project rooted at the prompt's folder, with the text - * in the composer and nothing sent. Renders nothing; without a link it does - * nothing at all. + * primary environment's project rooted at the prompt's folder (in a managed + * environment, its Lazurio Folder's project), with the text in the composer + * and nothing sent. Renders nothing; without a link it does nothing at all. */ export function LazurioPromptDraft() { const primaryEnvironmentId = useAtomValue(livePrimaryEnvironmentIdAtom); + const lazurioFolder = useAtomValue(primaryServerConfigAtom)?.lazurioFolder; const handleNewThread = useNewThreadHandler(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const started = useRef(false); @@ -72,6 +73,7 @@ export function LazurioPromptDraft() { await waitForProject(projectRef, 3_000).catch(() => null); return projectRef; }, + lazurioFolder, openThread: (projectRef) => handleNewThread(projectRef), setPrompt: (draftId, text) => useComposerDraftStore.getState().setPrompt(draftId, text), }).then((outcome) => { @@ -85,7 +87,7 @@ export function LazurioPromptDraft() { ); } }); - }, [createProject, handleNewThread, primaryEnvironmentId]); + }, [createProject, handleNewThread, lazurioFolder, primaryEnvironmentId]); return null; } diff --git a/apps/web/src/lazurio/ManagedFolderWelcome.tsx b/apps/web/src/lazurio/ManagedFolderWelcome.tsx index a9a25885972f7..2240a39f4ed52 100644 --- a/apps/web/src/lazurio/ManagedFolderWelcome.tsx +++ b/apps/web/src/lazurio/ManagedFolderWelcome.tsx @@ -1,22 +1,101 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { ScopedProjectRef } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { useState } from "react"; import { Button } from "../components/ui/button"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useClientSettings, useClientSettingsHydrationStatus } from "../hooks/useSettings"; import { useCompleteOnboarding } from "../onboarding/firstRun"; +import type { FirstRunDecision } from "../onboarding/firstRun.logic"; import { useProjects } from "../state/entities"; import { primaryServerConfigAtom } from "../state/server"; +import { environmentShell } from "../state/shell"; +import { managedFirstRunDecision } from "./managedFolder"; +import { prefersCzech } from "./threadTabs"; -export function ManagedFolderWelcome({ - onDone, -}: { - onDone: (project: ScopedProjectRef) => Promise; -}) { +/** The primary Environment's Lazurio Folder (LazurioPlatform F51), when it is managed. */ +export function usePrimaryLazurioFolder(): string | undefined { + return useAtomValue(primaryServerConfigAtom)?.lazurioFolder; +} + +const primaryShellLiveAtom = Atom.make((get) => { + const config = get(primaryServerConfigAtom); + return ( + config !== null && + get(environmentShell.stateValueAtom(config.environment.environmentId)).status === "live" + ); +}).pipe(Atom.withLabel("lazurio-managed-first-run-primary-shell-live")); + +type FirstRun = { readonly decision: FirstRunDecision; readonly persistCompletion: boolean }; + +/** + * FirstRunGate's decision, with a managed Environment's own first run in place of upstream's: + * the welcome below once the primary server's live shell has the Folder's project, and never a + * completion it did not ask the person for. Without a managed Folder, upstream's decision. + */ +export function useManagedFirstRunDecision(upstream: FirstRun): FirstRun { + const config = useAtomValue(primaryServerConfigAtom); + const live = useAtomValue(primaryShellLiveAtom); + const projects = useProjects(); + const hydrated = useClientSettingsHydrationStatus() === "ready"; + const completed = useClientSettings((settings) => settings.onboardingCompletedAt) !== null; + if (config?.lazurioFolder === undefined) return upstream; + const projectReady = projects.some( + (project) => + project.environmentId === config.environment.environmentId && + project.workspaceRoot === config.lazurioFolder, + ); + return { + decision: managedFirstRunDecision({ hydrated, live, projectReady, completed }), + persistCompletion: false, + }; +} + +/** The welcome's words: Czech where the person's browser is, with formal address (root decision + * 0199) and "Environment" for the place (root decision 0170), as the rest of the overlay. */ +const welcomeWords = (czech: boolean) => + czech + ? { + title: "Váš pracovní prostor je připravený", + folder: + "Každý nový chat začíná ve vašem Lazurio Folderu, který vašemu agentovi dává kontext tohoto Environmentu a Organizací, se kterými můžete pracovat.", + inside: "Vaše projekty a repozitáře jsou v tomto Folderu. Není potřeba nic importovat.", + connectBefore: "Účet agenta připojte v ", + connectLink: "nastavení Environmentu", + connectAfter: + " a pak se sem vraťte. Účet, který tam už je připojený, můžete rovnou použít.", + connected: "Použijte účet agenta připojený k tomuto Environmentu.", + failed: "Lazurio se nepodařilo otevřít. Zkuste to prosím znovu.", + opening: "Otevírám…", + start: "Začít v Lazuriu", + } + : { + title: "Your workspace is ready", + folder: + "Every new chat starts in your Lazurio Folder. It gives your agent the context for this Environment and the Organizations you can work with.", + inside: + "Your projects and repositories live inside this Folder. There is nothing to import.", + connectBefore: "Connect your agent account in ", + connectLink: "Environment settings", + connectAfter: ", then return here. An account already connected there is ready to use.", + connected: "Use the agent account connected to this Environment.", + failed: "Could not open Lazurio. Please try again.", + opening: "Opening…", + start: "Start in Lazurio", + }; + +/** + * The managed Environment's welcome, in place of upstream's setup wizard: it names the Lazurio + * Folder, completes onboarding and opens a new chat in the Folder's project. + */ +export function ManagedFolderWelcome() { const config = useAtomValue(primaryServerConfigAtom); const projects = useProjects(); const complete = useCompleteOnboarding(); + const openNewThread = useNewThreadHandler(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [words] = useState(() => welcomeWords(prefersCzech())); const project = projects.find( (p) => p.environmentId === config?.environment.environmentId && @@ -27,29 +106,24 @@ export function ManagedFolderWelcome({

Lazurio Environment

-

Your workspace is ready

+

{words.title}

-

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

+

{words.folder}

Lazurio

{config?.lazurioFolder}

-

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

+

{words.inside}

{config?.lazurioToolsUrl ? (

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

) : ( -

Use the agent account connected to this Environment.

+

{words.connected}

)} {error ? (

@@ -63,14 +137,18 @@ export function ManagedFolderWelcome({ setBusy(true); setError(null); void complete() - .then(() => onDone(scopeProjectRef(project.environmentId, project.id))) + .then(() => + openNewThread(scopeProjectRef(project.environmentId, project.id), { + replace: true, + }), + ) .catch(() => { - setError("Could not open Lazurio. Please try again."); + setError(words.failed); setBusy(false); }); }} > - {busy ? "Opening…" : "Start in Lazurio"} + {busy ? words.opening : words.start}

diff --git a/apps/web/src/lazurio/environmentBrowser.test.ts b/apps/web/src/lazurio/environmentBrowser.test.ts index ae75e4eb0624e..1c765bd090f6f 100644 --- a/apps/web/src/lazurio/environmentBrowser.test.ts +++ b/apps/web/src/lazurio/environmentBrowser.test.ts @@ -206,16 +206,13 @@ describe("a message of the framed view", () => { }); it("tells its tab's title, or else the page's host", () => { - const shown = { id: `environment-browser:${view.slice(-32)}`, view }; expect(readEnvironmentBrowserMessage(message(info), frame, view)).toEqual({ type: "info", title: "Docs", - tab: shown, }); expect(readEnvironmentBrowserMessage(message({ ...info, title: " " }), frame, view)).toEqual({ type: "info", title: "example.com", - tab: shown, }); expect( readEnvironmentBrowserMessage( @@ -223,27 +220,7 @@ describe("a message of the framed view", () => { frame, view, ), - ).toEqual({ type: "info", title: null, tab: shown }); - }); - - it("names the remote tab it shows, on the view's own origin only", () => { - // The view's new tab tells the tab it moved to. - expect( - readEnvironmentBrowserMessage(message({ ...info, view: tabView }), frame, `${viewOrigin}/`), - ).toMatchObject({ - type: "info", - tab: { id: `environment-browser:${targetId}`, view: tabView }, - }); - for (const named of [ - undefined, - `${viewOrigin}/`, - `https://browser.vm-02.example.lazurio.io/t/${targetId}`, - `${tabView}?x=1`, - ]) { - expect( - readEnvironmentBrowserMessage(message({ ...info, view: named }), frame, `${viewOrigin}/`), - ).toMatchObject({ type: "info", tab: null }); - } + ).toEqual({ type: "info", title: null }); }); it("opens a page's new tab on the view's own origin", () => { @@ -259,14 +236,11 @@ describe("a message of the framed view", () => { it("says that its tab ended, only from its own frame and the view's origin", () => { const closed = { type: "lazurio-browser:closed", view }; - expect(readEnvironmentBrowserMessage(message(closed), frame, view)).toEqual({ - type: "closed", - tab: { id: `environment-browser:${view.slice(-32)}`, view }, - }); + expect(readEnvironmentBrowserMessage(message(closed), frame, view)).toEqual({ type: "closed" }); // The view it names is only a hint: the frame that says it ended is the tab. expect( readEnvironmentBrowserMessage(message({ ...closed, view: "about:blank" }), frame, view), - ).toEqual({ type: "closed", tab: null }); + ).toEqual({ type: "closed" }); expect(readEnvironmentBrowserMessage(message(closed, { source: {} }), frame, view)).toBeNull(); expect( readEnvironmentBrowserMessage( diff --git a/apps/web/src/lazurio/environmentBrowser.ts b/apps/web/src/lazurio/environmentBrowser.ts index 5fd6c23a07014..c5d8892b10059 100644 --- a/apps/web/src/lazurio/environmentBrowser.ts +++ b/apps/web/src/lazurio/environmentBrowser.ts @@ -30,7 +30,8 @@ const TAB_VIEW_PATH = /^\/t\/([0-9A-Fa-f]{32})$/; * The panel tab whose view is at `view`, or null unless it is exactly * `https:///t/<32 hex digits>`: no credentials, query or fragment, not even an empty one, * so an address the panel keeps names one remote tab and carries nothing else. Where it frames - * the view, the panel also refuses its own origin (`pageOrigin`), as for the thread's own tab. + * the view, the panel also refuses its own origin (`pageOrigin`): the view is never framed on the + * page's origin. */ export function environmentBrowserTab( view: unknown, @@ -126,18 +127,16 @@ export function isPlainPrimaryClick( ); } -/** What the view in a panel frame says (LazurioPlatform F39, F47 point 6): its tab (title and - * view), a page's new tab, or that its tab ended. */ +/** What the view in a panel frame says (LazurioPlatform F39, F47 point 6): its tab's title, a + * page's new tab, or that its tab ended. The frame that says it is the tab it speaks of. */ export type EnvironmentBrowserMessage = | { readonly type: "info"; /** The page's title, else its host; null for a page with neither, such as a new tab. */ readonly title: string | null; - /** The remote tab the view shows, on the view's own origin; null when it names none. */ - readonly tab: EnvironmentBrowserTab | null; } | { readonly type: "new-tab"; readonly tab: EnvironmentBrowserTab } - | { readonly type: "closed"; readonly tab: EnvironmentBrowserTab | null }; + | { readonly type: "closed" }; /** * The message of the view that `frame` (the panel frame's window) shows at `view`, or null for @@ -157,25 +156,18 @@ export function readEnvironmentBrowserMessage( if (message.type === "lazurio-browser:info") { const { title, url } = message; if (typeof title !== "string" || typeof url !== "string") return null; - const tab = environmentBrowserTab(message.view); - return { - type: "info", - title: title.trim() || hostOf(url), - tab: tab !== null && originOf(tab.view) === origin ? tab : null, - }; + return { type: "info", title: title.trim() || hostOf(url) }; } if (message.type === "lazurio-browser:new-tab") { const tab = environmentBrowserTab(message.view); return tab !== null && originOf(tab.view) === origin ? { type: "new-tab", tab } : null; } - if (message.type === "lazurio-browser:closed") { - const tab = environmentBrowserTab(message.view); - return { type: "closed", tab: tab !== null && originOf(tab.view) === origin ? tab : null }; - } + if (message.type === "lazurio-browser:closed") return { type: "closed" }; return null; } -function originOf(url: string): string | null { +/** The origin of an address, or null when it is none. */ +export function originOf(url: string): string | null { try { return new URL(url).origin; } catch { diff --git a/apps/web/src/lazurio/managedFolder.test.ts b/apps/web/src/lazurio/managedFolder.test.ts index 9b9e4d3bf1b61..5a0cff7559216 100644 --- a/apps/web/src/lazurio/managedFolder.test.ts +++ b/apps/web/src/lazurio/managedFolder.test.ts @@ -1,25 +1,120 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { managedFirstRunDecision } from "./managedFolder"; +import { + everyEnvironmentManaged, + managedFirstRunDecision, + managedNewThread, + offeredInProjectPicker, +} from "./managedFolder"; + +const folder = "/home/person/Lazurio"; +const managedId = "environment-managed" as EnvironmentId; +const otherId = "environment-other" as EnvironmentId; +const unmanaged = { environmentId: otherId, serverConfig: {} }; +const managed = { environmentId: managedId, serverConfig: { lazurioFolder: folder } }; +const loading = { environmentId: otherId, serverConfig: null }; + +describe("Add project in a managed Environment", () => { + // The command palette and the sidebar show "Add project" exactly when this is false. + it("stays as upstream without T3CODE_LAZURIO_FOLDER, also with no Environment at all", () => { + expect(everyEnvironmentManaged([])).toBe(false); + expect(everyEnvironmentManaged([unmanaged])).toBe(false); + expect(everyEnvironmentManaged([unmanaged, unmanaged])).toBe(false); + expect(everyEnvironmentManaged([loading])).toBe(false); + }); + + it("leaves only when every known Environment is managed", () => { + expect(everyEnvironmentManaged([managed])).toBe(true); + expect(everyEnvironmentManaged([managed, managed])).toBe(true); + expect(everyEnvironmentManaged([managed, unmanaged])).toBe(false); + expect(everyEnvironmentManaged([unmanaged, managed])).toBe(false); + // Until its config arrives, an Environment is not known to be managed. + expect(everyEnvironmentManaged([managed, loading])).toBe(false); + }); +}); + +describe("the project pickers", () => { + const offered = offeredInProjectPicker([managed, unmanaged]); + const entry = (environmentId: EnvironmentId, workspaceRoot: string) => ({ + targetProject: { environmentId, workspaceRoot }, + }); + + it("offer of a managed Environment only its Folder's project", () => { + expect(offered(entry(managedId, folder))).toBe(true); + expect(offered(entry(managedId, "/home/person/older-repository"))).toBe(false); + }); + + it("offer every project of any other Environment, as upstream", () => { + expect(offered(entry(otherId, "/home/person/repository"))).toBe(true); + expect(offeredInProjectPicker([])(entry(managedId, "/anywhere"))).toBe(true); + }); +}); + +describe("a new thread", () => { + const folderProjectId = "project-folder" as ProjectId; + const projects = [ + { id: "project-old" as ProjectId, environmentId: managedId, workspaceRoot: "/old/repository" }, + { id: folderProjectId, environmentId: managedId, workspaceRoot: folder }, + ]; + const configs = new Map([ + [managedId, { lazurioFolder: folder }], + [otherId, {}], + ]); + const worktree = { + branch: "feature", + worktreePath: "/tmp/worktree", + envMode: "worktree" as const, + }; + + it("goes where the caller asked without a managed Folder", () => { + const request = scopeProjectRef(otherId, "project-other" as ProjectId); + expect(managedNewThread(configs, projects, request, worktree)).toEqual({ + projectRef: request, + options: worktree, + }); + }); + + it("starts in a managed Environment's Folder project, in the Folder itself", () => { + const request = scopeProjectRef(managedId, "project-old" as ProjectId); + expect(managedNewThread(configs, projects, request, { ...worktree, replace: true })).toEqual({ + projectRef: scopeProjectRef(managedId, folderProjectId), + options: { + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + replace: true, + }, + }); + }); + + it("waits while the Folder's project has not reached the web", () => { + const request = scopeProjectRef(managedId, "project-old" as ProjectId); + expect(managedNewThread(configs, projects.slice(0, 1), request, undefined)).toBeNull(); + }); +}); 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, + managedFirstRunDecision({ hydrated: true, live: false, projectReady: true, completed }), + ).toBe("pending"); + expect( + managedFirstRunDecision({ hydrated: true, live: true, projectReady: false, completed }), ).toBe("pending"); expect( - managedFirstRunDecision({ hydrated: true, live: true, projectReady: false, completed }) - .decision, + managedFirstRunDecision({ hydrated: false, live: true, projectReady: true, completed }), ).toBe("pending"); } }); - it("offers the Lazurio welcome once and then opens the app, never imports or silently completes", () => { + it("offers the Lazurio welcome once and then opens the app", () => { expect( managedFirstRunDecision({ hydrated: true, live: true, projectReady: true, completed: false }), - ).toEqual({ decision: "wizard", persistCompletion: false }); + ).toBe("wizard"); expect( managedFirstRunDecision({ hydrated: true, live: true, projectReady: true, completed: true }), - ).toEqual({ decision: "app", persistCompletion: false }); + ).toBe("app"); }); }); diff --git a/apps/web/src/lazurio/managedFolder.ts b/apps/web/src/lazurio/managedFolder.ts index 800c6c7113fdc..206d6d7ea08c3 100644 --- a/apps/web/src/lazurio/managedFolder.ts +++ b/apps/web/src/lazurio/managedFolder.ts @@ -1,18 +1,112 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId, ScopedProjectRef, ServerConfig } from "@t3tools/contracts"; + +import type { useNewThreadHandler } from "../hooks/useHandleNewThread"; import type { FirstRunDecision } from "../onboarding/firstRun.logic"; +/** + * Lazurio overlay, the managed Lazurio Folder (LazurioPlatform F51). An Environment whose server + * has `T3CODE_LAZURIO_FOLDER` is managed: its chats start in its Lazurio Folder, the one project it + * takes. Without the variable no Environment is managed and nothing here changes upstream's + * behaviour. + */ + +/** An Environment as the web knows it: its server's config, once that has arrived. */ +interface KnownEnvironment { + readonly environmentId: EnvironmentId; + readonly serverConfig: Pick | null; +} + +/** The Environment's Lazurio Folder; undefined when it is not managed or its config has not arrived. */ +export function lazurioFolderOf( + environments: ReadonlyArray, + environmentId: EnvironmentId | undefined, +): string | undefined { + return environments.find((environment) => environment.environmentId === environmentId) + ?.serverConfig?.lazurioFolder; +} + +/** + * Whether "Add project" leaves the command palette and the sidebar: only when the web knows at + * least one Environment and every one of them is managed. With none, upstream's "Add project" + * leads to Settings → Connections, so it stays. An Environment whose config has not arrived yet + * counts as not managed. + */ +export function everyEnvironmentManaged( + environments: ReadonlyArray>, +): boolean { + return ( + environments.length > 0 && + environments.every((environment) => environment.serverConfig?.lazurioFolder !== undefined) + ); +} + +/** + * Whether the command palette's project pickers offer a project: of a managed Environment only + * its Folder's own, the one where its new threads start; every other project as upstream. + */ +export const offeredInProjectPicker = + (environments: ReadonlyArray) => + (entry: { + readonly targetProject: { + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + }; + }): boolean => { + const folder = lazurioFolderOf(environments, entry.targetProject.environmentId); + return folder === undefined || entry.targetProject.workspaceRoot === folder; + }; + +type NewThreadOptions = Parameters>[1]; + +/** + * Where a new thread starts. In a managed Environment, whichever project the caller names: in the + * Folder's own project, in the Folder itself, without a branch or a worktree; null while that + * project has not reached the web. Without a managed Folder, the request as it was. + */ +export function managedNewThread( + serverConfigs: ReadonlyMap>, + projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + }>, + projectRef: ScopedProjectRef, + options: NewThreadOptions, +): { readonly projectRef: ScopedProjectRef; readonly options: NewThreadOptions } | null { + const folder = serverConfigs.get(projectRef.environmentId)?.lazurioFolder; + if (folder === undefined) return { projectRef, options }; + const project = projects.find( + (candidate) => + candidate.environmentId === projectRef.environmentId && candidate.workspaceRoot === folder, + ); + if (project === undefined) return null; + return { + projectRef: scopeProjectRef(project.environmentId, project.id), + options: { + ...options, + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }, + }; +} + +/** + * A managed Environment's first run: pending until settings hydrate and the primary server's live + * shell has the Folder's project, then its welcome until onboarding completes. It never completes + * onboarding by itself, whatever else the workspace holds. + */ 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, - }; +}): FirstRunDecision { + return !input.hydrated || !input.live || !input.projectReady + ? "pending" + : input.completed + ? "app" + : "wizard"; } diff --git a/apps/web/src/lazurio/promptDraft.test.ts b/apps/web/src/lazurio/promptDraft.test.ts index ea043dee4dba0..47bd9511e2f58 100644 --- a/apps/web/src/lazurio/promptDraft.test.ts +++ b/apps/web/src/lazurio/promptDraft.test.ts @@ -183,6 +183,17 @@ describe("the draft", () => { ]); }); + it("opens in a managed environment's Folder project whatever the cwd, and adds nothing", async () => { + const folder = "/home/operator/Lazurio"; + const { calls, dependencies: deps } = dependencies({ lazurioFolder: folder }); + await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); + expect(calls).toEqual([`find ${folder}`, "open", `prompt draft-1 ${document.text}`]); + + const missing = dependencies({ lazurioFolder: folder, findProject: () => null }); + await expect(openPromptDraft(link, missing.dependencies)).resolves.toBe("failed"); + expect(missing.calls).toEqual([]); + }); + it("asks again when another navigation overtook the new thread", async () => { let attempts = 0; const { calls, dependencies: deps } = dependencies({ diff --git a/apps/web/src/lazurio/promptDraft.ts b/apps/web/src/lazurio/promptDraft.ts index c013f896909e9..3f3a468d5d141 100644 --- a/apps/web/src/lazurio/promptDraft.ts +++ b/apps/web/src/lazurio/promptDraft.ts @@ -119,6 +119,12 @@ export interface PromptDraftDependencies { readonly findProject: (cwd: string) => ScopedProjectRef | null; /** Adds `cwd` as a project of the primary environment, as "Add project" does. */ readonly addProject: (cwd: string) => Promise; + /** + * The primary environment's Lazurio Folder when it is managed (LazurioPlatform F51). Every new + * thread there starts in the Folder, which takes no other project, so the prompt opens in the + * Folder's project whatever its `cwd`, and nothing is added. + */ + readonly lazurioFolder?: string | undefined; /** Opens a new thread draft in the project; null when another navigation overtook it. */ readonly openThread: (projectRef: ScopedProjectRef) => Promise<{ draftId: DraftId } | null>; readonly setPrompt: (draftId: DraftId, text: string) => void; @@ -128,7 +134,8 @@ const OPEN_ATTEMPTS = 3; /** * Opens the prompt a link named: a new thread in the project rooted at its - * folder (added as a project when there is none), with the text in the + * folder (added as a project when there is none), or in a managed + * environment in its Lazurio Folder's project, with the text in the * composer, unsent. */ export async function openPromptDraft( @@ -139,7 +146,9 @@ export async function openPromptDraft( if (prompt === null) return "failed"; try { const projectRef = - dependencies.findProject(prompt.cwd) ?? (await dependencies.addProject(prompt.cwd)); + dependencies.lazurioFolder === undefined + ? (dependencies.findProject(prompt.cwd) ?? (await dependencies.addProject(prompt.cwd))) + : dependencies.findProject(dependencies.lazurioFolder); if (projectRef === null) return "failed"; // The index route opens a draft of the latest project at the same time; // whichever navigation lands second makes the other one return null. diff --git a/apps/web/src/lazurio/threadTabs.test.ts b/apps/web/src/lazurio/threadTabs.test.ts index eb7d32d563e0d..bf1fc7e158a0e 100644 --- a/apps/web/src/lazurio/threadTabs.test.ts +++ b/apps/web/src/lazurio/threadTabs.test.ts @@ -337,6 +337,28 @@ describe("holds", () => { expect(panel().surfaces).toEqual([tab(1)]); }); + it("stop retrying a failure that does not pass after about a minute, keeping the tab as the device's own", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const warn = vi.spyOn(toastManager, "add"); + for (let attempt = 0; attempt < 10; attempt += 1) { + answers.push(answerWith({ error: "unavailable" }, 503)); + } + showOwnTab(threadA, id(1), true); + await settled(); + for (const pause of [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]) { + await vi.advanceTimersByTimeAsync(pause); + await settled(); + } + expect(posted).toHaveLength(7); + expect(syncOf(threadA).pending.size).toBe(0); + expect(syncOf(threadA).local.has(id(1))).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(120_000); + await settled(); + expect(posted).toHaveLength(7); + expect(panel().surfaces).toEqual([tab(1)]); + }); + it("do not retry a failed hold once × let go of the tab", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); answers.push( @@ -457,6 +479,31 @@ describe("closing in the strip and Vrátit", () => { expect(syncOf(threadA).ended.size).toBe(0); }); + it("a tab whose view ended leaves as upstream's × would, and the panel closes with the last one", () => { + const states: ThreadRightPanelState[] = [ + { isOpen: true, activeSurfaceId: tab(1).id, surfaces: [tab(1)] }, + { isOpen: true, activeSurfaceId: tab(1).id, surfaces: [files, tab(1), tab(2)] }, + { isOpen: true, activeSurfaceId: files.id, surfaces: [files, tab(1)] }, + { isOpen: false, activeSurfaceId: tab(1).id, surfaces: [tab(1), tab(2)] }, + ]; + for (const state of states) { + setPanel(state); + useRightPanelStore.getState().closeSurface(threadA, tab(1).id); + const upstream = panel(); + const revision = useRightPanelStore.getState().getUserActionRevision(threadA); + setPanel(state); + tabEnded(threadA, tab(1).id); + expect(panel()).toEqual(upstream); + // The view ended the tab: no choice of the person's, so upstream's later automatic + // panels still open. + expect(useRightPanelStore.getState().getUserActionRevision(threadA)).toBe(revision); + } + // The thread's last tab ended: the panel closes instead of staying open and empty. + setPanel({ isOpen: true, activeSurfaceId: tab(1).id, surfaces: [tab(1)] }); + tabEnded(threadA, tab(1).id); + expect(panel()).toEqual({ isOpen: false, activeSurfaceId: null, surfaces: [] }); + }); + it("says what becomes of the tabs, from the agent and the other holders", () => { const shared = { tabId: id(1), view: "", agent: false, alsoHeldBy: [] }; expect(closeToastText([{ ...shared, agent: true }], true)).toEqual({ diff --git a/apps/web/src/lazurio/threadTabs.ts b/apps/web/src/lazurio/threadTabs.ts index ecf08f34e382d..5d593571298a9 100644 --- a/apps/web/src/lazurio/threadTabs.ts +++ b/apps/web/src/lazurio/threadTabs.ts @@ -28,7 +28,11 @@ import { type ThreadRightPanelState, useRightPanelStore, } from "../rightPanelStore"; -import { environmentBrowserLink, foreignEnvironmentBrowserTab } from "./environmentBrowser"; +import { + environmentBrowserLink, + foreignEnvironmentBrowserTab, + originOf, +} from "./environmentBrowser"; export const THREAD_TABS_ROUTE = "/api/lazurio/thread-tabs"; @@ -71,13 +75,6 @@ const NEW_TAB_PREFIX = "environment-browser:new-tab:"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const originOf = (address: string) => { - try { - return new URL(address).origin; - } catch { - return null; - } -}; /** The panel surface of this Environment's remote tab: its id only, the address is the server's. */ export const tabSurfaceId = (tabId: string) => @@ -342,6 +339,7 @@ export function reloadSurface(surfaceId: string): void { })); } +/** The thread's last snapshot: undefined before the server's first answer, null while unavailable. */ export const snapshotOf = (threadRef: ScopedThreadRef | null) => threadRef === null ? undefined @@ -412,7 +410,7 @@ export type PostOutcome = | { readonly ok: false; readonly retry: boolean; readonly status: number | null }; /** POST hold or release on this page's own origin; a network failure or a 5xx may be retried. */ -export async function postThreadTab( +async function postThreadTab( action: "hold" | "release", threadId: string, tabId: string, @@ -441,7 +439,8 @@ const enqueue = (sync: ThreadTabsSync, work: () => Promise) => { }; const setSnapshot = (key: string, snapshot: ThreadTabsSnapshot | null) => useThreadTabsStore.setState((state) => ({ snapshots: { ...state.snapshots, [key]: snapshot } })); -const panelOf = (threadRef: ScopedThreadRef) => +/** The thread's right panel as the store has it now. */ +export const panelOf = (threadRef: ScopedThreadRef) => selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); const hasSurface = (threadRef: ScopedThreadRef, surfaceId: string) => panelOf(threadRef).surfaces.some((surface) => surface.id === surfaceId); @@ -462,14 +461,22 @@ function withSurface( : { ...current, surfaces, activeSurfaceId: current.activeSurfaceId ?? surface.id }; } -/** Removes a surface the way upstream closes one: the neighbour comes to the front. */ +/** + * Removes a surface exactly as upstream's × does (rightPanelStore `closeSurface`): the neighbour + * comes to the front, and the panel closes with its last surface. Upstream's action counts as the + * person's choice, which a tab that ended is not (it would hold back the panels upstream opens by + * itself after a turn), so the overlay applies the same update as an automatic one; a test holds + * the two equal. + */ function withoutSurface(current: ThreadRightPanelState, surfaceId: string): ThreadRightPanelState { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); - if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; + const isOpen = surfaces.length > 0 && current.isOpen; + if (current.activeSurfaceId !== surfaceId) return { ...current, isOpen, surfaces }; return { ...current, + isOpen, surfaces, activeSurfaceId: surfaces[Math.min(index, surfaces.length - 1)]?.id ?? null, }; @@ -584,10 +591,10 @@ function intend(sync: ThreadTabsSync, tab: string): () => boolean { /** * The thread holds `tabId` (a choice in Nová karta, a page's new tab, a link, Vrátit). Until the * POST answers, the tab is pending and the strip keeps it. A network failure or a 5xx is retried - * with a growing pause while the tab is in the strip; a refusal (the thread is gone, or has as many - * tabs as it may) leaves it as the device's own, which ends 30 s after its last viewer (F39). - * A later × of the tab replaces the hold: a retry still waiting does not go, and an answer still - * under way changes nothing. + * with a growing pause while the tab is in the strip, for about a minute; a refusal (the thread is + * gone, or has as many tabs as it may), or a failure that outlasts the retries, leaves it as the + * device's own, which ends 30 s after its last viewer (F39). A later × of the tab replaces the + * hold: a retry still waiting does not go, and an answer still under way changes nothing. */ export function holdTab(threadRef: ScopedThreadRef, tabId: string): void { const sync = syncOf(threadRef); @@ -608,13 +615,12 @@ export function holdTab(threadRef: ScopedThreadRef, tabId: string): void { return; } const inStrip = hasSurface(threadRef, tabSurfaceId(tab)); - if (outcome.retry && inStrip) { - const delay = RETRY_DELAYS_MS[Math.min(tries, RETRY_DELAYS_MS.length - 1)]; - window.setTimeout(() => attempt(tries + 1), delay); + if (outcome.retry && inStrip && tries < RETRY_DELAYS_MS.length) { + window.setTimeout(() => attempt(tries + 1), RETRY_DELAYS_MS[tries]); return; } sync.pending.delete(tab); - if (!inStrip || outcome.retry) return; + if (!inStrip) return; sync.local.add(tab); const czech = prefersCzech(); toastManager.add( @@ -713,7 +719,7 @@ export function letGoOfTabs(threadRef: ScopedThreadRef, closed: ClosedTabs): voi } /** Vrátit: the tabs go back where they were and the thread holds them again (a new record). */ -export function restoreTabs(threadRef: ScopedThreadRef, closed: ClosedTabs): void { +function restoreTabs(threadRef: ScopedThreadRef, closed: ClosedTabs): void { useRightPanelStore.getState().lazurio(threadRef, false, (current) => { const surfaces = [...current.surfaces]; for (const { surface, index } of [...closed.surfaces].sort((a, b) => a.index - b.index)) { diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx index f47735b50085b..bf06afee6e6be 100644 --- a/apps/web/src/routes/welcome.tsx +++ b/apps/web/src/routes/welcome.tsx @@ -1,6 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; -import { primaryServerConfigAtom } from "../state/server"; -import { ManagedFolderWelcome } from "../lazurio/ManagedFolderWelcome"; +import { ManagedFolderWelcome, usePrimaryLazurioFolder } from "../lazurio/ManagedFolderWelcome"; import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { EnvironmentId } from "@t3tools/contracts"; @@ -36,20 +34,15 @@ 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 }); - }} - /> - ); + // Lazurio (LazurioPlatform F51): a managed Environment has its own welcome, behind the same + // route guard as the wizard. + const lazurioFolder = usePrimaryLazurioFolder(); + if (lazurioFolder !== undefined) return isWelcomeRoute ? : null; return ( <> diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index 4919e476d5be1..38ef6da759bf7 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -133,10 +133,8 @@ Hostované proměnné a spravovaná složka platí, jen když jsou nastavené, a prohlížeč Environmentu a Nová karta se ukážou, jen když je Environment nabízí. Bez ohledu na to běží dvě věci: server pod launcherem kontroluje vydání svého kanálu (vypne to `T3CODE_UPDATE_CHECK_ENABLED=false`) a procesy poskytovatelů -dostávají `AGENT_BROWSER_SESSION`, které nic nepovoluje. Známou odchylku -spravované složky popisuje [Spravovaná složka -Lazuria](#spravovaná-složka-lazuria). Řádek odeberte, jakmile upstream nabídne -ekvivalent. Upstream soubory pod `apps/`, `packages/` a `scripts/` overlay mění +dostávají `AGENT_BROWSER_SESSION`, které nic nepovoluje. Řádek odeberte, jakmile +upstream nabídne ekvivalent. Upstream soubory pod `apps/`, `packages/` a `scripts/` overlay mění jen v přesných souborech z allowlistu `allowed_upstream_changes` v `lazurio-fork-ci.yml`. Allowlist je seskupený podle funkce, které soubor patří, a soubory, které mění víc funkcí, má v jedné skupině společných švů. Každý @@ -218,7 +216,8 @@ commit mění. 11. 02d8c3791, 5b4bff3b6, 09a34d82e. `apps/server/src/server.ts`\*, `apps/server/src/lazurio/`: `holders.ts`\* a jeho test\*, `overlay.ts`\*, `threadTabs.ts`\* a jeho test\*, `threadTabsRoute.ts` a jeho test\*, - `threadLifecycle.ts`\*, `environmentBrowser.ts`\* a jeho test\*. + `routeAuth.ts`\*, `threadLifecycle.ts`\*, `environmentBrowser.ts`\* a jeho + test\*. 12. c9ef2a1e7, 18043f639, 26e141cd0, a6915a2eb. `apps/server/src/lazurio/`: `threadLifecycle.ts`\* a jeho test, `holders.ts`\* a jeho test\*, `threadTabs.ts`\* a jeho test\*, `threadTabsRoute.test.ts`\*, `overlay.ts`\*. @@ -229,17 +228,15 @@ commit mění. 14. 8b3d9c1e2. `apps/web/src/components/ChatView.tsx`\*, `RightPanelTabs.tsx`\*, `apps/web/src/rightPanelStore.ts`\* a jeho test, `apps/web/src/routes/_chat.pull-requests.tsx`. -15. 8b0e53424, b64117698. Server: `bin.ts`, `cli/config.ts`\*, `cli/lazurio.ts`, - `config.ts`\*, `http.ts`, `orchestration/Layers/OrchestrationEngine.ts` a - jeho test, `orchestration/Layers/ProviderCommandReactor.ts`, - `serverRuntimeStartup.ts`, `serverSettings.ts` a jeho test, - `vcs/VcsProvisioningService.ts` a jeho test, `ws.ts`; jednořádkové fixtury - v `device/DeviceService.test.ts`, `orchestration/ThreadSettlementReactor.test.ts`, - `provider/Layers/ProviderRegistry.test.ts`, - `provider/makeManagedServerProvider.test.ts` a `terminal/Manager.test.ts`; - `apps/server/src/lazurio/`: `managedFolder.ts`, `managedFolderStartup.ts`, - `modelRecommendation.ts`, `modelRecommendationRoute.ts` a jeho test, - `overlay.ts`\*. Web: `components/CommandPalette.tsx`, +15. 8b0e53424, b64117698. Server: `bin.ts`, `cli/config.ts`\*, `config.ts`\*, + `orchestration/Layers/OrchestrationEngine.ts` a jeho test, + `orchestration/Layers/ProviderCommandReactor.ts` a jeho test, + `serverRuntimeStartup.ts`, `serverSettings.ts`, + `vcs/VcsProvisioningService.ts` a jeho test, `ws.ts`; + `apps/server/src/lazurio/`: `cli.ts`, `managedFolder.ts`, + `managedFolderConfig.ts`, `modelRecommendation.ts` a + `modelRecommendationRoute.ts`, každý s testem, `managedFolderStartup.ts`, + `routeAuth.ts`\*, `overlay.ts`\*. Web: `components/CommandPalette.tsx`, `components/GitActionsControl.tsx`, `components/Sidebar.tsx`, `components/chat/DraftHeroHeadline.tsx`, `components/onboarding/FirstRunGate.tsx`, @@ -247,7 +244,7 @@ commit mění. a jeho test, `hooks/useScratchProject.ts`, `routes/welcome.tsx`; `apps/web/src/lazurio/`: `ManagedFolderWelcome.tsx`, `managedFolder.ts` a jeho test. `packages/contracts/src/server.ts`, - `packages/contracts/src/settings.ts`, `packages/shared/src/serverSettings.ts`. + `packages/contracts/src/settings.ts`. ### Lazurio shell @@ -348,8 +345,11 @@ se nikdy neodešle samo; odešle ho až člověk. neprázdným `text` do 16 KiB a absolutním `cwd`. - Otevře nové vlákno v projektu primárního environmentu s kořenem `cwd`. Když takový projekt není, přidá složku jako projekt, jako to dělá „Add - project“, ale bez zakládání složky. Text vloží do pole zprávy, nic - neodešle. + project“, ale bez zakládání složky. Ve spravovaném Environmentu + (`T3CODE_LAZURIO_FOLDER`) otevře vlákno vždy v projektu Lazurio a nic + nepřidává: nová vlákna tam začínají ve složce Lazuria a jiný projekt server + odmítne. `cwd` zadání Organizace leží uvnitř složky a text Organizaci + jmenuje. Text vloží do pole zprávy, nic neodešle. - Neznámé `id`, neúspěšný nebo přesměrovaný fetch, jiný tvar odpovědi nebo cizí origin nevloží nic. Člověk uvidí jen chybový toast „Could not open the prepared prompt“. @@ -410,7 +410,8 @@ Lazuria jako výchozí kontext Chatu. Server při startu, ještě před připrav převezme nebo založí její nativní projekt. Ostatní projekty a konverzace zůstávají jako historie; nová vlákna začínají v kořeni složky. Nejde o sandbox souborového systému. `T3CODE_LAZURIO_TOOLS_URL` odkáže spravované uvítání na -stránku Environmentu, kde se přihlašují agenti. +stránku Environmentu, kde se přihlašují agenti. Uvítání mluví česky (s vykáním), +má-li prohlížeč češtinu, jinak anglicky, jako ostatní texty overlaye. Machines ověří `t3 lazurio capabilities` (kontrakt verze 1) a po zřízení složky spustí `lazurio chat defaults --apply` Platformy. Doporučení vlastní Platforma; @@ -419,14 +420,19 @@ služba nastavení T3 atomicky zachová výslovnou volbu uživatele. srovnává jen přes běžící službu. Offline úprava nastavení nebo databáze nikdy není náhradní cestou nasazení. T3 a Platformu vydávejte spolu. -Bez proměnné se chování nemá měnit. Známá odchylka: paleta příkazů skryje „Add -project“, když ho žádný Environment nepřijme, a sidebar skryje tlačítko nového -projektu, dokud nemá konfiguraci žádného serveru. Upstream obojí ukazuje a z -palety vede do Settings → Connections. +Bez proměnné se chování nemění. „Add project“ zmizí z palety příkazů i ze +sidebaru, jen když klient zná aspoň jeden Environment a každý z nich je +spravovaný. Bez jakéhokoli Environmentu zůstává a z palety vede do Settings → +Connections jako v upstreamu. + +Kód spravované složky žije ve složkách overlaye (`apps/*/src/lazurio/`); +upstream soubory nesou jen háčky o několika řádcích. Kontraktní test je hlídá +(„the managed Lazurio Folder keeps its seams“) a `apps/server/src/lazurio/cli.test.ts` +drží výstup `t3 lazurio capabilities` a `t3 lazurio model` bajt po bajtu. Při přestavbě na nový upstream zachovejte testy spravovaného startu, nativní -stráže příkazů a spravovaného uvítání; samotný výběr modelu je záměrně mimo -fork. +stráže příkazů, stráže tahů agenta a spravovaného uvítání; samotný výběr +modelu je záměrně mimo fork. ## Kdy vydávat @@ -514,8 +520,9 @@ změny. ## Testování před vydáním 1. **CI.** `Lazurio Fork CI` na PR i na `main` musí být zelené. Běží v něm: - - hlídání upstream souborů (allowlist), kontrola formátu, typecheck serveru - a webu a kontraktní test; + - hlídání upstream souborů (allowlist), kontrola formátu, lint celého + repozitáře upstreamovým skriptem a pravidly (`pnpm run lint`), typecheck + serveru a webu a kontraktní test; - testy všeho, co overlay mění: každý testovací soubor, který overlay přidá nebo změní, a upstream test vedle každého souboru, který mění (`X.ts` → `X.test.ts`), každý balíček svým vlastním testovacím skriptem. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6a429a65734b1..e16ae4db31337 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1514,7 +1514,6 @@ 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 823c9f549a9b1..d847cb2b0795f 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -11,7 +11,6 @@ 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"; @@ -263,16 +262,6 @@ 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 5f6a8c2f4eb2f..b1ef8eeb97395 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -194,6 +194,8 @@ NodeTest.test("archives are built with upstream's own release steps", () => { NodeTest.test("CI is read-only and pins the exact upstream base", () => { NodeAssert.match(ci, /^permissions:\n {2}contents: read\n {2}id-token: none/m); NodeAssert.match(ci, /name: Server and web compatibility/); + // The whole repository is linted with upstream's own script and rules (Lazurio/t3code#63). + NodeAssert.match(ci, /\n {8}run: pnpm run lint\n/); NodeAssert.match(ci, /UPSTREAM_TAG: v0\.0\.45/); NodeAssert.match(ci, /UPSTREAM_SHA: 6c8fed35dded9ff71c5b46807125457acbb76be6/); const upstreamVersion = /UPSTREAM_TAG: v(\S+)/.exec(ci)?.[1]; @@ -420,6 +422,138 @@ NodeTest.test("the prompt hand-off overlay keeps its seam and never sends", asyn } }); +// LazurioPlatform F51: with T3CODE_LAZURIO_FOLDER, Chat starts in the Environment's Lazurio Folder. +// Its code lives in apps/*/src/lazurio/; the upstream files keep only the hooks below, each a few +// lines, so a rebuild on a new upstream tag that loses or moves one fails here. Without the +// variable each hook leaves upstream's behaviour as it is (the overlay's own tests prove that). +NodeTest.test("the managed Lazurio Folder keeps its seams", async () => { + const files = { + bin: "apps/server/src/bin.ts", + cliConfig: "apps/server/src/cli/config.ts", + engine: "apps/server/src/orchestration/Layers/OrchestrationEngine.ts", + reactor: "apps/server/src/orchestration/Layers/ProviderCommandReactor.ts", + vcs: "apps/server/src/vcs/VcsProvisioningService.ts", + startup: "apps/server/src/serverRuntimeStartup.ts", + settings: "apps/server/src/serverSettings.ts", + ws: "apps/server/src/ws.ts", + contractsServer: "packages/contracts/src/server.ts", + contractsSettings: "packages/contracts/src/settings.ts", + palette: "apps/web/src/components/CommandPalette.tsx", + sidebar: "apps/web/src/components/Sidebar.tsx", + sidebarHeader: "apps/web/src/components/sidebar/SidebarThreadHeader.tsx", + hero: "apps/web/src/components/chat/DraftHeroHeadline.tsx", + firstRun: "apps/web/src/components/onboarding/FirstRunGate.tsx", + newThread: "apps/web/src/hooks/useHandleNewThread.ts", + scratch: "apps/web/src/hooks/useScratchProject.ts", + gitActions: "apps/web/src/components/GitActionsControl.tsx", + welcome: "apps/web/src/routes/welcome.tsx", + }; + const source = Object.fromEntries( + await Promise.all(Object.entries(files).map(async ([key, file]) => [key, await read(file)])), + ); + const before = (text, first, second, message) => { + const at = text.indexOf(first); + NodeAssert.ok(at >= 0 && at < text.indexOf(second), message); + }; + + // Server: `t3 lazurio`, the config, the startup adoption and the guards. + NodeAssert.match(source.bin, /import \{ lazurioCommand \} from "\.\/lazurio\/cli\.ts";/); + NodeAssert.match(source.bin, /\n\s+lazurioCommand,\n/); + before( + source.cliConfig, + "const managedFolder = yield* resolveManagedFolderConfig;", + "ServerConfig.deriveServerPaths(baseDir, devUrl", + "the Folder is checked before the server's directories exist", + ); + NodeAssert.match(source.cliConfig, /\n\s+\.\.\.managedFolder,\n\s+\};\n\n\s+return config;/); + NodeAssert.match(source.engine, /const lazurioCommandGuard = yield\* makeCommandGuard;/); + NodeAssert.match( + source.engine, + /yield\* lazurioCommandGuard\(envelope\.command, commandReadModel\);\n\s+const eventBase = yield\* decideOrchestrationCommand\(/, + ); + NodeAssert.match(source.reactor, /const lazurioFolderGuard = yield\* makeFolderGuard;/); + before( + source.reactor, + "yield* lazurioFolderGuard.turnRefusal(effectiveCwd);", + ".startSession(threadId, {", + "an agent turn outside the Folder is refused before a session starts", + ); + NodeAssert.match(source.vcs, /const lazurioFolderGuard = yield\* makeFolderGuard;/); + before( + source.vcs, + "yield* lazurioFolderGuard.repositoryRefusal(input.cwd);", + "driver.initRepository(input)", + "the Folder is refused before any driver initializes a repository", + ); + NodeAssert.match( + source.startup, + /runStartupPhase\(\s*"lazurio\.project",\s*ensureManagedFolderProject\./, + ); + NodeAssert.match(source.startup, /\.\.\.welcomeBase,\n\s+\.\.\.managedWelcome,\n/); + NodeAssert.doesNotMatch(source.startup, /lazurioFolder/); + NodeAssert.match(source.settings, /readonly reconcileRecommendedModel\?: \(/); + NodeAssert.match(source.settings, /\n\s+apply = applyServerSettingsPatch,\n/); + NodeAssert.match(source.settings, /const updated = apply\(current, patch\);/); + NodeAssert.match(source.ws, /\n\s+\.\.\.managedFolderClientConfig\(config\),\n/); + NodeAssert.match( + source.contractsServer, + /lazurioFolder: Schema\.optionalKey\(TrimmedNonEmptyString\),/, + ); + NodeAssert.match( + source.contractsSettings, + /lazurioManagedModelSelection: Schema\.NullOr\(ModelSelection\)/, + ); + NodeAssert.doesNotMatch(source.contractsSettings, /Compare-and-set/); + + // Web: new threads start in the Folder, what leads elsewhere leaves only for a managed + // Environment, and the managed welcome sits behind upstream's route guard. + NodeAssert.match( + source.palette, + /\}\)\.filter\(offeredInProjectPicker\(environments\)\),\n\s+\[contextualProjectRef, environments, projectGroups\],/, + ); + NodeAssert.match( + source.palette, + /\n\s+\.filter\(\(environment\) => environment\.serverConfig\?\.lazurioFolder === undefined\)\n/, + ); + NodeAssert.match( + source.palette, + /if \(!everyEnvironmentManaged\(environments\)\) actionItems\.push\(addProjectItem\);/, + ); + NodeAssert.match( + source.sidebar, + /onNewProject=\{\s*everyEnvironmentManaged\(environments\) \? undefined : openAddProjectCommandPalette\s*\}/, + ); + NodeAssert.match(source.sidebarHeader, /\{onNewProject \? \(/); + NodeAssert.match( + source.hero, + /const projectSelector = managedFolder === undefined \? projectPicker : Lazurio<\/span>;/, + ); + NodeAssert.match( + source.firstRun, + /const \{ decision: nextDecision, persistCompletion \} =\s*useManagedFirstRunDecision\(upstreamDecision\);/, + ); + NodeAssert.match( + source.newThread, + /const projects = readProjects\(\);\n(?:\s*\/\/[^\n]*\n)?\s*const managed = managedNewThread\(environmentServerConfigs, projects, projectRef, options\);\n\s*if \(managed === null\) return Promise\.resolve\(null\);\n\s*\(\{ projectRef, options \} = managed\);/, + ); + NodeAssert.match(source.scratch, /environment\.serverConfig\?\.lazurioFolder === undefined/); + NodeAssert.match( + source.gitActions, + /if \(!gitCwd \|\| serverConfig\?\.lazurioFolder === gitCwd\) return null;/, + ); + before( + source.welcome, + "const isWelcomeRoute = useLocation(", + "if (lazurioFolder !== undefined) return isWelcomeRoute ? : null;", + "the managed welcome follows upstream's welcome-route guard", + ); + + // Every file is the overlay's own or a reviewed allowlist entry. + for (const file of Object.values(files)) { + NodeAssert.ok(overlayMayChange(file), file); + } +}); + NodeTest.test("the Lazurio shell slot survives a rebuild on a new upstream tag", async () => { const [page, layout, sidebarUi] = await Promise.all([ read("apps/web/index.html"), @@ -741,17 +875,26 @@ NodeTest.test("the panel opens on an agent's tab through its seams", async () => // them through an overlay route with its own prefix, authenticated like T3 Code's routes, with // upstream's preview scopes, its event stream never compressed or buffered. NodeTest.test("a thread's tabs live in its holder file behind an overlay route", async () => { - const [route, service, holders, overlay] = await Promise.all( + const [route, service, holders, overlay, routeAuth] = await Promise.all( [ "apps/server/src/lazurio/threadTabsRoute.ts", "apps/server/src/lazurio/threadTabs.ts", "apps/server/src/lazurio/holders.ts", "apps/server/src/lazurio/overlay.ts", + "apps/server/src/lazurio/routeAuth.ts", ].map(read), ); NodeAssert.match(route, /export const THREAD_TABS_ROUTE = "\/api\/lazurio\/thread-tabs";/); - NodeAssert.match(route, /serverAuth\s*\.authenticateHttpRequest\(request\)/); - NodeAssert.doesNotMatch(route, /authenticateWebSocketUpgrade|authenticateMediaRequest/); + // The overlay's routes authenticate as upstream's HTTP routes do, through one helper. + NodeAssert.match( + route, + /import \{ authenticateOverlayRoute as authenticate \} from "\.\/routeAuth\.ts";/, + ); + NodeAssert.match(routeAuth, /serverAuth\.authenticateHttpRequest\(request\)/); + NodeAssert.match(routeAuth, /if \(!session\.scopes\.includes\(scope\)\)/); + for (const source of [route, routeAuth]) { + NodeAssert.doesNotMatch(source, /authenticateWebSocketUpgrade|authenticateMediaRequest/); + } NodeAssert.match(route, /read: requiredScopeForRpcMethod\(WS_METHODS\.previewList\),/); NodeAssert.match(route, /hold: requiredScopeForRpcMethod\(WS_METHODS\.previewOpen\),/); NodeAssert.match(route, /release: requiredScopeForRpcMethod\(WS_METHODS\.previewClose\),/); @@ -840,7 +983,7 @@ NodeTest.test("T3's browser tools reach the Environment browser through their se // Plan DEV-6646: another Lazurio Environment's view of a tab, where an agent works over SSH. The // server's Environment browser host answers preview_open of such a view without loading it, and // the web opens it as a marked tab of the right panel. Both read a view's address with one -// function, which each holds a copy of, as with the session name. +// function, which each holds a copy of. NodeTest.test("the server and the web read a Lazurio browser view's address alike", async () => { const [server, web] = await Promise.all([ import("../apps/server/src/lazurio/browserView.ts"),