From 3a75eef699f8298f9fcf4be0ff2bb8cad06b87cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:31:53 +0200 Subject: [PATCH 01/14] fix(web): "Add project" stays unless every Environment is managed The managed Folder hid "Add project" in the command palette whenever no Environment could take a project, and the sidebar hid its button unless some server config was unmanaged. Both fired without T3CODE_LAZURIO_FOLDER: with no Environment, or none connected, upstream shows "Add project" and the palette leads to Settings -> Connections, and the fork showed nothing, so the one way to connect an Environment from there was gone. Both now ask one overlay helper, everyEnvironmentManaged: "Add project" leaves only when the web knows at least one Environment and every one of them is managed; an Environment whose config has not arrived yet counts as not managed. Without the variable nothing is managed, so the palette and the sidebar behave as upstream again, including with no Environment. A managed Environment still takes no other project: it stays out of the palette's list of Environments to add one to. The helper's tests cover no Environment, unmanaged, loading and mixed lists. The fork runbook no longer lists this as a known deviation. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/CommandPalette.tsx | 3 ++- apps/web/src/components/Sidebar.tsx | 5 ++-- apps/web/src/lazurio/managedFolder.test.ts | 25 +++++++++++++++++++- apps/web/src/lazurio/managedFolder.ts | 27 ++++++++++++++++++++++ docs/operations/lazurio-fork-release.md | 14 +++++------ 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 384803d40472..bfdcfc865cb2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ "use client"; +import { everyEnvironmentManaged } from "../lazurio/managedFolder"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -2066,7 +2067,7 @@ function OpenCommandPaletteDialog(props: { }); } - if (addProjectEnvironmentOptions.length > 0) + if (!everyEnvironmentManaged(environments)) actionItems.push({ kind: "action", value: "action:add-project", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 175edf6e55b0..154ade953ec7 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/lazurio/managedFolder.test.ts b/apps/web/src/lazurio/managedFolder.test.ts index 9b9e4d3bf1b6..7b78ac65e044 100644 --- a/apps/web/src/lazurio/managedFolder.test.ts +++ b/apps/web/src/lazurio/managedFolder.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it } from "vite-plus/test"; -import { managedFirstRunDecision } from "./managedFolder"; +import { everyEnvironmentManaged, managedFirstRunDecision } from "./managedFolder"; + +const unmanaged = { serverConfig: {} }; +const managed = { serverConfig: { lazurioFolder: "/home/person/Lazurio" } }; +const loading = { 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("managed first run", () => { it("waits for live server evidence of the native Folder project, even with saved onboarding", () => { diff --git a/apps/web/src/lazurio/managedFolder.ts b/apps/web/src/lazurio/managedFolder.ts index 800c6c7113fd..12d49ea6aec9 100644 --- a/apps/web/src/lazurio/managedFolder.ts +++ b/apps/web/src/lazurio/managedFolder.ts @@ -1,5 +1,32 @@ +import type { ServerConfig } from "@t3tools/contracts"; + 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 serverConfig: Pick | null; +} + +/** + * 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) + ); +} + export function managedFirstRunDecision(input: { hydrated: boolean; live: boolean; diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index 4919e476d5be..a4c2eea047f2 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ý @@ -419,10 +417,10 @@ 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. 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 From 2859a136f9c1345403890ac1e34309ad8be3cfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:33:12 +0200 Subject: [PATCH 02/14] fix(web): the managed welcome shows only on the welcome route, like the wizard Upstream's /welcome renders its setup wizard only while the location is still /welcome: the root shell can remount the route's pending outlet after the location changes, and setup must not reopen while the destination route loads. The managed Folder returned its own welcome before that guard, so after "Start in Lazurio" the remounted outlet could show the welcome again, with a fresh, enabled button, until the new chat's route loaded. The managed welcome now sits behind the same guard: on /welcome it shows, during a pending navigation away the outlet renders nothing. It opens the new chat in the Folder's project itself, so the route's seam is one import and two lines instead of a separate early return with its own config read and callback. What the welcome says and does is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/ManagedFolderWelcome.tsx | 24 +++++++++++++------ apps/web/src/routes/welcome.tsx | 17 ++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/web/src/lazurio/ManagedFolderWelcome.tsx b/apps/web/src/lazurio/ManagedFolderWelcome.tsx index a9a25885972f..cb9b193a6eea 100644 --- a/apps/web/src/lazurio/ManagedFolderWelcome.tsx +++ b/apps/web/src/lazurio/ManagedFolderWelcome.tsx @@ -1,20 +1,26 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { ScopedProjectRef } from "@t3tools/contracts"; import { useState } from "react"; import { Button } from "../components/ui/button"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useCompleteOnboarding } from "../onboarding/firstRun"; import { useProjects } from "../state/entities"; import { primaryServerConfigAtom } from "../state/server"; -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; +} + +/** + * 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 project = projects.find( @@ -63,7 +69,11 @@ 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."); setBusy(false); diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx index f47735b50085..bf06afee6e6b 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 ( <> From fd8bc0baa3a700aab38d7e529a3899f76b4a5664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:34:20 +0200 Subject: [PATCH 03/14] fix(web): a prepared prompt opens in a managed Environment's Lazurio project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Launchpad's "+ Nový modul" prompt names the Organization's root inside the Lazurio Folder (/organizations/) as its cwd. The prompt overlay opens the project rooted there and adds one when there is none, but a managed Environment (T3CODE_LAZURIO_FOLDER) refuses any project other than the Folder itself, so the prompt failed with "Could not open the prepared prompt" wherever the Folder was managed. Only a prompt whose cwd was the Folder root, or an Organization that already had a project from before, got through. In a managed Environment the prompt now opens in the Folder's own project, where every new thread there starts anyway, and nothing is added; the text names the Organization. Without the variable nothing changes: the project rooted at cwd, added when missing. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/LazurioPromptDraft.tsx | 10 ++++++---- apps/web/src/lazurio/promptDraft.test.ts | 11 +++++++++++ apps/web/src/lazurio/promptDraft.ts | 13 +++++++++++-- docs/operations/lazurio-fork-release.md | 7 +++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lazurio/LazurioPromptDraft.tsx b/apps/web/src/lazurio/LazurioPromptDraft.tsx index d5c324078922..15348a885b5e 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/promptDraft.test.ts b/apps/web/src/lazurio/promptDraft.test.ts index ea043dee4dba..47bd9511e2f5 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 c013f896909e..3f3a468d5d14 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/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index a4c2eea047f2..c51330ca7062 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -346,8 +346,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“. From b7ad0e525a29a91f383fdc27cb4b92374a420d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:38:35 +0200 Subject: [PATCH 04/14] refactor(web): the managed Folder's web seams shrink to their real lines The managed Folder's web seams wrapped upstream code in new conditionals, which re-indented whole blocks: 182 changed lines in DraftHeroHeadline.tsx for 8 real ones, 81 in CommandPalette.tsx, 63 in FirstRunGate.tsx. Each of them is a conflict waiting at the next upstream rebuild. Upstream's code now stays in place and the overlay decides beside it, through helpers in apps/web/src/lazurio/: - DraftHeroHeadline.tsx: upstream's picker keeps its block under a new name; one line picks "Lazurio" instead for a managed Environment (7 changed lines). - CommandPalette.tsx: the project pickers filter their entries with offeredInProjectPicker, the Environments to add a project to drop managed ones with one more filter line, and the "Add project" item is built as a const and pushed unless everyEnvironmentManaged (12 changed lines). - FirstRunGate.tsx: upstream's decision is computed as before and useManagedFirstRunDecision puts the managed first run in its place (6). - useHandleNewThread.ts: one call to managedNewThread moves a new thread to the Folder's project and workspace (5); its second branch inside resolveDefaultEnvMode was unreachable, as the managed options always carry an explicit env mode, and is gone. managedFirstRunDecision returns only the decision: a managed first run never persists completion, which the hook says once instead of a field that was always false. The helpers have tests; behaviour does not change. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/CommandPalette.tsx | 90 ++++----- .../src/components/chat/DraftHeroHeadline.tsx | 187 +++++++++--------- .../components/onboarding/FirstRunGate.tsx | 65 +++--- apps/web/src/hooks/useHandleNewThread.ts | 21 +- apps/web/src/lazurio/ManagedFolderWelcome.tsx | 38 ++++ apps/web/src/lazurio/managedFolder.test.ts | 94 +++++++-- apps/web/src/lazurio/managedFolder.ts | 91 +++++++-- 7 files changed, 367 insertions(+), 219 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index bfdcfc865cb2..cf6eb0276145 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ "use client"; -import { everyEnvironmentManaged } from "../lazurio/managedFolder"; +import { everyEnvironmentManaged, offeredInProjectPicker } from "../lazurio/managedFolder"; import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -973,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( () => @@ -1003,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 { @@ -2067,36 +2058,37 @@ function OpenCommandPaletteDialog(props: { }); } - if (!everyEnvironmentManaged(environments)) - 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/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 884e32037ae1..451610d1e011 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 1197e23fd64a..55acfa4d879f 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 ee946518bef1..ba99b881bb6d 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/ManagedFolderWelcome.tsx b/apps/web/src/lazurio/ManagedFolderWelcome.tsx index cb9b193a6eea..1dd536353266 100644 --- a/apps/web/src/lazurio/ManagedFolderWelcome.tsx +++ b/apps/web/src/lazurio/ManagedFolderWelcome.tsx @@ -1,17 +1,55 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +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"; /** 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 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. diff --git a/apps/web/src/lazurio/managedFolder.test.ts b/apps/web/src/lazurio/managedFolder.test.ts index 7b78ac65e044..5a0cff755921 100644 --- a/apps/web/src/lazurio/managedFolder.test.ts +++ b/apps/web/src/lazurio/managedFolder.test.ts @@ -1,9 +1,19 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { everyEnvironmentManaged, managedFirstRunDecision } from "./managedFolder"; +import { + everyEnvironmentManaged, + managedFirstRunDecision, + managedNewThread, + offeredInProjectPicker, +} from "./managedFolder"; -const unmanaged = { serverConfig: {} }; -const managed = { serverConfig: { lazurioFolder: "/home/person/Lazurio" } }; -const loading = { serverConfig: null }; +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. @@ -24,25 +34,87 @@ describe("Add project in a managed Environment", () => { }); }); +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 12d49ea6aec9..206d6d7ea08c 100644 --- a/apps/web/src/lazurio/managedFolder.ts +++ b/apps/web/src/lazurio/managedFolder.ts @@ -1,5 +1,7 @@ -import type { ServerConfig } from "@t3tools/contracts"; +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"; /** @@ -11,35 +13,100 @@ import type { FirstRunDecision } from "../onboarding/firstRun.logic"; /** 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 { +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"; } From d187f96e639b4ee8c4f600ae9a96f25f4d1301b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:52:28 +0200 Subject: [PATCH 05/14] refactor(server): the managed Folder's server code moves into the overlay folder The managed Folder (LazurioPlatform F51) spread its server code over upstream files: `t3 lazurio` in cli/, 44 lines of Folder parsing in cli/config.ts, three copies of `autoBootstrapProjectFromCwd && lazurioFolder === undefined` in startup, a realPath check written twice (provider reactor, VCS service), an auth helper exported from upstream's http.ts and copied in the thread-tabs route, and a new required method on the settings service that five upstream test fixtures had to stub. Now apps/server/src/lazurio/ holds it and upstream files keep hooks: - cli.ts: `t3 lazurio`, moved verbatim from cli/lazurio.ts; bin.ts imports it from there. - managedFolderConfig.ts: T3CODE_LAZURIO_FOLDER and T3CODE_LAZURIO_TOOLS_URL with their checks; cli/config.ts spreads its result over the config. The Folder becomes the cwd and turns off the cwd bootstrap there, so startup keeps upstream's checks and its three folded conditions are gone. - managedFolder.ts: the engine's command guard, and one guard for the provider reactor and the VCS service (agent turns only in the Folder, the Folder never a repository), the refusal words unchanged; ws.ts gives clients the Folder through one helper. - modelRecommendation.ts: the reconcile function, out of packages/shared. The settings service's reconcileRecommendedModel is optional, and its writer takes the function to apply inside its lock, so the five fixtures and the test layer are upstream's again; the model route answers 503 where the method is missing, which only a test layer does. - routeAuth.ts: one auth helper for both overlay routes; http.ts is upstream's again. - packages/contracts: the wrong "compare-and-set" comment on the settings patch field is gone. `t3 lazurio capabilities` and `t3 lazurio model` print the same bytes: cli.test.ts pins both, metadata reads, the offline refusal and a live apply through the route. New tests cover the Folder config, the guards on a real filesystem with symlinks, the VCS refusal and the reactor guard (a turn whose workspace no longer resolves to the Folder starts no session); the settings test moved into the overlay. The allowlist drops the files that are upstream's again and adds the reactor's test. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lazurio-fork-ci.yml | 23 +-- apps/server/src/bin.ts | 2 +- apps/server/src/cli/config.ts | 62 +------- apps/server/src/device/DeviceService.test.ts | 1 - apps/server/src/http.ts | 5 +- apps/server/src/lazurio/cli.test.ts | 148 ++++++++++++++++++ .../src/{cli/lazurio.ts => lazurio/cli.ts} | 12 +- apps/server/src/lazurio/managedFolder.test.ts | 71 +++++++++ apps/server/src/lazurio/managedFolder.ts | 77 ++++++++- .../src/lazurio/managedFolderConfig.test.ts | 118 ++++++++++++++ .../server/src/lazurio/managedFolderConfig.ts | 66 ++++++++ .../src/lazurio/modelRecommendation.test.ts | 68 ++++++++ .../server/src/lazurio/modelRecommendation.ts | 21 ++- .../lazurio/modelRecommendationRoute.test.ts | 72 ++++++--- .../src/lazurio/modelRecommendationRoute.ts | 27 ++-- apps/server/src/lazurio/routeAuth.ts | 35 +++++ apps/server/src/lazurio/threadTabsRoute.ts | 35 +---- .../Layers/OrchestrationEngine.ts | 12 +- .../Layers/ProviderCommandReactor.test.ts | 80 +++++++++- .../Layers/ProviderCommandReactor.ts | 25 ++- .../ThreadSettlementReactor.test.ts | 1 - .../provider/Layers/ProviderRegistry.test.ts | 1 - .../makeManagedServerProvider.test.ts | 2 - apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/serverSettings.test.ts | 34 ---- apps/server/src/serverSettings.ts | 26 ++- apps/server/src/terminal/Manager.test.ts | 1 - .../src/vcs/VcsProvisioningService.test.ts | 39 +++++ apps/server/src/vcs/VcsProvisioningService.ts | 23 ++- apps/server/src/ws.ts | 6 +- docs/operations/lazurio-fork-release.md | 25 ++- packages/contracts/src/settings.ts | 1 - packages/shared/src/serverSettings.ts | 11 -- scripts/lazurio-release-contract.test.mjs | 15 +- 34 files changed, 870 insertions(+), 284 deletions(-) create mode 100644 apps/server/src/lazurio/cli.test.ts rename apps/server/src/{cli/lazurio.ts => lazurio/cli.ts} (91%) create mode 100644 apps/server/src/lazurio/managedFolder.test.ts create mode 100644 apps/server/src/lazurio/managedFolderConfig.test.ts create mode 100644 apps/server/src/lazurio/managedFolderConfig.ts create mode 100644 apps/server/src/lazurio/modelRecommendation.test.ts create mode 100644 apps/server/src/lazurio/routeAuth.ts diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 6e55185e9cb8..ef36be9dd1ec 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)/' | diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 46d199435d04..881764e20ae0 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 81a6c0fcd25a..912077381a9a 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 27cf0ff1dbc3..7a81d757c0ac 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 20ded5f9d357..30b368e62167 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/cli.test.ts b/apps/server/src/lazurio/cli.test.ts new file mode 100644 index 000000000000..688fcd68076c --- /dev/null +++ b/apps/server/src/lazurio/cli.test.ts @@ -0,0 +1,148 @@ +// @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"}]}'; +const folder = "/workspace/Lazurio"; +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 57cb66c3bf2b..2f6cc315c093 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/managedFolder.test.ts b/apps/server/src/lazurio/managedFolder.test.ts new file mode 100644 index 000000000000..3ac7f58436d2 --- /dev/null +++ b/apps/server/src/lazurio/managedFolder.test.ts @@ -0,0 +1,71 @@ +// @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("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 1857fee3319b..034380441de7 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,59 @@ 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 and the VCS service, read once from the server config: why an + * agent turn or a new repository is refused in a managed Environment, or null. A working directory + * 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)), + ), + }; +}); + +/** 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 000000000000..8fdd0414ed16 --- /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 000000000000..99546e4ae574 --- /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 000000000000..2e1680139084 --- /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 3598b8bdc8ab..9f34489708eb 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 d0e561fe2160..21715eeb36a0 100644 --- a/apps/server/src/lazurio/modelRecommendationRoute.test.ts +++ b/apps/server/src/lazurio/modelRecommendationRoute.test.ts @@ -30,7 +30,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 +43,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,25 +71,27 @@ 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); + const applied = await post({ folder: config.lazurioFolder, 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" }] }; @@ -91,6 +100,17 @@ it.layer(Layer.mergeAll(authLayer, Settings.layerTest()).pipe(Layer.provide(Node post({ folder: config.lazurioFolder, 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: config.lazurioFolder, 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 12811cf6cd43..231c80941e7c 100644 --- a/apps/server/src/lazurio/modelRecommendationRoute.ts +++ b/apps/server/src/lazurio/modelRecommendationRoute.ts @@ -1,16 +1,25 @@ +/** + * 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 one, 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 { ServerConfig } from "../config.ts"; import { ServerSettingsService } from "../serverSettings.ts"; - import { MODEL_RECOMMENDATION_ROUTE, ModelRecommendationRequest } from "./modelRecommendation.ts"; -import { ServerConfig } from "../config.ts"; -// Platform supplies product recommendations. T3 only owns reconciliation and -// persistence of its own preference, including evidence of its last managed write. +import { authenticateOverlayRoute } from "./routeAuth.ts"; + export const layer = HttpRouter.use((router) => Effect.gen(function* () { const settings = yield* ServerSettingsService; @@ -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, ); @@ -41,6 +48,8 @@ export const layer = HttpRouter.use((router) => ); if (config.lazurioFolder === undefined || selection.value.folder !== config.lazurioFolder) 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/routeAuth.ts b/apps/server/src/lazurio/routeAuth.ts new file mode 100644 index 000000000000..8d7aa751b05a --- /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/threadTabsRoute.ts b/apps/server/src/lazurio/threadTabsRoute.ts index 82ffc14a12fa..63e2a394c65d 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.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 90b2ed9462b6..40bc9060414b 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 87f9bd03d46c..f3848a889ac5 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 7b00d044842f..f85f7fd08bc0 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 9ed1f5dab77d..cad45007665c 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 f38c45249d3c..0970ea88748b 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 87d460b6a8c9..292ef66ff2ee 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/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c7c979759814..156e6e6f65bd 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 8887d5144914..502ae3430e27 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 eba486efa3de..50e5cda8208c 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 186e7d865c70..325233bf59d9 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 386cc1db29de..9cc0c4335557 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 8f16c5b0b4fc..0632a2345afc 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 4c46bcb3ab1c..369ac9eb7225 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/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index c51330ca7062..04f9dbdd035c 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -216,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`\*. @@ -227,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`, @@ -245,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 diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6a429a65734b..e16ae4db3133 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 823c9f549a9b..d847cb2b0795 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 5f6a8c2f4eb2..f92130bd2d4d 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -741,17 +741,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\),/); From 630461fb14abdba682117e03806a79c6cd45e704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:54:07 +0200 Subject: [PATCH 06/14] fix(server): the model recommendation route knows the Folder by its canonical path POST /api/lazurio/model-recommendation compared the folder in the request with the configured Folder as raw strings, while the configured Folder is the canonical path T3CODE_LAZURIO_FOLDER resolves to. The Platform's `lazurio chat defaults --apply` sends realpath(folder), so it matched, but the same Folder named through a symlink or with a trailing slash got 409 folder-mismatch. The route now asks the Folder guard, which resolves the requested path the way the provider reactor and the VCS service resolve theirs: a path that resolves to the Folder is the Folder, anything else stays 409. The tests use a real Folder and check a symlink, a trailing slash, the parent, a subfolder and a relative path. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/lazurio/cli.test.ts | 5 ++- apps/server/src/lazurio/managedFolder.test.ts | 18 ++++++++++ apps/server/src/lazurio/managedFolder.ts | 12 +++++-- .../lazurio/modelRecommendationRoute.test.ts | 33 +++++++++++++------ .../src/lazurio/modelRecommendationRoute.ts | 10 +++--- 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/apps/server/src/lazurio/cli.test.ts b/apps/server/src/lazurio/cli.test.ts index 688fcd68076c..2034ae0ee3e0 100644 --- a/apps/server/src/lazurio/cli.test.ts +++ b/apps/server/src/lazurio/cli.test.ts @@ -40,7 +40,10 @@ const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3- // 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"}]}'; -const folder = "/workspace/Lazurio"; +// 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. */ diff --git a/apps/server/src/lazurio/managedFolder.test.ts b/apps/server/src/lazurio/managedFolder.test.ts index 3ac7f58436d2..df5019839058 100644 --- a/apps/server/src/lazurio/managedFolder.test.ts +++ b/apps/server/src/lazurio/managedFolder.test.ts @@ -60,6 +60,24 @@ it.effect("runs an agent turn only in the Folder itself, a symlink to it include }).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; diff --git a/apps/server/src/lazurio/managedFolder.ts b/apps/server/src/lazurio/managedFolder.ts index 034380441de7..4162826521fc 100644 --- a/apps/server/src/lazurio/managedFolder.ts +++ b/apps/server/src/lazurio/managedFolder.ts @@ -88,9 +88,10 @@ const repositoryAtFolder = "The Lazurio Folder holds separate repositories, so it cannot become a repository itself."; /** - * The guard of the provider reactor and the VCS service, read once from the server config: why an - * agent turn or a new repository is refused in a managed Environment, or null. A working directory - * counts as the Folder when it resolves to it, symlinks followed. + * 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; @@ -112,6 +113,11 @@ export const makeFolderGuard = Effect.gen(function* () { : 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)), }; }); diff --git a/apps/server/src/lazurio/modelRecommendationRoute.test.ts b/apps/server/src/lazurio/modelRecommendationRoute.test.ts index 21715eeb36a0..3400b27277c3 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), @@ -85,8 +94,14 @@ it.layer(settingsLayer.pipe(Layer.provide(NodeServices.layer)), { 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); - const applied = await post({ folder: config.lazurioFolder, selection }, writer.token); + 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( @@ -96,9 +111,7 @@ it.layer(settingsLayer.pipe(Layer.provide(NodeServices.layer)), { 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. @@ -108,7 +121,7 @@ it.layer(settingsLayer.pipe(Layer.provide(NodeServices.layer)), { const unavailable = handlerWith(testSettings); yield* Effect.addFinalizer(() => Effect.promise(() => unavailable.dispose())); const refused = yield* Effect.promise(() => - unavailable.handler(request({ folder: config.lazurioFolder, selection }, writer.token)), + 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 231c80941e7c..600e5d289962 100644 --- a/apps/server/src/lazurio/modelRecommendationRoute.ts +++ b/apps/server/src/lazurio/modelRecommendationRoute.ts @@ -5,8 +5,8 @@ * (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 one, 503 when the settings service cannot - * reconcile (only its test layer). + * 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"; @@ -15,15 +15,15 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import { failEnvironmentInternal } from "../auth/http.ts"; import { requiredScopeForRpcMethod } from "../auth/RpcAuthorization.ts"; -import { ServerConfig } from "../config.ts"; import { ServerSettingsService } from "../serverSettings.ts"; +import { makeFolderGuard } from "./managedFolder.ts"; import { MODEL_RECOMMENDATION_ROUTE, ModelRecommendationRequest } from "./modelRecommendation.ts"; 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; @@ -46,7 +46,7 @@ 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 }); From 3511735a745e4dc50931c55b7de7ad136fd22e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:55:47 +0200 Subject: [PATCH 07/14] test(server): the managed Folder's engine tests run on test layers; fork CI lints (#63) `vp lint` (the repository's `pnpm run lint`) failed on main: upstream holds manual Effect runners in tests to a per-file ceiling with no net-new occurrences, and the managed Folder's two engine tests each added a ManagedRuntime.make to OrchestrationEngine.test.ts, two over its ceiling of five. Fork CI never ran lint, so it went unseen. Both tests now run as @effect/vitest live tests that provide the engine's test layer, the adoption test once without and once with the Folder over the same database file, so the file is back at upstream's five runners and the full lint passes. What they check is unchanged. Fork CI runs upstream's lint script after the formatter, as upstream's own `vp check` does; the release contract keeps the step and the runbook lists it. Fixes Lazurio/t3code#63. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lazurio-fork-ci.yml | 5 + .../Layers/OrchestrationEngine.test.ts | 237 +++++++++--------- docs/operations/lazurio-fork-release.md | 5 +- scripts/lazurio-release-contract.test.mjs | 2 + 4 files changed, 131 insertions(+), 118 deletions(-) diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index ef36be9dd1ec..db3dcf27ca7c 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -196,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/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 3ef633ab01d6..6786e67cb4cf 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/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index 04f9dbdd035c..a4357eabf483 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -514,8 +514,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/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index f92130bd2d4d..b8cb1def4d87 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]; From 761f01e18e61bd613f7051acfdf91655495a52b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 11:57:02 +0200 Subject: [PATCH 08/14] test: the release contract keeps the managed Folder's seams Every overlay feature has a guard in the release contract that fails when a rebuild on a new upstream tag loses one of its seams; the managed Folder (LazurioPlatform F51) had none, though it touches the most upstream files. The new guard reads the hooks in upstream files and pins each one: `t3 lazurio` in bin.ts, the Folder config spread last over cli/config.ts and checked before the server's directories exist, the engine's command guard right before a command is decided, the provider reactor's turn guard before a session starts, the VCS guard before a driver runs, the startup adoption and its welcome fields, the optional reconcile method and the writer's apply hook in the settings service, the clients' config in ws.ts, the contract fields; and in the web, the palette's project pickers and "Add project", the sidebar's button, the hero, the first-run gate, the new-thread hook, the scratch project, the git actions and the welcome behind upstream's route guard. Every file must be the overlay's own or on the allowlist. Disabling the palette's condition or the reactor's guard fails it. Co-Authored-By: Claude Opus 5.5 --- docs/operations/lazurio-fork-release.md | 9 +- scripts/lazurio-release-contract.test.mjs | 132 ++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index a4357eabf483..b9c7874037b5 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -424,9 +424,14 @@ 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 diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index b8cb1def4d87..3e5e714e2115 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -422,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"), From 6e383cac6e90d3c6cf8012d98464de985380cb46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 12:06:20 +0200 Subject: [PATCH 09/14] refactor(lazurio): the overlay drops dead code and keeps one copy of its helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Environment browser overlay carried code nothing used and helpers written twice: - Server: every tab of a thread's snapshot carried `view`, which the web never reads (it builds `/t/` itself since the panel first showed the tabs, so no released client needs it); DaemonSeams.read was left from the PID-based stop; askDaemonSocket and daemonProcessId were exported without importers. The three reconciliations of a holder file each removed the tabs the hub let go in a loop of their own; letGo now removes them and names them. The service cut labels with its own copy of the holder file's rule; holders.ts exports it as holderLabel. - Web: the view's info and closed messages carried a validated `tab` that nothing read; the frame that sends them is the tab. originOf, the panel's state of a thread and the thread's last snapshot each had two definitions; environmentBrowser.ts and threadTabs.ts now export the one each. Nová karta used a copy of upstream's typing check for its letters; it imports upstream's exported surfaceShortcutTargetsTypingContext. postThreadTab, restoreTabs, runTool and chooseInNewTab are module-private. Behaviour does not change; the tests that pinned the dropped fields follow. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/lazurio/holders.ts | 11 ++++--- apps/server/src/lazurio/threadTabs.test.ts | 1 - apps/server/src/lazurio/threadTabs.ts | 20 +++++------- .../src/lazurio/threadTabsRoute.test.ts | 4 +-- .../src/lazurio/LazurioEnvironmentBrowser.tsx | 3 +- apps/web/src/lazurio/LazurioNewTab.tsx | 22 +++++-------- .../src/lazurio/environmentBrowser.test.ts | 32 ++----------------- apps/web/src/lazurio/environmentBrowser.ts | 23 ++++--------- apps/web/src/lazurio/threadTabs.ts | 21 ++++++------ 9 files changed, 46 insertions(+), 91 deletions(-) diff --git a/apps/server/src/lazurio/holders.ts b/apps/server/src/lazurio/holders.ts index 2339be80c80d..2f0c05d2cf39 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/threadTabs.test.ts b/apps/server/src/lazurio/threadTabs.test.ts index 70feea8e2681..056950b016c8 100644 --- a/apps/server/src/lazurio/threadTabs.test.ts +++ b/apps/server/src/lazurio/threadTabs.test.ts @@ -697,7 +697,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 c37da31d5314..aa590b53fbd1 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"); @@ -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); @@ -525,7 +522,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 +693,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 1eb6e4eb1224..1e1aca4915b2 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/web/src/lazurio/LazurioEnvironmentBrowser.tsx b/apps/web/src/lazurio/LazurioEnvironmentBrowser.tsx index 825bc59d449c..b9f2cb002c45 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 8aa88239aa34..53a7ff9e2d36 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/environmentBrowser.test.ts b/apps/web/src/lazurio/environmentBrowser.test.ts index ae75e4eb0624..1c765bd090f6 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 5fd6c23a0701..41b096de60c8 100644 --- a/apps/web/src/lazurio/environmentBrowser.ts +++ b/apps/web/src/lazurio/environmentBrowser.ts @@ -126,18 +126,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 +155,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/threadTabs.ts b/apps/web/src/lazurio/threadTabs.ts index ecf08f34e382..96462e2a98ce 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); @@ -713,7 +712,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)) { From 91b0b032ac1a809ab56edb991b4ea99090ffe5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 12:07:32 +0200 Subject: [PATCH 10/14] =?UTF-8?q?fix(web):=20a=20tab=20that=20ends=20as=20?= =?UTF-8?q?the=20panel's=20last=20one=20closes=20the=20panel,=20like=20?= =?UTF-8?q?=C3=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the view says its tab ended, the tab leaves the strip. The overlay removed it with its own copy of upstream's close, which lacked one rule of upstream's ×: the panel closes with its last surface. So a thread whose only Environment browser tab ended kept an open, empty panel. The overlay's update now matches upstream's closeSurface rule for rule. It stays an automatic update rather than a call to closeSurface: that action counts as the person's choice, and one during a turn holds back the panel upstream opens by itself when the turn completes, while a tab an agent closes ends without anyone choosing. A test runs upstream's closeSurface and the overlay on the same panels (the last tab, the tab in front, a tab behind, a closed panel) and requires the same result, with the person's choices untouched. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/threadTabs.test.ts | 25 +++++++++++++++++++++++++ apps/web/src/lazurio/threadTabs.ts | 12 ++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lazurio/threadTabs.test.ts b/apps/web/src/lazurio/threadTabs.test.ts index eb7d32d563e0..9ef57a2bb6d3 100644 --- a/apps/web/src/lazurio/threadTabs.test.ts +++ b/apps/web/src/lazurio/threadTabs.test.ts @@ -457,6 +457,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 96462e2a98ce..3fe301c13394 100644 --- a/apps/web/src/lazurio/threadTabs.ts +++ b/apps/web/src/lazurio/threadTabs.ts @@ -461,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, }; From c99b5e15fe8b7b975efd1913c9d7567552bb160c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 12:09:12 +0200 Subject: [PATCH 11/14] fix(server): the Environment browser overlay forgets what it no longer needs Three small faults of the server overlay: - The thread tabs service cached a failed lookup of a session's thread for good: a `t3-` session first seen while its thread was not live (archived, or not projected yet) was never matched again, so the tabs its agent bound after the thread came back never reached the thread's strip. Only a thread found is cached now; a session of no live thread is asked again when it binds anew, which happens only when its binding changes. - The browser host kept a lock for every tab it ever drove and every other Environment's tab preview_open ever answered with, for the server's whole life. A tab's lock now goes when its last operation is done, and the host remembers the latest 256 other Environments' tabs; one forgotten is only no longer named as another Environment's when a tool reaches for it. - The host's own record of a thread was an interface named ThreadTabs, the name of the service module it imports; it is HostThread. The JSDoc of foreignView sat on the function above it and moved to its own. A test covers the session that is asked again. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/lazurio/environmentBrowser.ts | 50 +++++++++++++------ apps/server/src/lazurio/threadTabs.test.ts | 14 ++++++ apps/server/src/lazurio/threadTabs.ts | 7 +-- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/server/src/lazurio/environmentBrowser.ts b/apps/server/src/lazurio/environmentBrowser.ts index 90e312ee5790..fb007bf7047d 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/threadTabs.test.ts b/apps/server/src/lazurio/threadTabs.test.ts index 056950b016c8..db6cdc7050ed 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[] = []; diff --git a/apps/server/src/lazurio/threadTabs.ts b/apps/server/src/lazurio/threadTabs.ts index aa590b53fbd1..83f91f39bcea 100644 --- a/apps/server/src/lazurio/threadTabs.ts +++ b/apps/server/src/lazurio/threadTabs.ts @@ -213,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 }> @@ -435,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); @@ -454,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; } From 83b6658f09f04f1b6a39389fd8add40ef3e3ff2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 12:11:54 +0200 Subject: [PATCH 12/14] fix(web): a hold the server keeps failing stops retrying after about a minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person's hold of a tab (Nová karta, a page's new tab, a link, Vrátit) retried a network failure or a 5xx with a growing pause, up to 30 s, for as long as the tab stayed in the strip. A server that keeps answering 503 (no holder file it can write, the service off) got a POST every 30 s from every device, for as long as the person kept the tab, and the tab stayed pending. The hold now tries six more times, over about a minute, as the release gives up after its few. After that it ends as a refusal does: the tab stays in the strip as the device's own, with the same warning that it closes 30 s after nobody views it. A test runs a persistent 503 through the retries. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/threadTabs.test.ts | 22 ++++++++++++++++++++++ apps/web/src/lazurio/threadTabs.ts | 15 +++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lazurio/threadTabs.test.ts b/apps/web/src/lazurio/threadTabs.test.ts index 9ef57a2bb6d3..bf1fc7e158a0 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( diff --git a/apps/web/src/lazurio/threadTabs.ts b/apps/web/src/lazurio/threadTabs.ts index 3fe301c13394..5d593571298a 100644 --- a/apps/web/src/lazurio/threadTabs.ts +++ b/apps/web/src/lazurio/threadTabs.ts @@ -591,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); @@ -615,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( From f4eba7460a76a83d9ab15f4c055868584fd98390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 12:13:16 +0200 Subject: [PATCH 13/14] chore(lazurio): overlay comments say what the overlay does today Comments that outlived the code they described: - agentBrowserSession.ts described a web copy of the session name, and a release contract check keeping the two equal; the web copy went when the right panel started reading the thread's tabs from the server. The contract test's comment on the browser view's address said the same. - The web's environmentBrowserTab compared its refusal of the page's own origin to the thread's own tab, which the panel no longer has. - overlay.ts and the line of server.ts that joins its layer named the browser host and the thread tabs, not the model recommendation route the managed Folder added to the same layer. Comments only; nothing runs differently. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/lazurio/agentBrowserSession.ts | 10 +++------- apps/server/src/lazurio/overlay.ts | 14 ++++++++------ apps/server/src/server.ts | 5 +++-- apps/web/src/lazurio/environmentBrowser.ts | 3 ++- scripts/lazurio-release-contract.test.mjs | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/server/src/lazurio/agentBrowserSession.ts b/apps/server/src/lazurio/agentBrowserSession.ts index 36219f447ec6..3bfe08a80ed0 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/overlay.ts b/apps/server/src/lazurio/overlay.ts index eeb675aadb61..1fa2a185eb04 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/server.ts b/apps/server/src/server.ts index 963e4aec5dcf..59e8ad11490d 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/web/src/lazurio/environmentBrowser.ts b/apps/web/src/lazurio/environmentBrowser.ts index 41b096de60c8..c5d8892b1005 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, diff --git a/scripts/lazurio-release-contract.test.mjs b/scripts/lazurio-release-contract.test.mjs index 3e5e714e2115..b1ef8eeb9739 100644 --- a/scripts/lazurio-release-contract.test.mjs +++ b/scripts/lazurio-release-contract.test.mjs @@ -983,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"), From ffde6297394d299fa12abc7fa1bf457ff86bcf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Tue, 13 Oct 2026 12:15:40 +0200 Subject: [PATCH 14/14] feat(web): the managed welcome speaks Czech where the person's browser does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed Folder's welcome was English only, while the rest of the overlay (the Environment browser's tabs, Nová karta, the toasts) speaks Czech where the browser's language is Czech (prefersCzech), the agreed rule until the shell's identity carries the person's language. The welcome now follows the same rule. Its Czech addresses people formally (root decision 0199) and calls the place the Environment (root decision 0170), with the Lazurio Folder named as the Environment's own pages name it. The English text is unchanged, and so is what the welcome does. The runbook's managed Folder section says it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lazurio/ManagedFolderWelcome.tsx | 58 ++++++++++++++----- docs/operations/lazurio-fork-release.md | 3 +- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lazurio/ManagedFolderWelcome.tsx b/apps/web/src/lazurio/ManagedFolderWelcome.tsx index 1dd536353266..2240a39f4ed5 100644 --- a/apps/web/src/lazurio/ManagedFolderWelcome.tsx +++ b/apps/web/src/lazurio/ManagedFolderWelcome.tsx @@ -11,6 +11,7 @@ import { useProjects } from "../state/entities"; import { primaryServerConfigAtom } from "../state/server"; import { environmentShell } from "../state/shell"; import { managedFirstRunDecision } from "./managedFolder"; +import { prefersCzech } from "./threadTabs"; /** The primary Environment's Lazurio Folder (LazurioPlatform F51), when it is managed. */ export function usePrimaryLazurioFolder(): string | undefined { @@ -50,6 +51,39 @@ export function useManagedFirstRunDecision(upstream: FirstRun): FirstRun { }; } +/** 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. @@ -61,6 +95,7 @@ export function ManagedFolderWelcome() { 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 && @@ -71,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 ? (

@@ -113,12 +143,12 @@ export function ManagedFolderWelcome() { }), ) .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/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index b9c7874037b5..38ef6da759bf 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -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;