Repository navigation
fix(server): retry failed loads, reload JS configs, answer per path when --root holds projects - #242
Merged
Merged
Conversation
… projects per path - A load that fails (an invalid config at startup) is no longer kept: the next request loads again, and in path mode the failed project no longer counts as a project found. A first model build that fails is built again too. - JS and TS configs are imported under a query that changes with their content, and a CommonJS one is cleared from require's cache, so an edited config is read again instead of the module Node.js loaded first. - A --root (or client root) that is no project but holds projects is answered per path, as the server does when started outside any project, and zero config there no longer reads the token files of the projects in it. OS2-47
dgesteves
added a commit
that referenced
this pull request
Oct 28, 2026
#201 shipped in 0.9.1, #202 in 0.10.0 and #199 in 0.10.1, as the changelog shows; #200, #203 and #204 are website and docs changes, live since they merged. Main is at 0.10.1 with no pending changesets, so nothing is merged and waiting for a release. In review now lists the open pull requests #241 and #242.
With --root (or a client root) on a folder that holds projects, a request is now answered only for an absolute path inside that folder, from the project in it that holds the file. A path outside the folder is refused, and so is a monorepo root above it: the nearest project inside the folder answers instead. A relative path is refused too, since the tools would read it against whichever project answers; the error asks for the absolute path. - The error for a JavaScript or TypeScript config in a project found inside such a folder says to start the server with --root <that project>; config paths and the folder list in "Requests have named files in N projects" are quoted with pathOf(). - PROJECT_MARKERS moves to src/config.ts, which design-system.ts and workspace.ts both import, instead of a copy in design-system.ts. - docs/configuration.md: a JS or TS config is read again when it changes, a module it imports is not, and each edit keeps a small copy in memory until the server restarts. - Tests: a path outside the folder and a relative path are refused, also after another project answered; --root mono/apps never applies mono's root config or lists packages/*; both config errors and the quoted folder list; and a child Node.js process that reloads an edited CommonJS config, which fails without the require.cache delete.
A path in the folder, and the project it leads to, are checked with links resolved: a link to a project or a monorepo outside the folder, or a linked folder in a project, gets an error instead of an answer from there. A workspace in the folder serves only its packages that are in it, and the log names the others with the --root that includes them. Projects found from a path are kept by real path, so two spellings of one folder (letter case on macOS or Windows, or a link) are one project. docs/clients.md describes the folder-of-projects mode.
…eload
With a --root folder of projects, each project's design system, and each
package's in a workspace in the folder, is built only while its folder is
really in that folder. A project or workspace package replaced by a link
to one outside it while the server watches now fails to rebuild ("... now
leads outside ..., the folder the server answers for, through a link"),
so the model built from inside the folder stays, and calls without a path
never answer with the components or tokens from where the link leads.
WorkspaceProjects takes the build function for its hosts.
Zero config at such a folder leaves out the nested projects' token files
before the files that lead outside are found, so the log no longer names
them as not read. docs/clients.md says the folder limits the paths tool
calls name, not the files a project's own config names in its repository
or workspace.
…n Linux Node.js 26's recursive watcher on Linux looks a reported file name up by the path it watches, which after the swap leads to the linked folder. The tests now write the new file in both the moved folder and the linked one, so every watcher reports the change and the server tries to rebuild.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Roadmap item: OS2-47 (findings
os-bugs-edges/os-server-config-error-sticky,os-bugs-edges/os-stale-config-long-running(the MCP server half),os-adopt-clients/root-parent-folder-cross-project-fixes).What was wrong
Three setup-time failures that make the MCP server look broken. I reproduced each one with a stdio client against
node dist/cli.jsbuilt from main (f8c541e), then ran the same client against this branch:rules: {"no-hardcoded-colour": "off"}at startup, then the typo fixedInvalid config ...again after the fix1 component. ...onsystem.config.mjsreplaced byonsystem.config.jsonas the error says... or use onsystem.config.json.againerrorCount 1(the config is read)onsystem.config.mjs/.ts/ CommonJS.jsedited fromoffto"error"while watchingreloaded ...,errorCount 0errorCount 1--root apps, whereapps/holds projectsaandbbut is noneloaded 0 components, 1 token from .;check_uiona/app/page.tsxreports 0 errors;a/app/alert.tsxgets a fix that citesb'sdangertokena's own design system checks it: 1 error,"loud" is not a valid tone for <Chip>; no fix citesbSuch a
--rootlimits what the server reads. With the built CLI from this branch, started with--root repos(projectsalphaandbeta, and a projectelsewherenext torepos) and with--root mono/apps(monoa pnpm workspace whose root config turnsno-unknown-variantoff, withapps/webandpackages/secret):get_componentwith a path inelsewhere: an error,... is outside .../repos, the folder the server answers for; a call without a path after it still has no project to answer from.check_uiinbeta,check_uiwithcodeandfilename: "alpha/app/new.tsx", and withpath: "app/page.tsx": an error,onsystem cannot tell which project alpha/app/new.tsx is in: it is a relative path, ..., which asks for the absolute path.--root mono/apps:check_uionapps/web/app/page.tsxgiveserrorCount 1(the root config does not apply),list_componentslists 1 component, andget_componentwith a path inpackages/secretis an error,... is outside .../mono/apps.Paths are compared with links resolved. With the built CLI started with
--root x/apps, wherex/apps/link-projlinks to a project outside the folder,x/apps/link-monoto a pnpm workspace outside it, andx/apps/a/vendor(in projecta) to the outside project:get_componentandcheck_uiwith a path inlink-proj,list_componentswith a path inlink-mono, andget_componentwith a path ina/vendor: each an error,... leads outside .../x/apps, the folder the server answers for, through a link. Pass the absolute path of a file inside it, or start the server with --root <project folder>.check_uiona/app/page.tsxthen answers froma(1 error);list_componentswith the pathx/apps/A(another letter case, on macOS) answers from the same project, and aget_componentwithout a path after both answers froma, not "Requests have named files in 2 projects".x/apps/ws, a pnpm workspace whosepackagesname../../../out/wsout(outside the folder):list_componentswith a path inws/apps/weblists 1 component;get_componentVault(the outside package's component) without a path isNo component named "Vault" in any project. Available: Chip.,search_componentsvaultfinds nothing, and the log says.../x/apps/ws names workspace packages outside .../x/apps, the folder the server answers for (links resolved), which it leaves out: .../out/wsout. To include them, start the server with --root .../x/apps/ws.A project in the folder, or a workspace package in it, that is replaced by a link while the server watches is not built again from where the link leads. With the built CLI started with
--root folder --no-cache(watching on, the default), aftercheck_uionfolder/a/app/page.tsx,folder/awas moved away and replaced by a link to a project outside the folder whoseVaultcomponent has asealedprop and whose token isvault-steel; the same withfolder/mono/apps/web, a package of a pnpm workspace in the folder:reload failed: .../folder/a now leads outside .../folder, the folder the server answers for, through a link: it is not read from there.(and the same for.../folder/mono/apps/web);get_componentVaultwithout a path isNo component named "Vault". Available: Chip.(in the workspace,... in any project. Available: Chip.),list_componentslistsChiponly,get_tokensandds://tokensgivebrandonly, andsearch_componentsvaultfinds nothing; no answer hassealedorvault-steel.The causes:
serveStdiokept the firstload()promise even when it rejected, so nothing reloaded it. WhenloadTargetthrows, noDesignSystemHostexists, so nothing is watched either. Path mode kept the rejected promise per folder, and the failed folder also counted as a project, so a call without a path got "Requests have named files in 2 projects".readConfigFileimported JS and TS configs withimport(pathToFileURL(file).href). Node.js returns the module it already has for that URL. A CommonJS.jsconfig is also kept inrequire.cache.--rootalways went toserveOne. The safety net that the working-directory path has (holds no project ... finding each request's project from the absolute path it names) never ran for it. Zero config's**/*.tokens.jsonthen read every nested repository's tokens.What changed
src/server/stdio.ts: onefoundmap of loads by folder. A load that rejects is logged and dropped, so the next request loads again, and until then it is not a project found. A--rootor client root that has no project marker and no workspace file, but has a project in a folder one or two levels below it, is served per path, like a working directory that holds no project. Only an absolute path inside that folder is answered: a path outside it gets an error that names the folder and says to pass a file inside it or use--root <project folder>. Inside it, the projectfindProjectRootfinds is used only when it is inside the folder too; when it is above it (a monorepo root that holds the folder), the nearest project inside the folder answers (projectDirOf). A file in none of those projects is still answered from the root itself, as before. Links are resolved, withrealpathSync.native(the folder's real path computed once): a path whose real location, or whose project's, is outside the folder's real location gets an error that names the path and the folder and says it leads outside the folder through a link. When the project found is a workspace, its packages whose real root is outside the folder are left out (Workspace.without, insrc/workspace.ts), and one warning names them and says to start the server with--root <that workspace>to include them. Projects found from a path are kept by real path, so two spellings of one folder (another letter case on macOS or Windows, or a link) load it once. A relativepathorfilenamegets an error that asks for the absolute path:src/server/index.tsreads it against the root of whichever project answers, so it would name a file in another project. A request without any path gets an error that names the folder and asks forpath, until one project has answered. Configs in projects found this way are read only when they are JSON, as in the existing path mode. Each project found in such a folder, and each package of a workspace found in it, is built withbuildWithin(folder): beforebuildModel, it checks that the project's folder, links resolved, is still in the folder, and otherwise throws aConfigErrornaming both (... now leads outside ..., the folder the server answers for, through a link: it is not read from there.). A project replaced by a link while the server watches it then fails to rebuild, andDesignSystemHost.reload()keeps serving the model it built from inside the folder. A folder with no project below it (a project without apackage.json, served with--root) is still served as one project.src/server/projects.ts:WorkspaceProjectsOptions.build, passed to each project'sDesignSystemHost, so a workspace in the folder gets the same check for each package.src/config.ts: JS and TS configs are imported asfile://...?v=<sha256 of the content>. An edited config becomes a new module, and an unchanged one is the same module. A CommonJS config'srequire.cacheentry is deleted first. A module that a config imports is not read again, and each edited version stays in Node.js's module cache;docs/configuration.mdnow says both (restart the server after editing an imported module). The error for a JavaScript or TypeScript config in a project found inside a--rootfolder of projects says that such a project reads only a JSON config, and to start the server with--root <that project>for a JS or TS one. The config path in both forms of that error is quoted withpathOf().PROJECT_MARKERSmoves here fromsrc/workspace.ts, sosrc/design-system.tsimports it instead of keeping a copy (it cannot importworkspace.ts, which imports it).src/design-system.ts:DesignSystemHost.get()andreload()drop a build that fails when there is no earlier model to serve, so the nextget()builds again. Before, a first build that failed was kept until a watched file changed. For zero config in a folder that has no project marker and no config, a token file inside a nested project is left out. That fixes the CLI case from the finding too (check base-app/...run from the parent folder no longer suggests another repository's token), without touchingsrc/cli.ts. A root that is a project itself (with apackage.jsonor a config) reads its nested packages' token files as before. Those nested token files are left out before the check for files that lead outside the project (findtakes askipfilter), so the log no longer names one of them that is a link to somewhere else as "Not read ... lead outside the project".The MCP boundary from #199 is unchanged. The new errors are
ProjectNotFoundErrors thrown fromgetDesignSystem/getProjects, soguardturns them into answers, and the paths in the new messages, and the folder list in "Requests have named files in N projects", are quoted withpathOf().File boundary. The wave plan lists
src/config.ts,src/design-system.ts,src/server/index.tsandsrc/server/projects.tsfor this item. The sticky load and the--rootrouting both live insrc/server/stdio.ts(the finding citesstdio.ts:131-136and:161), so this PR changes that file and leavesindex.tsuntouched;projects.tsgets one option, so a workspace's hosts use the same build check. No open PR and no other item in this wave touchesstdio.ts. The review rounds also changesrc/workspace.ts(the marker list moved out, the new config option passed on to a monorepo's projects, andWorkspace.without),docs/configuration.md(the reload note) anddocs/clients.md(one paragraph in "How the server finds your project" on a--rootthat holds projects); no open PR touches them.How it was tested
New
test/server-recovery.test.ts(28 tests). Against main (0b558b9), 27 of them fail; the one that passes checks that a marker-less--rootproject still works. Against this PR's first commit, the 6 tests added in the first review round fail and the rest pass; against the head before the second round (5ff77c2, main merged in), the 6 tests added in that round fail and the other 19 pass; against the head before the third round (4aa4bf5), the 3 tests added in that round fail and the other 25 pass. The tests:list_componentsandcheck_uianswer with the fixed config;.mjsconfig is refused, a path-less call answers from the other project that loaded, and after the switch toonsystem.config.jsonthe call works;get();loadConfigreads an edited.mjs,.tsand CommonJS.jsconfig again, and the server applies an edited.mjsconfig's rules while watching;require.cache, so it passes without therequire.cachedelete. The child process loadssrc/config.tswith each source file compiled by TypeScript as it is imported, writes the config twice and prints["off","warn"]; with the delete removed it prints["off","off"]and the test fails, while the three Vitest cases still pass;--rooton a folder of two repositories routes per path, never cites the other project's token, asks for a path before one is named, and still serves a marker-less folder with no project below it as one project;--root, a path outside the folder is refused with the message above and loads nothing; a relativefilenameorpathis refused, also after another project has answered, and never gets that project's fix; the folder list in "Requests have named files in 2 projects" is quoted; a.mjsconfig in a project of the folder gets the--root <that project>error;--root mono/appsat a pnpm workspace never appliesmono's root config (the page still has 1 error) and never lists or citespackages/secret; the server logs that it usesmono/apps/web, nevermono;--rootfolder of projects, links that point at other fixtures: a link in the folder to a project outside it, a link to a monorepo outside it, a linked folder in a project, and a link in a monorepo in the folder to a project outside it are each refused, with none of their components in any answer (get_component,check_uiwithpathor withcodeand a newfilename,list_components,search_components), and nothing loaded from them;packagesname../../outside:list_components,search_componentsandget_componentwithout a path never show the outside package, and the warning names it and the--rootthat includes it;--rootfolder of projects and watching on, a loaded project, and a loaded package of a workspace in the folder, moved away and replaced by a link to a fixture outside the folder: the log names the refused rebuild, andget_component,list_components,get_tokensandsearch_componentswithout a path (andds://tokens) never show the outside component's prop or token, whileChipand the inside token still answer. Where the file system does not let a watched folder be moved, the test is skipped;.mjsconfig in a project found from a path, in a folder whose name has a space, is named quoted in the error;package.json; a nested project's token file that is a link to a fixture outside is not named in the folder's warnings, and is still named "Not read" from that project;checkrun from the parent gives no fix from another project.I also ran the built CLI over stdio for the cases listed under "What was wrong", with the outputs given there, and for a CommonJS
onsystem.config.jsedited fromoffto"error"while watching:errorCount 0before the edit,errorCount 13 s after it.Gate on this branch (281e589, main 0b558b9 merged in), all passing on macOS:
pnpm install --frozen-lockfile --prefer-offline,pnpm format:check,pnpm lint,pnpm typecheck,pnpm test(104 files passed, 1 skipped; 1324 tests passed, 3 skipped),pnpm build(schema.json unchanged) andpnpm smoke(passed); build and smoke ran on 3d7748c, whosesrcis the same. On 3d7748c, CI's Test (Node 26) job failed the two link-swap tests: Node.js 26's recursive watcher on Linux reports a new file only when it finds it by the path it watches, which after the swap leads to the linked folder, so it never saw the change. 281e589 writes that file in both folders.pnpm corpuswas run on the first commit ("Counts match corpus/snapshot.json", and all 80 labels of the blind sample still reported); the review rounds change MCP routing and its error messages, not whatcheckreports, so it was not run again.Follow-ups (not in this PR)
In a workspace in such a folder, a package replaced by a link keeps its components and tokens from inside the folder, but when the merged catalog is first built after the swap, its group heading (
## vault (used by apps/web)) and a token's package tag come from thepackage.jsonthat the link leads to:Cataloginsrc/catalog.tsreads package names from disk when it is built. A catalog built before the swap keeps the old names. Reading the name from where the model was built would mean changingsrc/catalog.ts.ESLint plugin: edits to
rulesandoverridesstill need an editor restart. The worker keys its design system on the model's cache key, which leaves those fields out. The ESLint plugin's stale config is left to OS2-45 ("config hash"; now listed in its brief), whose fix is insrc/eslint/worker.ts.Workspace.projectFor(src/workspace.ts) keeps a rejected load for a folder at a monorepo root that is not a workspace project. It is the same bug class, rarer, and outside this item's files.checkrun from a folder of repositories still checks every file against that folder's config rather than per project. This PR removes the other repository's tokens from its fixes. Routing per file would mean changingsrc/cli.ts.A project is kept by the spelling of its first path.
check_uiwith apathin another spelling of it (another letter case, or through a link inside the folder) is answered "... is outside the project root ()", as--rootwith such a path is on main; the other tools answer. Comparing real paths wheresrc/server/index.tsmakes apathrelative to the project would accept both spellings.A relative path with such a
--rootis refused. Reading it against the--rootfolder, as main did, would mean changing howsrc/server/index.tsresolves paths for a project found this way.