Skip to content

fix(server): retry failed loads, reload JS configs, answer per path when --root holds projects - #242

Merged
dgesteves merged 7 commits into
mainfrom
fix/os2-47-server-robustness
Oct 28, 2026
Merged

dgesteves merged 7 commits into
mainfrom
fix/os2-47-server-robustness

Conversation

@dgesteves

@dgesteves dgesteves commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

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.js built from main (f8c541e), then ran the same client against this branch:

Case main this branch
rules: {"no-hardcoded-colour": "off"} at startup, then the typo fixed Invalid config ... again after the fix 1 component. ...
Path mode, onsystem.config.mjs replaced by onsystem.config.json as the error says ... or use onsystem.config.json. again errorCount 1 (the config is read)
onsystem.config.mjs / .ts / CommonJS .js edited from off to "error" while watching log says reloaded ..., errorCount 0 errorCount 1
--root apps, where apps/ holds projects a and b but is none loaded 0 components, 1 token from .; check_ui on a/app/page.tsx reports 0 errors; a/app/alert.tsx gets a fix that cites b's danger token a's own design system checks it: 1 error, "loud" is not a valid tone for <Chip>; no fix cites b

Such a --root limits what the server reads. With the built CLI from this branch, started with --root repos (projects alpha and beta, and a project elsewhere next to repos) and with --root mono/apps (mono a pnpm workspace whose root config turns no-unknown-variant off, with apps/web and packages/secret):

  • get_component with a path in elsewhere: 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.
  • After an absolute check_ui in beta, check_ui with code and filename: "alpha/app/new.tsx", and with path: "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_ui on apps/web/app/page.tsx gives errorCount 1 (the root config does not apply), list_components lists 1 component, and get_component with a path in packages/secret is an error, ... is outside .../mono/apps.

Paths are compared with links resolved. With the built CLI started with --root x/apps, where x/apps/link-proj links to a project outside the folder, x/apps/link-mono to a pnpm workspace outside it, and x/apps/a/vendor (in project a) to the outside project:

  • get_component and check_ui with a path in link-proj, list_components with a path in link-mono, and get_component with a path in a/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_ui on a/app/page.tsx then answers from a (1 error); list_components with the path x/apps/A (another letter case, on macOS) answers from the same project, and a get_component without a path after both answers from a, not "Requests have named files in 2 projects".
  • x/apps/ws, a pnpm workspace whose packages name ../../../out/wsout (outside the folder): list_components with a path in ws/apps/web lists 1 component; get_component Vault (the outside package's component) without a path is No component named "Vault" in any project. Available: Chip., search_components vault finds 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), after check_ui on folder/a/app/page.tsx, folder/a was moved away and replaced by a link to a project outside the folder whose Vault component has a sealed prop and whose token is vault-steel; the same with folder/mono/apps/web, a package of a pnpm workspace in the folder:

  • the log says 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_component Vault without a path is No component named "Vault". Available: Chip. (in the workspace, ... in any project. Available: Chip.), list_components lists Chip only, get_tokens and ds://tokens give brand only, and search_components vault finds nothing; no answer has sealed or vault-steel.

The causes:

  1. serveStdio kept the first load() promise even when it rejected, so nothing reloaded it. When loadTarget throws, no DesignSystemHost exists, 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".
  2. readConfigFile imported JS and TS configs with import(pathToFileURL(file).href). Node.js returns the module it already has for that URL. A CommonJS .js config is also kept in require.cache.
  3. An explicit --root always went to serveOne. 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.json then read every nested repository's tokens.

What changed

  • src/server/stdio.ts: one found map 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 --root or 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 project findProjectRoot finds 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, with realpathSync.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, in src/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 relative path or filename gets an error that asks for the absolute path: src/server/index.ts reads 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 for path, 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 with buildWithin(folder): before buildModel, it checks that the project's folder, links resolved, is still in the folder, and otherwise throws a ConfigError naming 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, and DesignSystemHost.reload() keeps serving the model it built from inside the folder. A folder with no project below it (a project without a package.json, served with --root) is still served as one project.
  • src/server/projects.ts: WorkspaceProjectsOptions.build, passed to each project's DesignSystemHost, so a workspace in the folder gets the same check for each package.
  • src/config.ts: JS and TS configs are imported as file://...?v=<sha256 of the content>. An edited config becomes a new module, and an unchanged one is the same module. A CommonJS config's require.cache entry 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.md now says both (restart the server after editing an imported module). The error for a JavaScript or TypeScript config in a project found inside a --root folder 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 with pathOf(). PROJECT_MARKERS moves here from src/workspace.ts, so src/design-system.ts imports it instead of keeping a copy (it cannot import workspace.ts, which imports it).
  • src/design-system.ts: DesignSystemHost.get() and reload() drop a build that fails when there is no earlier model to serve, so the next get() 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 touching src/cli.ts. A root that is a project itself (with a package.json or 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 (find takes a skip filter), 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 from getDesignSystem/getProjects, so guard turns them into answers, and the paths in the new messages, and the folder list in "Requests have named files in N projects", are quoted with pathOf().

File boundary. The wave plan lists src/config.ts, src/design-system.ts, src/server/index.ts and src/server/projects.ts for this item. The sticky load and the --root routing both live in src/server/stdio.ts (the finding cites stdio.ts:131-136 and :161), so this PR changes that file and leaves index.ts untouched; projects.ts gets one option, so a workspace's hosts use the same build check. No open PR and no other item in this wave touches stdio.ts. The review rounds also change src/workspace.ts (the marker list moved out, the new config option passed on to a monorepo's projects, and Workspace.without), docs/configuration.md (the reload note) and docs/clients.md (one paragraph in "How the server finds your project" on a --root that 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 --root project 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:

  • an invalid config at startup, fixed, then list_components and check_ui answer with the fixed config;
  • path mode: the .mjs config is refused, a path-less call answers from the other project that loaded, and after the switch to onsystem.config.json the call works;
  • a host whose first build fails builds again on the next get();
  • loadConfig reads an edited .mjs, .ts and CommonJS .js config again, and the server applies an edited .mjs config's rules while watching;
  • an edited CommonJS config is read again in a child Node.js process. The CommonJS case under Vitest fails on main too (the import URL is the same), but Vitest's module runner keeps no CommonJS module in require.cache, so it passes without the require.cache delete. The child process loads src/config.ts with 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;
  • --root on 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;
  • with such a --root, a path outside the folder is refused with the message above and loads nothing; a relative filename or path is 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 .mjs config in a project of the folder gets the --root <that project> error;
  • --root mono/apps at a pnpm workspace never applies mono's root config (the page still has 1 error) and never lists or cites packages/secret; the server logs that it uses mono/apps/web, never mono;
  • with a --root folder 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_ui with path or with code and a new filename, list_components, search_components), and nothing loaded from them;
  • a pnpm workspace in the folder whose packages name ../../outside: list_components, search_components and get_component without a path never show the outside package, and the warning names it and the --root that includes it;
  • with a --root folder 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, and get_component, list_components, get_tokens and search_components without a path (and ds://tokens) never show the outside component's prop or token, while Chip and the inside token still answer. Where the file system does not let a watched folder be moved, the test is skipped;
  • a project named in two letter cases loads once, so a call without a path answers from it (skipped where the file system is case-sensitive);
  • a .mjs config in a project found from a path, in a folder whose name has a space, is named quoted in the error;
  • zero config at such a folder reads none of the nested token files, and still reads them from the project itself or from a root with a 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; check run 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.js edited from off to "error" while watching: errorCount 0 before the edit, errorCount 1 3 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) and pnpm smoke (passed); build and smoke ran on 3d7748c, whose src is 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 corpus was 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 what check reports, 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 the package.json that the link leads to: Catalog in src/catalog.ts reads 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 changing src/catalog.ts.

  • ESLint plugin: edits to rules and overrides still 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 in src/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.

  • check run 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 changing src/cli.ts.

  • A project is kept by the spelling of its first path. check_ui with a path in another spelling of it (another letter case, or through a link inside the folder) is answered "... is outside the project root ()", as --root with such a path is on main; the other tools answer. Comparing real paths where src/server/index.ts makes a path relative to the project would accept both spellings.

  • A relative path with such a --root is refused. Reading it against the --root folder, as main did, would mean changing how src/server/index.ts resolves paths for a project found this way.

… 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.
@dgesteves
dgesteves merged commit 9cf2245 into main Oct 28, 2026
19 checks passed
@dgesteves
dgesteves deleted the fix/os2-47-server-robustness branch October 28, 2026 10:47
@dgesteves-release dgesteves-release Bot mentioned this pull request Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant