From 21a5b2695db6109317b904d145f3d89694190ad9 Mon Sep 17 00:00:00 2001 From: ru-code-dev <53821477+ru-code-dev@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:42:40 +0300 Subject: [PATCH] feat(ru-code): fix security error on secure storage check --- .gitignore | 3 +- apps/server/CHANGELOG.md | 29 - .../src/auth/Layers/ServerSecretStore.ts | 27 +- apps/server/src/auth/secretCrypto.ts | 11 +- .../src/ru-fork/mcp/mcpBuiltinDefinitions.ts | 89 +- .../Layers/ServerSecretStoreAutoheal.test.ts | 119 + .../mcp/builtinSeedingAccumulation.test.ts | 160 + .../mcp/builtinSeedingRemoveGuard.test.ts | 145 + .../ru-fork/mcp/builtinSeedingRestart.test.ts | 157 + .../tests/ru-fork/mcp/reactorWorker.test.ts | 36 +- .../src/ru-fork/advanced-chat/ToolStep.tsx | 4 +- .../src/ru-fork/advanced-chat/UserBubble.tsx | 12 +- mcp-probe/README.md | 100 - mcp-probe/monitor.mjs | 2 +- mcp-specs/current/ARCHITECTURE.md | 364 -- mcp-specs/current/AUDIT.md | 324 -- mcp-specs/current/DATA-MODEL.md | 236 - mcp-specs/current/FIXES.md | 379 -- mcp-specs/current/GAP-ANALYSIS.md | 128 - mcp-specs/current/GLOSSARY.md | 60 - mcp-specs/current/HANDOFF.md | 79 - mcp-specs/current/IMPLEMENTATION.md | 463 -- mcp-specs/current/README.md | 42 - mcp-specs/current/TESTING.md | 141 - mcp-specs/current/WORKING-LOGIC.md | 638 --- mcp-specs/current/check-mcp-feature.md | 107 - mcp-specs/current/improvments-banch-1.md | 750 --- mcp-specs/current/improvments-banch-2.md | 495 -- mcp-specs/current/improvments-banch-3.md | 1492 ------ mcp-specs/current/improvments-banch-4.md | 670 --- mcp-specs/legacy/mcp-final-plan.md | 4078 ----------------- mcp-specs/legacy/mcp-impl-progress.md | 90 - mcp-specs/legacy/mcp-progress.md | 170 - mcp-specs/legacy/mcp-vars-redesign.md | 381 -- pixso-move/STATUS.md | 129 - pixso-move/specs/00-overview.md | 164 - pixso-move/specs/01-scaffold.md | 209 - pixso-move/specs/02-contracts.md | 104 - pixso-move/specs/03-server.md | 214 - pixso-move/specs/04-processor.md | 144 - pixso-move/specs/05-embed.md | 90 - pixso-move/specs/06-plugin-build.md | 110 - pixso-move/specs/07-plugin-code.md | 123 - pixso-move/specs/08-plugin-ui.md | 103 - pixso-move/specs/09-end-to-end.md | 59 - pixso-move/specs/README.md | 85 - pixso-move/specs/conventions.md | 200 - 47 files changed, 699 insertions(+), 13016 deletions(-) delete mode 100644 apps/server/CHANGELOG.md create mode 100644 apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/builtinSeedingAccumulation.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/builtinSeedingRemoveGuard.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/builtinSeedingRestart.test.ts delete mode 100644 mcp-probe/README.md delete mode 100644 mcp-specs/current/ARCHITECTURE.md delete mode 100644 mcp-specs/current/AUDIT.md delete mode 100644 mcp-specs/current/DATA-MODEL.md delete mode 100644 mcp-specs/current/FIXES.md delete mode 100644 mcp-specs/current/GAP-ANALYSIS.md delete mode 100644 mcp-specs/current/GLOSSARY.md delete mode 100644 mcp-specs/current/HANDOFF.md delete mode 100644 mcp-specs/current/IMPLEMENTATION.md delete mode 100644 mcp-specs/current/README.md delete mode 100644 mcp-specs/current/TESTING.md delete mode 100644 mcp-specs/current/WORKING-LOGIC.md delete mode 100644 mcp-specs/current/check-mcp-feature.md delete mode 100644 mcp-specs/current/improvments-banch-1.md delete mode 100644 mcp-specs/current/improvments-banch-2.md delete mode 100644 mcp-specs/current/improvments-banch-3.md delete mode 100644 mcp-specs/current/improvments-banch-4.md delete mode 100644 mcp-specs/legacy/mcp-final-plan.md delete mode 100644 mcp-specs/legacy/mcp-impl-progress.md delete mode 100644 mcp-specs/legacy/mcp-progress.md delete mode 100644 mcp-specs/legacy/mcp-vars-redesign.md delete mode 100644 pixso-move/STATUS.md delete mode 100644 pixso-move/specs/00-overview.md delete mode 100644 pixso-move/specs/01-scaffold.md delete mode 100644 pixso-move/specs/02-contracts.md delete mode 100644 pixso-move/specs/03-server.md delete mode 100644 pixso-move/specs/04-processor.md delete mode 100644 pixso-move/specs/05-embed.md delete mode 100644 pixso-move/specs/06-plugin-build.md delete mode 100644 pixso-move/specs/07-plugin-code.md delete mode 100644 pixso-move/specs/08-plugin-ui.md delete mode 100644 pixso-move/specs/09-end-to-end.md delete mode 100644 pixso-move/specs/README.md delete mode 100644 pixso-move/specs/conventions.md diff --git a/.gitignore b/.gitignore index 13e95639f10..b4ffce787ff 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ staging .vscode dist-bundle ru-fork-instrumental -preflight.mjs \ No newline at end of file +preflight.mjs +project-specs \ No newline at end of file diff --git a/apps/server/CHANGELOG.md b/apps/server/CHANGELOG.md deleted file mode 100644 index 21300535dff..00000000000 --- a/apps/server/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# Change Log - @ru-code/ru-code - - - - - -## 3.0.2 - -Wed, 03 Jun 2026 01:39:08 GMT - -### Patches - -- feat(ru-code): install script improvments (53821477+ru-code-dev@users.noreply.github.com) - -## 3.0.1 - -Sun, 31 May 2026 23:42:25 GMT - -### Patches - -- feat(ru-code): fix install + error handling (53821477+ru-code-dev@users.noreply.github.com) - -## 3.0.0 - -Sun, 31 May 2026 10:51:07 GMT - -### Major changes - -- feat(ru-code): Russian localization + Qwen support + Cleanup (53821477+ru-code-dev@users.noreply.github.com) diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index fc96321cb2e..e65d25d1b6d 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -8,7 +8,7 @@ import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; -import { decryptSecret, encryptSecret } from "../secretCrypto.ts"; +import { decryptSecret, encryptSecret, isLegacySecretFormatError } from "../secretCrypto.ts"; import { SecretStoreError, ServerSecretStore, @@ -48,8 +48,11 @@ export const makeServerSecretStore = Effect.gen(function* () { }), ), ), - // ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null; a - // tampered/unwrappable blob becomes a typed SecretStoreError (Effect.try converts the throw). + // ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null. A + // LEGACY plaintext blob (a pre-encryption signing key — fails the iv:tag:cipher format check) is + // treated as ABSENT so getOrCreateRandom self-heals (mints a fresh encrypted key, no error/log). + // Any OTHER decrypt failure (GCM auth: tampered / wrong host-key) stays a typed SecretStoreError, + // so a real encrypted secret never silently vanishes. Effect.flatMap((bytes) => bytes === null ? Effect.succeed(null) @@ -57,7 +60,13 @@ export const makeServerSecretStore = Effect.gen(function* () { try: () => decryptSecret(Uint8Array.from(bytes)), catch: (cause) => new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }), - }), + }).pipe( + Effect.catch((error) => + isLegacySecretFormatError(error.cause) + ? Effect.succeed(null) + : Effect.fail(error), + ), + ), ), ); @@ -127,11 +136,11 @@ export const makeServerSecretStore = Effect.gen(function* () { Effect.flatMap((created) => created !== null ? Effect.succeed(created) - : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name} after concurrent creation.`, - }), - ), + : // ru-fork #4: the blocking file is present but UNREADABLE (a legacy plaintext + // secret — `get` healed it to null — not a concurrent writer's valid one), so the + // exclusive `create` can never overwrite it. Replace it atomically via `set` so a + // legacy signing key self-heals instead of stranding the boot. + set(name, generated).pipe(Effect.as(Uint8Array.from(generated))), ), ) : Effect.fail(error), diff --git a/apps/server/src/auth/secretCrypto.ts b/apps/server/src/auth/secretCrypto.ts index 11ef0392157..0719304559e 100644 --- a/apps/server/src/auth/secretCrypto.ts +++ b/apps/server/src/auth/secretCrypto.ts @@ -23,10 +23,19 @@ export function encryptSecret(plaintext: Uint8Array): Uint8Array { return new TextEncoder().encode(blob); } +// ru-fork #4: a blob that isn't our `ivHex:authTagHex:cipherHex` shape — i.e. a LEGACY plaintext secret +// written BEFORE at-rest encryption existed (e.g. a `server-signing-key` from an older build). Kept +// distinct from a GCM auth failure (tampered / wrong host-key) so the store can self-heal a legacy key +// by treating it as absent WITHOUT silently dropping a real encrypted secret that fails to authenticate. +export const LEGACY_SECRET_FORMAT_ERROR = "Invalid encrypted secret format"; + +export const isLegacySecretFormatError = (cause: unknown): boolean => + cause instanceof Error && cause.message === LEGACY_SECRET_FORMAT_ERROR; + export function decryptSecret(blob: Uint8Array): Uint8Array { const parts = new TextDecoder().decode(blob).split(":"); if (parts.length !== 3) { - throw new Error("Invalid encrypted secret format"); + throw new Error(LEGACY_SECRET_FORMAT_ERROR); } const iv = Buffer.from(parts[0]!, "hex"); const authTag = Buffer.from(parts[1]!, "hex"); diff --git a/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts b/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts index 83d5ae79ab6..9ff8aaed25b 100644 --- a/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts +++ b/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts @@ -23,48 +23,63 @@ export const MCP_BUILTINS: ReadonlyArray = [ vars: [], }, { - builtinId: "context7", - name: "context7", - description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.", - websiteUrl: "https://context7.com", - config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } }, - vars: [], - }, - { - builtinId: "atlassian", - name: "atlassian", + builtinId: "context7_local", + name: "Context 7 Local", description: - "Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.", - websiteUrl: "https://github.com/sooperset/mcp-atlassian", + "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.", + websiteUrl: "https://context7.com", config: { default: { transport: "stdio", - command: "uvx", - args: ["mcp-atlassian"], + command: "npx", + args: ["-y", "@upstash/context7-mcp"], }, }, - // All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the - // user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a - // real secret). Until the unfilled ones are set, the catalog server shows «требует настройки». - vars: [ - { - name: "JIRA_URL", - secret: false, - perProject: false, - required: true, - value: "https://your-company.atlassian.net/wiki", - }, - { name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null }, - { name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null }, - { - name: "CONFLUENCE_URL", - secret: false, - perProject: false, - required: true, - value: "https://your-company.atlassian.net/wiki", - }, - { name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null }, - { name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null }, - ], + vars: [], + }, + { + builtinId: "context7_remote", + name: "Context 7 Remote", + description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.", + websiteUrl: "https://context7.com", + config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } }, + vars: [], }, + // { + // builtinId: "atlassian", + // name: "atlassian", + // description: + // "Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.", + // websiteUrl: "https://github.com/sooperset/mcp-atlassian", + // config: { + // default: { + // transport: "stdio", + // command: "uvx", + // args: ["mcp-atlassian"], + // }, + // }, + // // All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the + // // user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a + // // real secret). Until the unfilled ones are set, the catalog server shows «требует настройки». + // vars: [ + // { + // name: "JIRA_URL", + // secret: false, + // perProject: false, + // required: true, + // value: "https://your-company.atlassian.net/wiki", + // }, + // { name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null }, + // { name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null }, + // { + // name: "CONFLUENCE_URL", + // secret: false, + // perProject: false, + // required: true, + // value: "https://your-company.atlassian.net/wiki", + // }, + // { name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null }, + // { name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null }, + // ], + // }, ]; diff --git a/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts b/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts new file mode 100644 index 00000000000..3ffb17affa0 --- /dev/null +++ b/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts @@ -0,0 +1,119 @@ +// ru-fork: spec for the legacy-plaintext AUTOHEAL of ServerSecretStore. +// +// Problem: ru-fork #4 added at-rest encryption (`decryptSecret`) to the WHOLE secret store, but +// `server-signing-key` predates it and shipped as RAW plaintext. On an upgrading machine `get()` now +// throws "Invalid encrypted secret format" (the 3-part `iv:tag:cipher` split fails), and because +// `getOrCreateRandom` calls `get()` first, it never reaches its regenerate path ⇒ the server can't boot. +// +// Fix: `get()` must treat an UNPARSEABLE / legacy blob as ABSENT (null) so the key self-heals +// (regenerated encrypted; only sessions reset). BUT it must do so ONLY for the *format* failure — a +// VALID-format blob that fails GCM authentication (tampered / wrong host-key / a real MCP credential +// that can't be unwrapped) must STILL error, never silently vanish. +// +// Test map: +// #1, #2 RED now → GREEN after the fix (legacy plaintext ⇒ null / autoheal) +// #3 GREEN now and after (proper round-trip still works — no regression) +// #4 GREEN now; stays GREEN ONLY for the SCOPED fix; a blanket `orElseSucceed(null)` turns it +// RED — this is the guardrail that forces "swallow the format error, not the GCM error." + +import { join } from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "../../../src/config.ts"; +import { encryptSecret } from "../../../src/auth/secretCrypto.ts"; +import { SecretStoreError, ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "../../../src/auth/Layers/ServerSecretStore.ts"; + +// ServerConfig is provideMerge'd so the test body can read `secretsDir` (to plant a raw .bin file) AND +// resolve the SAME store — a fresh `{prefix}` temp dir is minted once and shared by both. +const makeSharedLayer = () => + ServerSecretStoreLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-autoheal-test-" })), + ); + +// A pre-encryption key on disk: raw bytes with NO 0x3a (":"), so the `iv:tag:cipher` split yields one +// part ⇒ `decryptSecret` throws the FORMAT error (not a GCM error). Mirrors a real legacy signing key. +const LEGACY_PLAINTEXT_KEY = new Uint8Array(32).fill(7); + +// A VALID-format blob whose ciphertext has been flipped ⇒ GCM authentication fails at decrypt time. +// Stands in for a tampered file / wrong-host key / an MCP credential that genuinely can't be unwrapped. +function makeTamperedEncryptedBlob(): Uint8Array { + const text = new TextDecoder().decode(encryptSecret(Uint8Array.from([9, 8, 7, 6, 5]))); + const [ivHex, tagHex, cipherHex] = text.split(":"); + const lastChar = cipherHex!.at(-1); + const flippedCipher = cipherHex!.slice(0, -1) + (lastChar === "a" ? "b" : "a"); + return new TextEncoder().encode(`${ivHex}:${tagHex}:${flippedCipher}`); +} + +const plantSecretFile = (name: string, bytes: Uint8Array) => + Effect.gen(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(config.secretsDir, { recursive: true }); + yield* fileSystem.writeFile(join(config.secretsDir, `${name}.bin`), bytes); + }); + +it.layer(NodeServices.layer)("ServerSecretStore autoheal (legacy plaintext)", (it) => { + it.effect("#1 RED→GREEN: get() returns null for a legacy plaintext (unparseable) secret file", () => + Effect.gen(function* () { + yield* plantSecretFile("server-signing-key", LEGACY_PLAINTEXT_KEY); + const secretStore = yield* ServerSecretStore; + + // Today this THROWS SecretStoreError("Invalid encrypted secret format") ⇒ test fails. + // After the fix it must read the legacy blob as "absent". + const result = yield* secretStore.get("server-signing-key"); + expect(result).toBeNull(); + }).pipe(Effect.provide(makeSharedLayer())), + ); + + it.effect("#2 RED→GREEN: getOrCreateRandom self-heals over a legacy plaintext signing key", () => + Effect.gen(function* () { + yield* plantSecretFile("server-signing-key", LEGACY_PLAINTEXT_KEY); + const secretStore = yield* ServerSecretStore; + + // Must succeed by minting a FRESH encrypted key (not crash on the legacy blob). + const key = yield* secretStore.getOrCreateRandom("server-signing-key", 32); + expect(key.length).toBe(32); + expect(Array.from(key)).not.toEqual(Array.from(LEGACY_PLAINTEXT_KEY)); // a new key, not the legacy bytes + + // The regenerated key is now persisted encrypted-at-rest and reads back identically + stably. + const reread = yield* secretStore.get("server-signing-key"); + expect(reread).not.toBeNull(); + expect(Array.from(reread ?? new Uint8Array())).toEqual(Array.from(key)); + const again = yield* secretStore.getOrCreateRandom("server-signing-key", 32); + expect(Array.from(again)).toEqual(Array.from(key)); // healed once, then stable + }).pipe(Effect.provide(makeSharedLayer())), + ); + + it.effect("#3 GUARDRAIL (no regression): a properly encrypted secret still round-trips", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const value = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); + + yield* secretStore.set("mcp-credential-good", value); + const roundTripped = yield* secretStore.get("mcp-credential-good"); + + expect(roundTripped).not.toBeNull(); + expect(Array.from(roundTripped ?? new Uint8Array())).toEqual(Array.from(value)); + }).pipe(Effect.provide(makeSharedLayer())), + ); + + it.effect("#4 GUARDRAIL (scoped fix): a tampered/wrong-key (GCM auth) secret STILL errors, never null", () => + Effect.gen(function* () { + yield* plantSecretFile("mcp-credential", makeTamperedEncryptedBlob()); + const secretStore = yield* ServerSecretStore; + + // Valid `iv:tag:cipher` shape but the GCM tag no longer matches ⇒ this is NOT the format error. + // It must surface as a typed failure — a blanket swallow-to-null would silently drop a real + // MCP credential, which this test forbids. + const error = yield* Effect.flip(secretStore.get("mcp-credential")); + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to decrypt secret mcp-credential"); + }).pipe(Effect.provide(makeSharedLayer())), + ); +}); diff --git a/apps/server/tests/ru-fork/mcp/builtinSeedingAccumulation.test.ts b/apps/server/tests/ru-fork/mcp/builtinSeedingAccumulation.test.ts new file mode 100644 index 00000000000..307cf1c1ea2 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/builtinSeedingAccumulation.test.ts @@ -0,0 +1,160 @@ +// ru-fork: REPRO for the built-in seeding bug — "add a built-in, restart → it appears; add a SECOND +// built-in, restart → the FIRST disappears; you must restart twice to see both." +// +// These drive the REAL reactor reconcile loop (`reconcileBuiltinsWith`) through the REAL engine +// (decider → event → SQL projection), varying the shipped-definition list the way editing +// `mcpBuiltinDefinitions.ts` + restarting does. Each `reconcile([...])` call IS one app restart. +// +// The assertions encode the CORRECT behaviour: the shipped set is the source of truth, so after a +// reconcile the catalog must contain EXACTLY the shipped built-ins — every one of them, in a SINGLE +// pass. On the buggy build these go RED (the first built-in is dropped / a second restart is needed); +// on a correct build they are green. Pure seeding-side: if these PASS but the UI still shows one +// server, the fault is the client projection push, not seeding (see the SQL diagnostic in the report). + +import type { McpCatalogServer } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpCatalogRepository } from "../../../src/persistence/Services/McpCatalog.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import type { McpBuiltinDefinition } from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { reconcileBuiltinsWith } from "../../../src/ru-fork/mcp/McpReactor.ts"; + +// Two CLEARLY distinct built-ins: different builtinId, different config (different args ⇒ different +// configIdentity ⇒ different serverId). No platform skip (`default` variant), no identity collision. +// On a correct build, shipping both MUST yield both — nothing here justifies dropping either. +const ALPHA: McpBuiltinDefinition = { + builtinId: "alpha", + name: "alpha", + description: "Alpha built-in", + config: { default: { transport: "stdio", command: "uvx", args: ["alpha"] } }, + vars: [], +}; +const BETA: McpBuiltinDefinition = { + builtinId: "beta", + name: "beta", + description: "Beta built-in", + config: { default: { transport: "stdio", command: "uvx", args: ["beta"] } }, + vars: [], +}; +const GAMMA: McpBuiltinDefinition = { + builtinId: "gamma", + name: "gamma", + description: "Gamma built-in", + config: { default: { transport: "stdio", command: "uvx", args: ["gamma"] } }, + vars: [], +}; + +const builtinIds = (catalog: ReadonlyArray): ReadonlyArray => + catalog + .map((server) => server.builtinId) + .filter((id): id is string => id !== null) + .toSorted(); + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-seed-test-" }); + const layer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + // Merge the pipeline too so its McpCatalogRepository output is queryable from the runtime. + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + return { + // One restart with a given shipped-definition list (the real reconcile loop = the testability seam). + reconcile: (definitions: ReadonlyArray) => + runtime.runPromise(reconcileBuiltinsWith(definitions, process.platform)), + catalog: (): Promise> => + runtime.runPromise( + Effect.flatMap(Effect.service(McpCatalogRepository), (repo) => repo.listAll()), + ), + // The engine must be live (its command worker processes the reconcile's dispatches). + warmEngine: () => + runtime.runPromise(Effect.flatMap(Effect.service(OrchestrationEngineService), () => Effect.void)), + dispose: () => runtime.dispose(), + }; +} + +describe("built-in seeding accumulates across restarts (Problem 1 repro)", () => { + let system: ReturnType; + beforeEach(async () => { + system = makeSystem(); + await system.warmEngine(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("adding a SECOND built-in across restarts keeps the FIRST (the exact symptom)", async () => { + // Restart 1: ship only ALPHA → it appears. + await system.reconcile([ALPHA]); + expect(builtinIds(await system.catalog())).toEqual(["alpha"]); + + // Restart 2: edit mcpBuiltinDefinitions.ts to add BETA, restart. ALPHA must NOT vanish. + await system.reconcile([ALPHA, BETA]); + expect(builtinIds(await system.catalog())).toEqual(["alpha", "beta"]); + }); + + it("shipping two built-ins appears in ONE reconcile, not two (no second-restart needed)", async () => { + // Single restart from an empty catalog with both shipped → both present immediately. + await system.reconcile([ALPHA, BETA]); + expect(builtinIds(await system.catalog())).toEqual(["alpha", "beta"]); + }); + + it("a redundant restart with the SAME shipped list is stable (no churn, no loss)", async () => { + await system.reconcile([ALPHA, BETA]); + // "Restart twice": an identical reconcile must neither drop nor duplicate anything. + await system.reconcile([ALPHA, BETA]); + const catalog = await system.catalog(); + expect(builtinIds(catalog)).toEqual(["alpha", "beta"]); + // No duplicate rows for the same built-in. + expect(catalog.filter((s) => s.builtinId === "alpha")).toHaveLength(1); + expect(catalog.filter((s) => s.builtinId === "beta")).toHaveLength(1); + }); + + it("growing the shipped list one at a time accumulates EVERY built-in", async () => { + await system.reconcile([ALPHA]); + expect(builtinIds(await system.catalog())).toEqual(["alpha"]); + + await system.reconcile([ALPHA, BETA]); + expect(builtinIds(await system.catalog())).toEqual(["alpha", "beta"]); + + await system.reconcile([ALPHA, BETA, GAMMA]); + expect(builtinIds(await system.catalog())).toEqual(["alpha", "beta", "gamma"]); + }); + + it("adding a NEW built-in never collaterally removes an already-installed sibling", async () => { + await system.reconcile([ALPHA]); + const before = await system.catalog(); + const alphaBefore = before.find((s) => s.builtinId === "alpha"); + expect(alphaBefore).toBeDefined(); + + // Ship BETA alongside ALPHA; ALPHA's row must survive untouched (same id, same config). + await system.reconcile([ALPHA, BETA]); + const after = await system.catalog(); + const alphaAfter = after.find((s) => s.builtinId === "alpha"); + expect(alphaAfter).toBeDefined(); + expect(alphaAfter?.id).toBe(alphaBefore?.id); + expect(alphaAfter?.config).toEqual(alphaBefore?.config); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/builtinSeedingRemoveGuard.test.ts b/apps/server/tests/ru-fork/mcp/builtinSeedingRemoveGuard.test.ts new file mode 100644 index 00000000000..ad0eb0f9a5a --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/builtinSeedingRemoveGuard.test.ts @@ -0,0 +1,145 @@ +// ru-fork: PINPOINT tests for the remove loop (McpReactor.ts:168) — the only path that makes a +// built-in "disappear." The other two files assert the catalog END STATE (a vanished built-in fails +// them); this file additionally OBSERVES THE EVENT LOG, so it proves WHICH loop fired. It directly +// asserts the invariant Loop 2 must uphold: **a still-shipped built-in is never `mcp.server-remove`d.** +// +// Covers the three ways the first built-in can be wrongly deleted (see the loop analysis): +// 1. `shippedBuiltinIds` built wrong (e.g. scoped inside the loop ⇒ only the last def is "shipped"). +// 2. platform-skip: a def with no variant for the current OS skips `.add()` ⇒ a collateral remove. +// 3. builtinId mismatch: the projected `builtin_id` must round-trip the shipped id, or membership fails. + +import type { McpCatalogServer, OrchestrationEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Stream from "effect/Stream"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpCatalogRepository } from "../../../src/persistence/Services/McpCatalog.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import type { McpBuiltinDefinition } from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { reconcileBuiltinsWith } from "../../../src/ru-fork/mcp/McpReactor.ts"; + +const ALPHA: McpBuiltinDefinition = { + builtinId: "alpha", + name: "alpha", + config: { default: { transport: "stdio", command: "uvx", args: ["alpha"] } }, + vars: [], +}; +const BETA: McpBuiltinDefinition = { + builtinId: "beta", + name: "beta", + config: { default: { transport: "stdio", command: "uvx", args: ["beta"] } }, + vars: [], +}; +// A built-in with a variant ONLY for win32 — null under darwin/linux (exercises the platform-skip path). +const WIN_ONLY: McpBuiltinDefinition = { + builtinId: "winonly", + name: "winonly", + config: { win32: { transport: "stdio", command: "uvx", args: ["winonly"] } }, + vars: [], +}; + +const builtinIds = (catalog: ReadonlyArray): ReadonlyArray => + catalog + .map((server) => server.builtinId) + .filter((id): id is string => id !== null) + .toSorted(); + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-rmguard-test-" }); + const layer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + return { + // `platform` is overridable so the platform-skip path can be driven deterministically. + reconcile: (definitions: ReadonlyArray, platform: NodeJS.Platform = process.platform) => + runtime.runPromise(reconcileBuiltinsWith(definitions, platform)), + catalog: (): Promise> => + runtime.runPromise( + Effect.flatMap(Effect.service(McpCatalogRepository), (repo) => repo.listAll()), + ), + // The full event log — lets us see exactly which mcp.* events the reconcile produced. + events: (): Promise> => + runtime.runPromise( + Effect.flatMap(Effect.service(OrchestrationEngineService), (engine) => + Stream.runCollect(engine.readEvents(0)).pipe( + Effect.map((chunk): ReadonlyArray => Array.from(chunk)), + ), + ), + ), + dispose: () => runtime.dispose(), + }; +} + +const removedServerIds = (events: ReadonlyArray): ReadonlyArray => + events.filter((e) => e.type === "mcp.server-removed").map((e) => (e as { payload: { serverId: string } }).payload.serverId); + +describe("the remove loop never deletes a still-shipped built-in", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("failure mode #1: adding a second built-in emits NO server-remove (shippedBuiltinIds holds BOTH)", async () => { + await system.reconcile([ALPHA]); + await system.reconcile([ALPHA, BETA]); // the moment the first is reported to disappear + expect(builtinIds(await system.catalog())).toEqual(["alpha", "beta"]); + // The smoking gun: a removal here means Loop 2 saw alpha as "not shipped". + expect(removedServerIds(await system.events())).toEqual([]); + }); + + it("failure mode #2: a NEW built-in never collaterally removes a sibling supported on this OS", async () => { + await system.reconcile([ALPHA]); + const beforeEvents = (await system.events()).length; + await system.reconcile([ALPHA, BETA]); + // No server-removed event at all, and certainly not for alpha. + expect(removedServerIds(await system.events())).not.toContain("srv-builtin-alpha"); + // (sanity) the second reconcile only ADDED — it did not churn the first. + expect((await system.events()).length).toBeGreaterThan(beforeEvents); + }); + + it("platform-skip removes ONLY the unsupported built-in, not the supported sibling", async () => { + // Install winonly as if on Windows, alongside alpha. + await system.reconcile([ALPHA, WIN_ONLY], "win32"); + expect(builtinIds(await system.catalog())).toEqual(["alpha", "winonly"]); + + // Now reconcile on darwin: winonly has no variant ⇒ correctly removed; alpha MUST stay. + await system.reconcile([ALPHA, WIN_ONLY], "darwin"); + expect(builtinIds(await system.catalog())).toEqual(["alpha"]); + expect(removedServerIds(await system.events())).toEqual(["srv-builtin-winonly"]); // exactly one, the right one + }); + + it("failure mode #3: the projected builtin_id round-trips the shipped id (membership can't miss)", async () => { + await system.reconcile([ALPHA, BETA]); + const catalog = await system.catalog(); + // If the projector dropped/altered builtin_id, Loop 2's `shippedBuiltinIds.has(installed.builtinId)` + // would silently fail and delete on the next reconcile. Pin the contract. + expect(catalog.find((s) => s.id === "srv-builtin-alpha")?.builtinId).toBe("alpha"); + expect(catalog.find((s) => s.id === "srv-builtin-beta")?.builtinId).toBe("beta"); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/builtinSeedingRestart.test.ts b/apps/server/tests/ru-fork/mcp/builtinSeedingRestart.test.ts new file mode 100644 index 00000000000..0840135ac4e --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/builtinSeedingRestart.test.ts @@ -0,0 +1,157 @@ +// ru-fork: REPRO for the built-in seeding bug ACROSS A REAL RESTART. +// +// `builtinSeedingAccumulation.test.ts` reconciles repeatedly on ONE live runtime — it never re-runs +// the projection bootstrap, so it cannot catch a bug in the replay/checkpoint path. This file closes +// that gap: each `bootSystem(baseDir)` builds a FRESH runtime over the SAME on-disk sqlite file, so +// rebuilding it = a real process restart (the engine's `projectionPipeline.bootstrap` replays the +// persisted event log from each projector's checkpoint, exactly like a cold boot). +// +// Sharing the DB file is done by passing a FIXED-STRING baseDir to `ServerConfig.layerTest` (a string +// baseDir is used verbatim; a `{prefix}` would mint a fresh temp dir each time) plus the file-backed +// `layerConfig` instead of the in-memory sqlite layer. +// +// The assertions encode the CORRECT behaviour: a built-in seeded in one boot MUST still be in the +// catalog after the next boot's bootstrap replay (durability), and adding a SECOND built-in on a later +// boot must leave the first in place — visible in a SINGLE restart, not two. These go RED on the buggy +// build ("first disappears" / "restart twice"); green on a correct one. + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { McpCatalogServer } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { layerConfig } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpCatalogRepository } from "../../../src/persistence/Services/McpCatalog.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import type { McpBuiltinDefinition } from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { reconcileBuiltinsWith } from "../../../src/ru-fork/mcp/McpReactor.ts"; + +const ALPHA: McpBuiltinDefinition = { + builtinId: "alpha", + name: "alpha", + description: "Alpha built-in", + config: { default: { transport: "stdio", command: "uvx", args: ["alpha"] } }, + vars: [], +}; +const BETA: McpBuiltinDefinition = { + builtinId: "beta", + name: "beta", + description: "Beta built-in", + config: { default: { transport: "stdio", command: "uvx", args: ["beta"] } }, + vars: [], +}; + +const builtinIds = (catalog: ReadonlyArray): ReadonlyArray => + catalog + .map((server) => server.builtinId) + .filter((id): id is string => id !== null) + .toSorted(); + +// One boot of the server against the on-disk DB at `baseDir`. Building this layer runs the engine's +// `projectionPipeline.bootstrap` (replay from checkpoints) — i.e. it IS a process restart. +function bootSystem(baseDir: string) { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), baseDir); + const layer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(layerConfig), // file-backed sqlite — SHARED across boots + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + return { + reconcile: (definitions: ReadonlyArray) => + runtime.runPromise(reconcileBuiltinsWith(definitions, process.platform)), + catalog: (): Promise> => + runtime.runPromise( + Effect.flatMap(Effect.service(McpCatalogRepository), (repo) => repo.listAll()), + ), + dispose: () => runtime.dispose(), + }; +} + +describe("built-in seeding survives a real restart (bootstrap replay)", () => { + let baseDir: string; + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "t3-mcp-restart-")); + }); + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + it("ALPHA persists across a restart, then adding BETA on the next boot keeps BOTH", async () => { + // Boot 1: ship only ALPHA. + const boot1 = bootSystem(baseDir); + await boot1.reconcile([ALPHA]); + expect(builtinIds(await boot1.catalog())).toEqual(["alpha"]); + await boot1.dispose(); + + // Boot 2 = real restart. The bootstrap replay MUST have ALPHA before we touch anything. + const boot2 = bootSystem(baseDir); + expect(builtinIds(await boot2.catalog())).toEqual(["alpha"]); // durability across restart + // Now edit mcpBuiltinDefinitions.ts to add BETA and reconcile. + await boot2.reconcile([ALPHA, BETA]); + expect(builtinIds(await boot2.catalog())).toEqual(["alpha", "beta"]); // ALPHA must NOT vanish + await boot2.dispose(); + + // Boot 3 = another restart, same shipped list: both still there (no "restart twice"). + const boot3 = bootSystem(baseDir); + expect(builtinIds(await boot3.catalog())).toEqual(["alpha", "beta"]); + await boot3.dispose(); + }); + + it("a plain restart with no definition change neither drops nor duplicates built-ins", async () => { + const boot1 = bootSystem(baseDir); + await boot1.reconcile([ALPHA, BETA]); + expect(builtinIds(await boot1.catalog())).toEqual(["alpha", "beta"]); + await boot1.dispose(); + + const boot2 = bootSystem(baseDir); + // Replayed catalog before reconcile. + expect(builtinIds(await boot2.catalog())).toEqual(["alpha", "beta"]); + // Idempotent re-seed of the same list. + await boot2.reconcile([ALPHA, BETA]); + const catalog = await boot2.catalog(); + expect(builtinIds(catalog)).toEqual(["alpha", "beta"]); + expect(catalog.filter((s) => s.builtinId === "alpha")).toHaveLength(1); + expect(catalog.filter((s) => s.builtinId === "beta")).toHaveLength(1); + await boot2.dispose(); + }); + + it("the catalog the UI sees right after EACH restart matches the shipped set", async () => { + // Mirrors the user's narration: each boot's first catalog read = what the panel shows on open. + const seen: Record> = {}; + + const boot1 = bootSystem(baseDir); + await boot1.reconcile([ALPHA]); // ship #1 + seen.afterBoot1 = builtinIds(await boot1.catalog()); + await boot1.dispose(); + + const boot2 = bootSystem(baseDir); + await boot2.reconcile([ALPHA, BETA]); // ship #2 + seen.afterBoot2 = builtinIds(await boot2.catalog()); + await boot2.dispose(); + + expect(seen.afterBoot1).toEqual(["alpha"]); + expect(seen.afterBoot2).toEqual(["alpha", "beta"]); // NOT ["beta"], NOT needing a 3rd boot + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts b/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts index a6d82add8d5..3e636317b73 100644 --- a/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts +++ b/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts @@ -37,13 +37,21 @@ import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestra import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; import { ServerConfig } from "../../../src/config.ts"; import { ServerSettingsService } from "../../../src/serverSettings.ts"; -import { builtinServerId } from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { builtinConfigForPlatform, builtinServerId } from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { MCP_BUILTINS } from "../../../src/ru-fork/mcp/mcpBuiltinDefinitions.ts"; import { McpOverlayLive } from "../../../src/ru-fork/mcp/McpOverlay.ts"; import { McpReactor, McpReactorLive } from "../../../src/ru-fork/mcp/McpReactor.ts"; import { McpSupervisor, McpSupervisorLive } from "../../../src/ru-fork/mcp/McpSupervisor.ts"; const ISO = "2026-01-01T00:00:00.000Z"; +// ru-fork: the built-in ids that reconcileBuiltins will actually seed on THIS platform (a definition +// with no variant for the current OS is skipped). Derived from the shipped list so these tests track +// edits to mcpBuiltinDefinitions.ts (add/remove/rename a built-in) instead of hard-coding names/counts. +const SEEDED_BUILTIN_IDS = MCP_BUILTINS.filter( + (definition) => builtinConfigForPlatform(definition, process.platform) !== null, +).map((definition) => definition.builtinId); + // packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs — real stdio MCP server (echo + ping). const FAKE_SERVER = path.resolve( fileURLToPath(import.meta.url), @@ -176,8 +184,13 @@ describe("McpReactor worker + start lifecycle (integration)", () => { expect(result.instances.length).toBeGreaterThan(0); expect(result.instances.every((i) => i.status === "unchecked")).toBe(true); expect(result.inFlight.size).toBe(0); - // The shipped built-ins were seeded into the catalog by start(). - expect(result.instances.some((i) => i.refs.has(`catalog:${builtinServerId("filesystem")}`))).toBe(true); + // The shipped built-ins were seeded into the catalog by start() (at least one complete built-in + // registers a catalog instance — derived from the shipped list, not a hard-coded name). + expect( + SEEDED_BUILTIN_IDS.some((builtinId) => + result.instances.some((instance) => instance.refs.has(`catalog:${builtinServerId(builtinId)}`)), + ), + ).toBe(true); }, 25_000); it("a post-startup server-add flows engine → stream → worker → probe and goes ONLINE", async () => { @@ -207,9 +220,11 @@ describe("McpReactor worker + start lifecycle (integration)", () => { }, 25_000); it("project.created autobinds every built-in through the worker when autobindDefaults is ON", async () => { - // Disable the two always-complete built-ins so their autobound bindings don't trigger real - // npx/http probes — we're asserting the WIRING (bindings created), not probe outcomes. + // Disable EVERY shipped built-in first so their autobound (enabled) bindings don't trigger real + // npx/http probes — we're asserting the WIRING (one enabled binding per built-in), not probe + // outcomes. The set is derived from MCP_BUILTINS, so this stays correct when the list changes. system = makeSystem({ mcp: { autobindDefaults: true } }); + const expectedBuiltinCount = SEEDED_BUILTIN_IDS.length; const bindings = await system.run( Effect.gen(function* () { const reactor = yield* McpReactor; @@ -218,20 +233,21 @@ describe("McpReactor worker + start lifecycle (integration)", () => { yield* reactor.start(); yield* reactor.drain; - yield* engine.dispatch(setEnabled(builtinServerId("filesystem"), false)); - yield* engine.dispatch(setEnabled(builtinServerId("context7"), false)); + yield* Effect.forEach(SEEDED_BUILTIN_IDS, (builtinId) => + engine.dispatch(setEnabled(builtinServerId(builtinId), false)), + ); yield* reactor.drain; yield* engine.dispatch(createProject("proj-ab")); return yield* waitUntil( bindingRepository.listByProject({ projectId: ProjectId.make("proj-ab") }), - (rows) => rows.length >= 3, + (rows) => rows.length >= expectedBuiltinCount, ); }), ); - // All three shipped built-ins (filesystem, context7, atlassian) are autobound, enabled. - expect(bindings.length).toBe(3); + // Every shipped (platform-supported) built-in is autobound, enabled. + expect(bindings.length).toBe(expectedBuiltinCount); expect(bindings.every((binding) => binding.enabled)).toBe(true); }, 25_000); diff --git a/apps/web/src/ru-fork/advanced-chat/ToolStep.tsx b/apps/web/src/ru-fork/advanced-chat/ToolStep.tsx index f88f6540bac..941e2921447 100644 --- a/apps/web/src/ru-fork/advanced-chat/ToolStep.tsx +++ b/apps/web/src/ru-fork/advanced-chat/ToolStep.tsx @@ -118,9 +118,9 @@ function FileDiffView({ return (
- {files.map((fileDiff, index) => ( + {files.map((fileDiff) => ( ( - - - -``` -(Backend cascade + secret GC already exist; B4 cleans overlays.) The delete affordance never appears for -built-ins (gated above), so the modal copy needs no built-in special case. - -### ⑬ Catalog: **disable switch** (uses B5) -*Files: `RegistryTab` row **and** `RegistryDetail` header* (decision: place it in **both**). A -`Switch checked={server.enabled} onCheckedChange={(v) => setServerEnabled(server.id, Boolean(v))}` — one on -every catalog **list row** (toggle without opening the server) and one in the **detail header**. Requires -`McpRegistryServer.enabled` (map from `server.enabled` in the adapter). When off, the row is visibly dimmed -(`opacity-50`); backend B5 excludes it from probe + overlay; the project side grays per ⑬(project side). - -### ⑭ Catalog list: **right-click** context menu (refresh / edit / delete) -*File: `RegistryTab.tsx`.* Use the app's **existing cross-platform** context-menu — `api.contextMenu.show(...)` -via `readLocalApi()` (`localApi.ts` → `window.desktopBridge.showContextMenu` in Electron, `showContextMenuFallback` -on web). **Same logic as the sidebar's thread/project menus — zero custom code, identical in Electron and web.** -Items are `ContextMenuItem` (`{ id, label, destructive?, disabled? }` from `@t3tools/contracts`). -```tsx -// imports: import { readLocalApi } from "~/localApi"; (the `~` alias = apps/web/src, already used in this file) -// on the row ` (keeps the collapse button too). -- **⑦** show the server description: replace/augment the `detail` line — when not incomplete and - `server.description` is set, show it (the health line can move into the collapse). Concretely render a - second muted line `{server.description}` under the name when present. -- **⑧** «Показать в каталоге» is a button that **sits directly next to the «Убрать из проекта» (remove) - button**, same size/variant, as a pair (in the collapse, where the remove button lives): - `` (pull `selectServer`/`setActiveTab` from `useMcpManagerStore`). -- **⑬(project side) — disabled server shows grayed, not removed (B5(c)):** when `server.enabled === false`, - wrap the row in `opacity-50` and render an «отключён в каталоге» hint with a neutral dot (no error). The - binding stays listed; re-enabling in the catalog restores it. (`server.enabled` is on `McpRegistryServer`.) - -### ⑩ Project row: **right-click** context menu (refresh / show-in-catalog / delete) -*File: `ProjectBindingRow.tsx`.* Same `api.contextMenu.show(...)` pattern as ⑭ — `onContextMenu` on the row's -header `
` (line 47). **No `edit` item** — per-project edit stays on the pencil affordance (⑤), so the -menu needs **no** control over `ProjectConfigDialog` (which owns its own `open` state via a trigger and -exposes no `open`/`onOpenChange` props; adding controlled props just to duplicate the pencil would be dead -weight). Three items + dispatch — all use store/mutation setters already in scope, zero new wiring: -```tsx -// imports: import { readLocalApi } from "~/localApi"; import type { ContextMenuItem } from "@t3tools/contracts"; -const handleRowContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - void (async () => { - const clicked = await readLocalApi().contextMenu.show( - [ - { id: "recheck", label: "Проверить", disabled: binding.incomplete }, - { id: "show", label: "Показать в каталоге" }, - { id: "delete", label: "Убрать из проекта", destructive: true }, - ] satisfies ContextMenuItem<"recheck" | "show" | "delete">[], - { x: event.clientX, y: event.clientY }, - ); - if (clicked === "recheck") void recheck({ projectId: binding.projectId, serverId: server.id }); - else if (clicked === "show") { selectServer(server.id); setActiveTab("registry"); } - else if (clicked === "delete") removeBinding(server.id, binding.projectId); - })(); -}; -``` -(`recheck`/`removeBinding` from `useMcpMutations()`; `selectServer`/`setActiveTab` from `useMcpManagerStore` -— already pulled in for ⑧. To edit per-project, the user clicks the pencil (⑤) — `ProjectConfigDialog` -stays trigger-driven and untouched.) - ---- - -## Part 3 — ⑱/⑲ escape-hatch fields (dialog): `extraArgs` (stdio) + `extraHeaders` (http), templates only -*File: `McpServerDialog.tsx`.* Both are escape hatches for **locked templates** (a manual server edits -`args`/`headers` directly), so both are **template-only**. - -**⑱ — gate `ExtraArgsField` on a locked template:** -```tsx -// BEFORE -{showExtraArgs && ( - -)} -// AFTER (stdio AND a locked template only) -{showExtraArgs && isTemplate && ( - -)} -``` - -**⑲ — show `ExtraHeadersField` for a locked http template** (the B6 UI half). Add a new `extraHeadersText` -state (init from `(server?.extraHeaders … )`, formatted `Key: Value` per line, like `serverConfigForm`'s -header parsing), a `showExtraHeaders = draft.transport === "http"` flag, and render beside the vars editor: -```tsx -{showExtraHeaders && isTemplate && ( - -)} -``` -`ExtraHeadersField` is a new tiny component mirroring `ExtraArgsField` but using a `Textarea` and -`Key: Value` lines. **Helper sources (verified):** `parseHeaderLines(text): Record` is -exported from **`./addMcpParsing`** (NOT `serverConfigForm`) — import it from there. `recordToLines(record, -separator)` is currently a **private** helper in `serverConfigForm.ts` (line 31) — **add `export`** to it -and call `recordToLines(value, ": ")` to format (same call `draftFromConfig` already makes at -`serverConfigForm.ts:51`). `Textarea` is imported from `~/components/ui/textarea` (already imported in -`McpServerDialog.tsx`). The dialog's `handleSubmit` parses the text via `parseHeaderLines` to a -`Record` and sends it as `extraHeaders` (B6 `useMcp` wiring). -**Risk:** none for ⑱ (manual loses a redundant field); ⑲ is additive (B6). - ---- - -## Future improvements (design captured, not built now) - -> Branch-1 ships the **catalog-level** `extraArgs` (⑱, already in the model) and `extraHeaders` (B6/⑲). The -> future work is the **per-project (binding-level)** versions — a project customizes args/headers *without -> forking* the catalog server. - -### FI-1 — per-project `extraArgs` (on the binding) -A binding carries its own `extraArgs`, appended after the **catalog** `extraArgs` + template args. It keeps -the **catalog identity** (never forked) but mints a **distinct `configCacheKey`/`dedupHash`** → its own probe -instance + cache row (exactly like a per-project var value), and still inherits template changes. Touch -points: `McpBinding` + `McpBindingPatch` += `extraArgs`; pass `[...server.extraArgs, ...binding.extraArgs]` at -the binding call sites (`McpReactor.mergeDesired`, `McpOverlay.writeOverlay`); a per-project `ExtraArgsField` -in `ProjectConfigDialog`. The dedup model already supports this cleanly. - -### FI-2 — per-project `extraHeaders` (on the binding, http) -The http analogue of FI-1: a binding adds/overrides headers on top of the catalog template + catalog -`extraHeaders`, keeping catalog identity and spawning its own probe. Touch points: `McpBinding`/ -`McpBindingPatch` += `extraHeaders`; `resolveConfig` (http branch) already merges `extraHeaders` (B6) — feed -`{ ...server.extraHeaders, ...binding.extraHeaders }`; folded into `configCacheKey`; a per-project headers -editor in `ProjectConfigDialog`. Ships **together with FI-1** as the per-project-customization feature. - -*(Per your decision: **no `extraHeaders` at all in branch-1** — not catalog-level, not anywhere. The only -`extraHeaders` we will ever add is this per-project one, and it's future. A locked http built-in that needs -auth today uses a shipped secret var referenced in a header.)* - ---- - -## Summary & sequencing - -| # | Item | Type | Where | -|---|---|---|---| -| F1 | complete binding never probed | bug | McpSupervisor.reconcile | -| F2 | incomplete binding blue→gray | nit | adapters.bindingToUi | -| B1 ⑨⑰ | hard error → red | bug | nextStatus (+probe.timedOut) | -| B2 ① | appear-after-F5 | bug | R5 (done) + confirm | -| B3 ② | auto description + docs link, only-if-empty (built-in shipped link → probed → empty) | feature | probe→cache→reactor backfill→adapter; new catalog `websiteUrl` col + `McpBuiltinDefinition.websiteUrl?` | -| B4 ③ | overlay cleanup | bug | McpOverlay.removeOverlay + reactor on project.deleted | -| B5 ⑬ | catalog enabled flag (disable = grayed at project level, not removed) | feature | contracts+migration+reactor+overlay+UI | -| B6 ⑲ | catalog extraHeaders (http templates) | feature | contracts+migration+resolver+builders+UI | -| ⑪ | list status dot+error | ui | RegistryTab + adapter.message | -| ⑫ | detail error text | ui | RegistryDetail | -| ③ | delete + confirm modal (**custom servers only — no delete for built-ins**) | ui | RegistryDetail + useMcp.removeServer | -| ⑭ | catalog right-click menu (delete item gated to custom) | ui | RegistryTab (api.contextMenu.show) | -| ⑮ | refresh disabled if incomplete | ui | RegistryDetail + ProjectBindingRow | -| ⑯ | builtin lock **name + description**/hide transport | ui | McpServerDialog + ServerConfigFields | -| ④ | refresh-all in projects tab | ui | ProjectsTab + McpPanel | -| ⑤⑥⑦⑧⑩⑬ | project row: pencil/delete/desc/show-in-catalog(next to remove)/menu/grayed-when-disabled | ui | ProjectBindingRow | -| ⑱⑲ | extraArgs/extraHeaders fields, template-only | ui | McpServerDialog + ExtraHeadersField | -| FI-1/2 | per-project args/headers | future | — | - -**Order:** Phase 1 = backend bugs/data (F1, F2, B1, B4, B5, B6, B3) — gate. Phase 2 = catalog UI (⑪ ⑫ ③ ⑬ ⑭ ⑮ ⑯) -— gate. Phase 3 = projects UI (④ ⑤ ⑥ ⑦ ⑧ ⑩) + ⑱⑲ dialog fields — gate. B2 is just confirmation. FI-1/2 deferred. -**Three contract+migration changes** (B3 probe-cache cols + catalog `websiteUrl`; B5 catalog `enabled`; B6 -catalog `extraHeaders`) — all edit the single `031` migration in place. **New mutations:** `removeServer` (③), -`setServerEnabled` (⑬). **New component:** `ExtraHeadersField` (⑲) only. -**Right-click menus (⑭/⑩):** no new component — reuse the existing cross-platform -`readLocalApi().contextMenu.show(items, { x, y })` (the sidebar's pattern; Electron + web), `onContextMenu` -on the row, dispatch by returned `id`. Zero custom code. diff --git a/mcp-specs/current/improvments-banch-2.md b/mcp-specs/current/improvments-banch-2.md deleted file mode 100644 index 04eb86bb7be..00000000000 --- a/mcp-specs/current/improvments-banch-2.md +++ /dev/null @@ -1,495 +0,0 @@ -# MCP — Improvements Branch 2 (var-model edge cases + UI consistency) - -> Follow-up to branch-1 + the unified-card/var-model refactor. P1/P2/P3/P5 are web-only; **P4 spans the -> contract + server + web** (the `valueLocked` bit — see below). **No SQL migration** (vars live in -> `vars_json`; the new field is an Effect-schema `optionalKey`, so old rows decode and existing built-ins -> re-sync on next startup via the `builtinHash` change). - -## Context (gates off, per user) - -The unified-card refactor, the var-model change, and the `atlassian` built-in are in the working tree -un-gated. The user has chosen **not to run gates** for this branch — proceed straight to the edits below. -(For reference only, the env baseline is the 4 `tests/bin.test.ts` failures — qwen/cli.js absent.) - -## Decisions captured (LOCKED — no open questions) - -- **P5 — catalog DETAIL dot stays as-is.** Do **not** color the dot. Only **add the «Требует настройки» status - text** under the description. (User: "leave the dot! only add status.") -- **P3 — color = «требует настройки» amber, not red.** Unfilled required CATALOG vars in the read-only vars - block are marked **amber** (`text-amber-700 dark:text-amber-300/90`) + an amber `*`, matching the status - word — NOT the project dialog's hard-red border. A per-project hole empty at the catalog is normal ⇒ not - marked. -- **P6 — `field-sizing` is for the locked NAME ONLY.** The value field stays full-width (`flex-1`) — a long - read-only URL with `field-sizing:content` grows past the row and adds horizontal scroll (user-reported). - Latest-Chrome web target. Because ``'s `className` lands on the **wrapper span** and the inner - `` is hardcoded `w-full`, the working form is the descendant pair - **`[&_input]:[field-sizing:content] [&_input]:w-auto`** on a `w-auto max-w-full` wrapper. Editable name rows - keep `w-2/5`. -- **P3 second half — the EDIT MODAL marks empty required catalog values RED.** `ConfigSummary` (read-only) is - amber; `VarsEditor`'s value `` gets `aria-invalid` (red border) when an empty required catalog var - must be filled (`!perProject && required && value==="" && !(secret && hasStoredSecret)`), exactly like - `ProjectConfigDialog` marks unfilled per-project holes. (Missed in the first pass.) -- **P4 — read-only value needs a STABLE BIT, not a value-derived guess (CORRECTED).** The first attempt - (`valueReadonly = declarationLocked && value.length > 0`) was broken: it locked a user-fillable var the - instant a letter was typed, and again after save. "Author-fixed" is a property of the built-in *definition* - (shipped value non-null), not the live value, so it's a per-var bit `valueLocked`, sourced server-side and - surfaced to the UI. No SQL migration — vars live in `vars_json`; the bit is a new `McpServerVar` schema - field modeled like `keepSecret` (`Schema.optionalKey(Schema.Boolean)`, narrowly-true), NOT like the always- - meaningful `origin`. Also fixes `buildSyncedBuiltin` so a locked value re-adopts the shipped value on - re-sync (a deployer URL change propagates), while holes keep the user's value. -- **P2 — wording LOCKED: «Требует настройки в каталоге», grayed (neutral/disabled dot + muted text), card - dimmed, recheck disabled.** It points the user to «Показать в каталоге» (already in the collapse body). -- **P1 — «требует настройки» count badge + tooltip** on the catalog list (names overflow one line); the - catalog **detail** (P5) has room and lists the names inline. - ---- - -## Edit manifest - -**Web (UI):** - -| # | File | Items | New imports | -|---|---|---|---| -| 1 | `components/McpServerItemCard.tsx` | P1 | — (`ReactNode` already imported) | -| 2 | `components/RegistryTab.tsx` | P1 | `type ReactNode` (react), `Badge` | -| 3 | `components/RegistryDetail.tsx` | P5 | — | -| 4 | `components/ConfigSummary.tsx` | P3 | — (`Badge` already imported) | -| 5 | `components/VarsEditor.tsx` | P3·P4·P6 | `LockIcon` (lucide-react), `cn` | -| 6 | `components/ProjectBindingRow.tsx` | P2 | — | -| 7 | `mcp-manage/types.ts` | P4 | — (`McpVar` += `valueLocked`) | -| 8 | `mcp-manage/adapters.ts` | P4 | — (`catalogVarToUi` surfaces `valueLocked`) | -| 9 | `components/addMcpParsing.ts` | P4 | — (pasted env vars: `valueLocked: false`) | - -**Contract + server (P4's `valueLocked` bit):** - -| # | File | Change | -|---|---|---| -| 10 | `contracts/src/ru-fork/mcp.ts` | `McpServerVar` += `valueLocked: Schema.optionalKey(Schema.Boolean)` | -| 11 | `server/.../mcp/McpBuiltins.ts` | `builtinShippedVars` sets `valueLocked: variable.value !== null` | -| 12 | `server/.../mcp/McpCatalogBuilders.ts` | `buildSyncedBuiltin` `keptValue`: locked value re-adopts shipped value | - -`McpSecrets.ts` (`splitServerVars`) is **unchanged** — user/custom vars omit `valueLocked` (optionalKey), and a -locked template re-stamps it from `...shipped` in `mergeTemplateVars`. Server test fixtures are **unchanged** -(optionalKey ⇒ literals may omit it). No SQL migration (`vars_json`). - ---- - -## P1 — catalog list: «требует настройки» count badge + tooltip - -`McpServerItemCard` line-2 is a single `truncate` `

`; `server.missingVars.join(", ")` overflows. Show -«требует настройки» + a small count badge whose `title` lists the names; drop the inline names. - -### Edit 1a — `McpServerItemCard.tsx`: add optional `statusBadge` prop - -**Interface — before:** -```tsx - readonly statusLabel?: McpItemStatusLabel | undefined; - readonly statusDetail?: string | undefined; -``` -**after:** -```tsx - readonly statusLabel?: McpItemStatusLabel | undefined; - readonly statusDetail?: string | undefined; - /** Optional small chip after the status word (e.g. a count for «требует настройки»). */ - readonly statusBadge?: ReactNode | undefined; -``` - -**Destructure — before:** ` statusLabel,\n statusDetail,` → **after:** add ` statusBadge,` between them -(i.e. `statusLabel, statusBadge, statusDetail,`). - -**Line-2 render — before:** -```tsx - {(statusLabel || statusDetail) && ( -

- {statusLabel && {statusLabel.text}} - {statusLabel && statusDetail && · } - {statusDetail && {statusDetail}} -

- )} -``` -**after:** -```tsx - {(statusLabel || statusDetail || statusBadge) && ( -

- {statusLabel && {statusLabel.text}} - {statusBadge && {statusBadge}} - {statusLabel && statusDetail && · } - {statusDetail && {statusDetail}} -

- )} -``` -`ReactNode` is already imported (`import type { ReactNode } from "react";`). - -### Edit 1b — `RegistryTab.tsx`: emit the badge in the `incomplete` branch - -**Imports — before:** `import { useMemo, useState } from "react";` → -**after:** `import { type ReactNode, useMemo, useState } from "react";` -Add `import { Badge } from "~/components/ui/badge";` **between** the `~/components/ui/alert-dialog` block -(ends `} from "~/components/ui/alert-dialog";`) and `import { Button } from "~/components/ui/button";` -(alphabetical: badge < button). - -**Declarations — before:** -```tsx - let statusLabel: McpItemStatusLabel | undefined; - let statusDetail: string; -``` -**after:** -```tsx - let statusLabel: McpItemStatusLabel | undefined; - let statusDetail: string; - let statusBadge: ReactNode | undefined; -``` - -**`incomplete` branch — before:** -```tsx - if (server.incomplete) { - // Catalog-level required vars are unfilled — fixable HERE (edit the server). Name them. - statusLabel = { - text: "требует настройки", - className: "text-amber-700 dark:text-amber-300/90", - }; - statusDetail = server.missingVars.join(", "); - } else if (server.templateOnly) { -``` -**after:** -```tsx - if (server.incomplete) { - // Catalog-level required vars are unfilled — fixable HERE (edit the server). The names - // overflow the truncated line, so show a count badge; the tooltip lists them in full. - statusLabel = { - text: "требует настройки", - className: "text-amber-700 dark:text-amber-300/90", - }; - statusDetail = ""; - statusBadge = ( - - {server.missingVars.length} - - ); - } else if (server.templateOnly) { -``` -(`statusDetail = ""` → falsy → the `{statusDetail && …}` span renders nothing. The other two branches leave -`statusBadge` undefined.) - -**Card props — before:** ` statusDetail={statusDetail}` → -**after:** -```tsx - statusDetail={statusDetail} - statusBadge={statusBadge} -``` - -`Badge` forwards `title`/`size`/`variant` (it spreads `...props` via `mergeProps`); `variant="warning"` is -amber-toned (`bg-warning/8 text-warning-foreground`), `size="sm"` is the smallest chip. - -**Verify:** the `atlassian` built-in (4 unfilled vars) shows «требует настройки» + a `4` chip; hovering lists -`JIRA_USERNAME, JIRA_API_TOKEN, CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN`. - ---- - -## P2 — project row: catalog-incomplete server shows «Подключение» (model gap) - -**Root cause (verified):** `computeMissingVars` (`adapters.ts`) filters on `variable.perProject`, so -`binding.incomplete` ignores **catalog-level** required vars. The reactor's `missingRequiredVars` counts ALL -required vars → skips probing → no runtime → `runtimeStatusToUi(enabled, undefined, …)` → `"connecting"` -(blue «Подключение»), stuck. The data we need is already on the row: `server.incomplete` / -`server.missingVars`. Fix is row-level display only (mirrors the existing `catalogDisabled` override). - -### Edit 2a — `ProjectBindingRow.tsx`: derive `catalogIncomplete`, re-order the status block - -**before:** -```tsx - // ⑬ the catalog server is disabled — the binding stays listed but grayed + inactive. - const catalogDisabled = !server.enabled; - // The dot/status the row should show: neutral «disabled» when the catalog server is off. - const rowStatus = catalogDisabled ? "disabled" : binding.status; - const statusVis = statusVisual(binding.status); - // Line 2 leading word: disabled-in-catalog / requires-setup / the live status; counts after. - const statusLabel = catalogDisabled - ? { text: "Отключён в каталоге", className: statusVisual("disabled").textClass } - : binding.incomplete - ? { - text: `Требует настройки: ${binding.missingVars.join(", ")}`, - className: "text-amber-700 dark:text-amber-300/90", - } - : { text: statusVis.label, className: statusVis.textClass }; - const statusDetail = - catalogDisabled || binding.incomplete ? undefined : toolsCountLabel(tools.length); - const errorMessage = - !catalogDisabled && !binding.incomplete && binding.status === "error" - ? binding.health.detail - : undefined; -``` -**after:** -```tsx - // ⑬ the catalog server is disabled — the binding stays listed but grayed + inactive. - const catalogDisabled = !server.enabled; - // P2: the catalog server has unfilled CATALOG-level required vars (server.incomplete). The reactor - // skips probing it, so this binding has no runtime row and would read «Подключение» (blue) forever. - // It's the catalog author's job to fix (the project can't), so show a neutral, catalog-pointing state. - const catalogIncomplete = !catalogDisabled && server.incomplete; - // The dot/status the row should show: neutral when the catalog server is off OR needs catalog setup. - const rowStatus = catalogDisabled || catalogIncomplete ? "disabled" : binding.status; - const statusVis = statusVisual(binding.status); - // Line 2 leading word, by priority: off-in-catalog → needs-catalog-setup → per-project setup → status. - const statusLabel = catalogDisabled - ? { text: "Отключён в каталоге", className: statusVisual("disabled").textClass } - : catalogIncomplete - ? { text: "Требует настройки в каталоге", className: statusVisual("disabled").textClass } - : binding.incomplete - ? { - text: `Требует настройки: ${binding.missingVars.join(", ")}`, - className: "text-amber-700 dark:text-amber-300/90", - } - : { text: statusVis.label, className: statusVis.textClass }; - const statusDetail = - catalogDisabled || catalogIncomplete || binding.incomplete - ? undefined - : toolsCountLabel(tools.length); - const errorMessage = - !catalogDisabled && !catalogIncomplete && !binding.incomplete && binding.status === "error" - ? binding.health.detail - : undefined; -``` - -### Edit 2b — context-menu recheck disabled - -**before:** ` { id: "recheck", label: "Проверить", disabled: binding.incomplete },` -**after:** ` { id: "recheck", label: "Проверить", disabled: binding.incomplete || catalogIncomplete },` - -### Edit 2c — card `dimmed` - -**before:** ` dimmed={catalogDisabled}` -**after:** ` dimmed={catalogDisabled || catalogIncomplete}` -(`description={!catalogDisabled ? …}` stays — the description still shows; only the catalog-off case hides it.) - -### Edit 2d — actions recheck disabled - -**before:** ` recheckDisabled={binding.incomplete}` -**after:** ` recheckDisabled={binding.incomplete || catalogIncomplete}` - -**Verify:** bind a server, empty one of its **catalog-level** required vars → the project row goes grayed -«Требует настройки в каталоге» (neutral dot, dimmed, refresh disabled), NOT blue «Подключение»; refill it in -the catalog → the row returns to its real status. - ---- - -## P3 — catalog detail vars block: mark unfilled required catalog vars (amber) - -`ConfigSummary` → `VarsBlock` shows var names in plain `text-foreground`. Mark an unfilled CATALOG-level -required var amber + amber `*`. - -### Edit 3 — `ConfigSummary.tsx`: `VarsBlock` - -**before:** -```tsx -function VarsBlock({ vars }: { vars: readonly McpVar[] }) { - return ( -
- vars -
- {vars.map((variable) => ( -
- {variable.name} - {variable.secret && ( - - секрет - - )} - {variable.perProject && ( - - проект{variable.required ? " *" : ""} - - )} -
- ))} -
-
- ); -} -``` -**after:** -```tsx -function VarsBlock({ vars }: { vars: readonly McpVar[] }) { - return ( -
- vars -
- {vars.map((variable) => { - // Unfilled CATALOG-level required var ⇒ «требует настройки» (amber). A per-project hole being - // empty at the catalog is normal (it's filled per project) ⇒ not marked. - const needsCatalogValue = - !variable.perProject && variable.required && variable.value.length === 0; - return ( -
- - {variable.name} - {needsCatalogValue && *} - - {variable.secret && ( - - секрет - - )} - {variable.perProject && ( - - проект{variable.required ? " *" : ""} - - )} -
- ); - })} -
-
- ); -} -``` - -**Verify:** open the `atlassian` detail → `JIRA_USERNAME`, `JIRA_API_TOKEN`, `CONFLUENCE_USERNAME`, -`CONFLUENCE_API_TOKEN` names are amber with `*`; `JIRA_URL`/`CONFLUENCE_URL` (have values) stay normal. - ---- - -## P5 — catalog DETAIL header: add «Требует настройки» status text (dot unchanged) - -`RegistryDetail` only renders a red line for `status === "error" && message`; an `incomplete` server shows -nothing. Add an amber line under the description. **Dot is NOT changed** (user decision). - -### Edit 5 — `RegistryDetail.tsx`: after the error line - -**before:** -```tsx - {/* ⑫ surface the probe failure text, not just the dot */} - {server.status === "error" && server.message && ( -

- {server.message} -

- )} -
-``` -**after:** -```tsx - {/* ⑫ surface the probe failure text, not just the dot */} - {server.status === "error" && server.message && ( -

- {server.message} -

- )} - {/* P5: catalog-level required vars unfilled — fixable HERE (edit the server). Dot unchanged; - the detail has room, so list the names inline (the list view uses a count badge instead). */} - {server.incomplete && ( -

- Требует настройки: {server.missingVars.join(", ")} -

- )} -
-``` - -**Verify:** open the `atlassian` detail → «Требует настройки: JIRA_USERNAME, …» appears under the description; -the status dot is unchanged. - ---- - -## P4 + P6 — edit modal vars: `valueLocked` read-only + locked NAME fits content (AS BUILT) - -Two distinct things, both in the edit modal (`VarsEditor`), plus the contract/server bit that backs P4. - -### The `valueLocked` bit (contract + server) - -`McpServerVar` gains `valueLocked: Schema.optionalKey(Schema.Boolean)` — set true by `builtinShippedVars` -when the definition's value is non-null (the author's fixed URL), false/absent for holes. It rides through -`mergeTemplateVars` (`{ ...shipped, value }`) on user edits and `buildSyncedBuiltin` (`{ ...variable, … }`) -on re-sync, so it is **stable across typing and saving** — the live value never drives it. `buildSyncedBuiltin` -also now lets a locked value re-adopt the **shipped** value (a deployer URL change propagates), while a hole -keeps the user's value: -```ts -const keptValue = - variable.valueLocked || prior === undefined || prior.value === null ? variable.value : prior.value; -``` -`adapters.catalogVarToUi` surfaces it as `valueLocked: variable.valueLocked === true`; UI `McpVar` carries -`readonly valueLocked: boolean` (also set `false` in `EMPTY_VAR` + `addMcpParsing`). - -### P4 — value read-only when `valueLocked` - -```tsx -// NOT derived from the live value (that locked a field mid-type and after save) — a stable definition bit. -const valueReadonly = declarationLocked && variable.valueLocked; -``` -The value `` is `disabled={variable.perProject || valueReadonly}` and shows a «задано шаблоном» -`LockIcon` hint when `valueReadonly`. So `JIRA_URL` (shipped value) is read-only; `JIRA_USERNAME`/tokens -(shipped `null`) stay editable through typing and across saves. - -### P3 second half — empty required catalog value marked RED - -```tsx -const catalogValueMissing = - !variable.perProject && variable.required && variable.value.length === 0 && - !(variable.secret && variable.hasStoredSecret); -// on the value : -aria-invalid={catalogValueMissing || undefined} // red border (omit when valid), like ProjectConfigDialog -``` - -### P6 — locked NAME fits content; value stays full-width - -The **name** `` fits its content when locked (no scroll); the **value** `` stays full-width -(`flex-1`) — `field-sizing` on a long read-only URL would push the row into horizontal scroll. Because -``'s `className` lands on the **wrapper span** (input.tsx) and the inner `` is hardcoded -`w-full`, the name uses the descendant form `[&_input]:w-auto [&_input]:[field-sizing:content]` on a -`w-auto max-w-full` wrapper (wrapper-scoped `& input` specificity (0,1,1) beats `.w-full` (0,1,0)): -```tsx -className={cn( - "font-mono", - declarationLocked - ? "w-auto max-w-full [&_input]:w-auto [&_input]:[field-sizing:content]" // name only - : "w-2/5", -)} -// value input: className="min-w-0 flex-1 font-mono" (full width, NO field-sizing) -``` -Imports added to `VarsEditor`: `LockIcon` (lucide-react), `cn` (`~/lib/utils`). - -**Verify:** typing into `JIRA_USERNAME` keeps focus and stays editable (never «задано шаблоном»); after saving -it, reopening keeps it editable; `JIRA_URL` is read-only with the lock hint; empty required values -(`JIRA_USERNAME`, tokens) show a red border; the locked name `CONFLUENCE_API_TOKEN` is fully visible (no -scroll); the `…/wiki` value sits in a normal full-width box (no horizontal scroll). After a deployer changes -`JIRA_URL` in source, existing installs adopt the new URL on next startup; user-filled usernames are kept. - ---- - -## Build-safety checklist (why this compiles as written) - -- **Types:** `statusBadge?: ReactNode | undefined` (optional, `exactOptionalPropertyTypes`-safe); `ReactNode` - imported in both card + RegistryTab; `statusBadge` declared `let … | undefined` so all branches are - defined. `catalogIncomplete`/`valueReadonly`/`needsCatalogValue` are local `boolean`s. -- **No casts** (`as`/`any`/`unknown`); only string classNames + `cn()`. -- **Existing data only:** `server.incomplete`, `server.missingVars`, `McpVar.value` (always a `string`, `""` - when null), `variable.origin`, `variable.required` — all already on the view-types; no `adapters`/contract - edits. -- **Primitives:** `Badge` spreads `...props` ⇒ `title` works; `variant="warning"`/`size="sm"` exist. - `cn` = `~/lib/utils`. Tailwind JIT emits arbitrary variant+property `[&_input]:[field-sizing:content]`. -- **No new files, no deleted exports, no signature changes** ⇒ no other consumer breaks (`statusBadge` is - additive and optional). - -## Features this branch delivers - -1. Catalog list: «требует настройки» shows a **count chip + hover-tooltip** instead of an overflowing name list. -2. Project list: a binding whose **catalog** server has unfilled required vars reads a grayed **«Требует - настройки в каталоге»** (neutral dot, dimmed, refresh off) instead of a stuck blue «Подключение». -3. Catalog detail vars block: unfilled required catalog vars are **amber-marked** (name + `*`), consistent - with the status word. -4. Catalog detail header: adds a **«Требует настройки: …»** status line (dot unchanged). -5. Edit modal: locked var **names** and locked shipped **values** render as **content-fitting** read-only - fields (no horizontal scroll), with a **«задано шаблоном»** lock hint; editable rows unchanged. - -## Sequencing - -P1 → P3 → P5 → P6/P4 (independent, mostly disjoint files) → P2 last (touches `ProjectBindingRow` only). -Nothing here touches the backend, schema, contracts, or the migration. - -## Constraints (unchanged from branch-1) - -- Web has no test target → validated by typecheck + lint only. -- No `as`/`any`/`unknown` casts; `Effect.catch` not `catchAll`; `logError`/`logDebug` only; mark ru-fork - deltas with `ru-fork:`. qwen is never run here. diff --git a/mcp-specs/current/improvments-banch-3.md b/mcp-specs/current/improvments-banch-3.md deleted file mode 100644 index b3204a52579..00000000000 --- a/mcp-specs/current/improvments-banch-3.md +++ /dev/null @@ -1,1492 +0,0 @@ -# MCP — improvements branch 3 (analysis + validated plan) - -## ✅ IMPLEMENTATION STATUS (2026-06-14 — updated) - -**Done + verified this round** (gates: server/web/contracts typecheck 0 · oxlint 0/0 · build ✔ · -`test:fast` = only the 4 `bin.test.ts` env-baseline failures, **886 passed**): -- **#1** backfill ordering — back-fill moved after `probeHashes` in `processSignal`. -- **#2** config-uniqueness — `configIdentity` (mcp-core) + `requireCatalogConfigUnique` invariant on - `server-add`/config-`server-update`; built-in **skip-on-collision** in `reconcileBuiltinsWith`. -- **#4 (encryption half)** — `apps/server/src/auth/secretCrypto.ts` (scrypt + AES-256-GCM, qwen's - scheme) wrapping `ServerSecretStore` get/set/create. **Ephemeral-overlay half = NOT done** (next). -- **#5** UI errors — readable toasts (`readableMcpError` strips the `Orchestration command invariant - failed (…)` wrapper); add/edit blocked (dialog stays open), all errors → notification. -- **#6** trust — full chain: contract `trust` field (default true) + DB column + projection + - 3 builders + `buildServerEntry` emits qwen's `trust` + `overlayFingerprint` includes it + UI - («Доверять серверу» wired; removed dead «Включить все инструменты»). -- **#8** var validation — reject undeclared `${VAR}` + unknown binding var-key. -- **#9** missing-secret — excluded from `computeDesired` + overlay (no blank-credential launch). -- **#10** 64-bit hash — `fnv1a` widened (16 hex). -- **Server-side error logging** — `OrchestrationEngine` logs rejected commands: `logDebug` for - invariant (expected user rejection), `logError` for genuine failure — full details in the terminal. -- **Tests** — failing tests written first then made green; 4 canary tests fixed honestly (distinct - configs / adjusted premise, none removed); new coverage for trust + readable errors. Suite under - `apps/server/tests/ru-fork/mcp/branch3*.ts` (+ helper `branch3Helpers.ts`) and the - `mcp-probe` D6/D7 cases (operator-run, GO). - -**Remaining (next session — see HANDOFF.md):** -- **#4 ephemeral overlay** — delete the plaintext overlay file after the session establishes. **It IS - testable** (I was wrong earlier): the harness is `tests/provider/fakeAcpSpawner.ts` + - `fakeAcpCore.ts` (runs the REAL `CliAdapter`+`AcpSessionRuntime` over a scripted in-memory agent; - example: `tests/provider/cliAdapterErrorEngine.test.ts`). **Proven safe** by qwen-source analysis: - qwen reads the system settings file **once** at startup, never re-reads, never writes the System - scope, and stores MCP OAuth tokens in a SEPARATE encrypted file — so deleting after - `sessionSetupResult` (load-or-new ⇒ covers resume) is safe. Needs a fallback for a spawn that never - establishes. -- **#11 watchedProjects** — per-connection sets + union (deferred to last). - ---- - -**Status:** the original plan below is the agreed-shape proposal; the STATUS block above is the -current truth. The flow was: (1) agree on this report → (2) write the FAILING tests → (3) touch the -codebase. Every code block below is verbatim before→after against the working tree at plan time. - -**Standing constraints** (enforced): no `as`/`any`/`unknown` casts; `Effect.catch`/`catchCause` -not `catchAll`; only `logError`/`logDebug`; mark ru-fork deltas with `ru-fork:`; web has no -test target (typecheck+lint only); never run qwen/the project (hand a runnable artifact); -plan-before-editing. The 4 `bin.test.ts` failures are the env baseline. - -Severity legend: 🔴 correctness/security · 🟠 robustness · 🟡 UX/operational. - ---- - -## Index - -| # | Item | Sev | Lands cleanly? | Testable before impl? | -|---|---|---|---|---| -| 1 | Backfill ordering (empty description/docs) | 🟠 | Yes — 2-line reorder | Yes (reactor harness) | -| 2 | Config-uniqueness invariant + built-in skip | 🔴 | Yes — new invariant + reactor guard | Yes (decider + reactor harness) | -| 3 | qwen overlay verification | 🔴 | **Proven:** 0.13.1 response carries no MCP info → runbook + probe, not in-product | Probe = manual | -| 4 | Encrypt secrets at rest + ephemeral overlay | 🔴 | Yes — encrypt `.bin`; overlay written→spawn→deleted (verified safe) | Yes (round-trip + lifecycle test) | -| 5 | Surface UI errors (modal vs toast) | 🟠 | Yes — reuses toast + modal error block | Web = typecheck/lint only | -| 6 | Wire trust, remove «enable all», no autobind | 🟡 | Yes — `trust` overlay field (folder already trusted, full-access locked ⇒ only lever); remove 1 checkbox | Server unit (fingerprint+overlay) | -| 7 | Non-transactional secret write | 🟠 | Use existing GC (Option A proven **unsafe**) | Yes (fault-injection) | -| 8 | varValues / `${VAR}` ↔ declared-vars validation | 🟠 | Yes — decider guard | Yes (decider harness) | -| 9 | Missing secret → incomplete | 🟠 | Yes — exact server-side exclusion (UI surface = follow-on) | Yes (secrets+computeDesired unit) | -| 10 | 32-bit hash collision risk | 🟡 | Yes — widen fnv to 64-bit | Yes (hash unit) | -| 11 | Cross-client `watchedProjects` clobber | 🟠 | Yes — per-connection map + union (key choice noted) | Yes (supervisor unit) | - ---- - -## 1. Backfill ordering — the empty description/docs bug 🟠 - -### Mechanism (confirmed) -`backfillServerMetadataEffect` runs **inside** `reconcileNow` (`McpReactor.ts:497`), which -executes **before** the probe (`processSignal` → `supervisor.probeHashes(added)`, -`McpReactor.ts:551`). On a fresh add the probe cache has no `serverDescription` yet, so the -catalog field is never filled that session. After a restart the initial reconcile reads the -prior session's already-probed cache → it fills. That's why "after restart it works." -`probeHashes` (`McpSupervisor.ts:442-451`) **awaits** every probe + its `probeCache.upsert`, -so a backfill placed *after* it is race-free. - -### Options -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Move backfill after the probe** (recommended) | One pass, no new mechanics; converges; description not in overlay fingerprint ⇒ no respawn | none material | Clean — same scope, same fn | -| B. Second backfill pass after probe, keep the first | Covers a future caller that backfills without probing | Two passes per reconcile; redundant | Clean but wasteful | -| C. Re-trigger reconcile when a probe completes | "Event-driven" | Probe completion isn't a domain event; would need a new signal + risk of probe→reconcile loops | Needs new plumbing | - -**Decision: A.** - -### Testability — YES (red before fix) -Reactor harness `reconciliationLifecycle.test.ts` already has `system.backfill()` and -`system.probeUpsert()`. A new end-to-end test must drive the **real** `processSignal` order -(add → eager reconcile → probe) and assert description filled in that one cycle. The cleanest -red test seeds the cache to model "probe wrote it" and asserts the *post-probe* backfill runs; -but to truly prove ordering we add a test that runs the worker path. See §Tests. - -### Exact change — `apps/server/src/ru-fork/mcp/McpReactor.ts` - -**Before** (`reconcileNow`, lines 479–498): -```ts - const reconcileNow: Effect.Effect> = Effect.gen(function* () { - const desired = yield* computeDesired; - const added = yield* supervisor.reconcile(desired); - // GC: drop persisted cache rows whose authored config no longer exists in the - // live desired set (catalog default removed / override changed or dropped). - const liveConfigKeys = new Set([...desired.values()].map((instance) => instance.configKey)); - // Never run a "delete-all" on an empty desired set (a transient all-incomplete state must not wipe - // the cache); orphan rows are tiny and the next non-empty reconcile cleans them. - if (liveConfigKeys.size > 0) { - yield* probeCache - .deleteKeysNotIn([...liveConfigKeys]) - .pipe( - Effect.catch((error) => - Effect.logError("mcp reactor failed to GC probe cache", { error }), - ), - ); - } - yield* gcOrphanedSecrets; // item 10 — prune secret .bin files no longer referenced - yield* backfillServerMetadata; // B3 ② — fill empty description/websiteUrl from the probe - return added; - }).pipe( -``` - -**After** (remove the backfill line from `reconcileNow`): -```ts - const reconcileNow: Effect.Effect> = Effect.gen(function* () { - const desired = yield* computeDesired; - const added = yield* supervisor.reconcile(desired); - // GC: drop persisted cache rows whose authored config no longer exists in the - // live desired set (catalog default removed / override changed or dropped). - const liveConfigKeys = new Set([...desired.values()].map((instance) => instance.configKey)); - // Never run a "delete-all" on an empty desired set (a transient all-incomplete state must not wipe - // the cache); orphan rows are tiny and the next non-empty reconcile cleans them. - if (liveConfigKeys.size > 0) { - yield* probeCache - .deleteKeysNotIn([...liveConfigKeys]) - .pipe( - Effect.catch((error) => - Effect.logError("mcp reactor failed to GC probe cache", { error }), - ), - ); - } - yield* gcOrphanedSecrets; // item 10 — prune secret .bin files no longer referenced - return added; - }).pipe( -``` - -**Before** (`processSignal`, lines 540–553): -```ts - const processSignal = (signal: ReactorSignal): Effect.Effect => - Effect.gen(function* () { - const eager = signal.kind === "project-created" ? true : signal.eager; - if (signal.kind === "project-created") { - yield* autobindBuiltinsForProject(signal.projectId); - } - yield* pruneOrphanedVarValues; // item 11 — drop stranded var values (ref-preserving) - const added = yield* reconcileNow; - if (eager) { - // A config-affecting change (or new project) just landed — probe the newly-appeared - // instances NOW instead of waiting for the sweep (item 3). Cosmetic edits add nothing. - yield* supervisor.probeHashes(added); - } - }); -``` - -**After** (run the backfill *after* the probe — sees fresh metadata): -```ts - const processSignal = (signal: ReactorSignal): Effect.Effect => - Effect.gen(function* () { - const eager = signal.kind === "project-created" ? true : signal.eager; - if (signal.kind === "project-created") { - yield* autobindBuiltinsForProject(signal.projectId); - } - yield* pruneOrphanedVarValues; // item 11 — drop stranded var values (ref-preserving) - const added = yield* reconcileNow; - if (eager) { - // A config-affecting change (or new project) just landed — probe the newly-appeared - // instances NOW instead of waiting for the sweep (item 3). Cosmetic edits add nothing. - yield* supervisor.probeHashes(added); - } - // ru-fork: B3 ② — back-fill catalog description/websiteUrl from the probe cache. MUST run - // AFTER probeHashes: a fresh add has no cache row until the eager probe writes it, so an - // earlier pass (inside reconcileNow) saw nothing. Idempotent + converges (the empty-only - // guard makes repeat passes no-ops); description is not in the overlay fingerprint, so this - // never respawns a session. On the non-eager startup path it reads the prior-session cache. - yield* backfillServerMetadata; - }); -``` - -Also correct the now-stale comment at `McpReactor.ts:469-472` (it claims a "deterministic, -field-scoped commandId / replay-idempotent" — untrue since `mkReconcileCommandId` mints a fresh -uuid; and the pass now runs post-probe): - -**Before** (469–472): -```ts - // B3 ②: back-fill catalog `description`/`websiteUrl` from a server's first successful probe — but - // ONLY when the catalog field is empty (a shipped/user value always wins). Reads the server's - // default-config cache row and dispatches a metadata-only `mcp.server-update`. Replay-idempotent - // (deterministic, field-scoped commandId); converges (once filled, the next pass produces no patch). -``` -**After:** -```ts - // B3 ②: back-fill catalog `description`/`websiteUrl` from a server's first successful probe — but - // ONLY when the catalog field is empty (a shipped/user value always wins). Reads the server's - // default-config cache row and dispatches a metadata-only `mcp.server-update`. Invoked from - // processSignal AFTER the eager probe so the cache row exists; converges (the empty-only guard - // makes the next pass a no-op). commandId is a fresh per-dispatch uuid (mkReconcileCommandId). -``` - ---- - -## 2. Config-uniqueness invariant + built-in skip-on-collision 🔴 - -### Mechanism (confirmed) -The catalog keys identity by opaque `serverId` only (PK in `Migrations/031_Mcp.ts`; no UNIQUE -on name/config). The runtime collapses identical configs into one (`configCacheKey`/`dedupHash`). -So two catalog rows with the same config are allowed but produce: a shared probe row, duplicate -qwen `mcpServers` entries (overlay keys by `serverId` — `McpOverlay.ts:174`), and confusing shared -status. `mcp.builtin-sync` (`decider.ts:804-805`) keys only by `serverId`, so a shipped built-in -can silently duplicate a colliding custom server. **Tier-1 #5 is this same root cause.** - -### Identity definition — structural, secret-independent -We do NOT reuse `configCacheKey` (it includes per-server secret refs → two credentialed servers -never collide → useless for real MCPs). We add a pure **`configIdentity`** over -`{transport, command, args, httpUrl, headers-keys+template, extraArgs, extraHeaders, var NAMES}` -— value- and secret-independent. Server-authoritative (computed in the decider), so the secret -plaintext-vs-ref mismatch is moot. (Per-project AUTH secrets live on **bindings**, keyed by the -unchanged value-inclusive `configCacheKey` — fully distinguished; this invariant never touches them.) - -### Options — enforcement model -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Decider invariant + dialog stays open on reject** (recommended) | Single authority; can't be bypassed; no client hashing ⇒ no secret/drift issue; atomic (no event on reject) | Needs UI error surface (item 5) to be visible | Clean — mirrors `requireCatalogServerAbsent` | -| B. Disable Save button via client-side hash | Instant feedback | Browser can't see secret refs → wrong for credentialed servers; duplicates the hash (drift) | Smell — re-mirrors mcp-core like `adapters.ts:103` | -| C. De-dup at the overlay only | No user rejection | Leaves shared-probe confusion + ambiguous policy-union; doesn't fix builtin path | Hot-path complexity | - -**Decision: A** (the UI just renders the decider's error — item 5). - -### Options — built-in collision -| Option | Pros | Cons | Arch fit | -|---|---|---|---| -| **A. Reactor skips a built-in whose config collides with another server, on add AND update** (recommended) | Non-destructive; self-heals; same mechanism both paths | A built-in may stay on its last good config until the user resolves the clash | Clean — fits `reconcileBuiltinsWith` diffing | -| B. Adopt the custom server as the built-in (stamp builtinId) | No frozen built-in | Mutates/renames/locks the user's server — surprising | Risky | -| C. Do nothing (accept the gap) | Zero work | Duplicate built-in on collision | — | - -**Decision: A**, applied on both the add and update branches, with a `logDebug` notice. - -### Testability — YES (red before fix) -- Decider: add A (X) ok; add B (X, different name) → `rejects.toThrow()`; edit to collide → throw; - edit own non-config field → ok. Harness = `reconciliationLifecycle.test.ts` "decider guards" block. -- Reactor: custom X exists; ship built-in X → assert built-in **not** added (catalog has no - `builtinId` row for it). Harness = `system.reconcile([def])` + `system.catalog()`. - -### Exact code - -**2a. New pure identity — `packages/mcp-core/src/resolver.ts`** (append after `configCacheKey`, before `canonicalize`, ~line 187): -```ts -/** - * ru-fork: CATALOG-level uniqueness identity. Structural + secret/value-INDEPENDENT: two catalog - * templates collide iff they target the same transport endpoint with the same arg/header SHAPE and - * the same declared var NAMES — regardless of credentials or per-project values. Distinct from - * `configCacheKey` (which includes values/refs and is the per-binding probe-cache key). Used only by - * the `requireCatalogConfigUnique` decider invariant; never participates in resolution or probing. - */ -export function configIdentity( - config: McpServerConfig, - // Loose `{ name }` shape on purpose: callers pass either a catalog `McpServerVar[]` (server-update, - // built-in skip) or a draft `McpServerVarDraft[]` (server-add). Both carry `.name`; we read nothing - // else, so this avoids a type mismatch without a cast. - vars: ReadonlyArray<{ readonly name: string }>, - extraArgs: ReadonlyArray, - extraHeaders: Readonly>, -): string { - const varNames = vars.map((declared) => declared.name).toSorted(); - return fnv1a(JSON.stringify(canonicalize({ config, varNames, extraArgs, extraHeaders }))); -} -``` -(`McpServerConfig` is already imported in resolver.ts. `config` for stdio holds -`{transport, command, args}` and for http `{transport, httpUrl, headers}` — the headers VALUES are -`${VAR}` templates, not resolved secrets, so they're safe to include and they correctly distinguish -two different remote endpoints. Note: if item 10 widens `fnv1a` to 64-bit, this inherits it for free.) - -> **Package export:** verified `packages/mcp-core/src/index.ts` is `export * from "./resolver.ts"` -> — so `configIdentity` (and item 8's `configPlaceholders`) are exported automatically once added to -> resolver.ts. **No index change needed.** - -**2b. New invariant — `apps/server/src/ru-fork/mcp/McpInvariants.ts`** (append; add the import): -```ts -import { configIdentity } from "@ru-fork/mcp-core"; -``` -```ts -/** - * ru-fork: reject a catalog write whose structural config identity already exists on ANOTHER server - * (custom or built-in). `excludeServerId` is the server being edited (so a non-config edit never - * collides with itself). Scans the whole catalog so a custom add that matches a shipped built-in is - * told to bind the built-in instead. - */ -export function requireCatalogConfigUnique(input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly identity: string; - readonly excludeServerId: McpServerId | null; -}): Effect.Effect { - const clash = input.readModel.mcpCatalog.find( - (server) => - server.id !== input.excludeServerId && - configIdentity(server.config, server.vars, server.extraArgs, server.extraHeaders) === - input.identity, - ); - if (!clash) { - return Effect.void; - } - return Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: input.command.type, - detail: - `Сервер с такой конфигурацией уже существует: «${clash.name}». ` + - `Привяжите его к проекту или измените команду/URL, аргументы или заголовки.`, - }), - ); -} -``` -(`configIdentity` needs the server's `extraArgs`/`extraHeaders`, which `McpCatalogServer` always -carries via `withDecodingDefault`. The new draft on add has them too — see 2c.) - -**Decider imports (required for 2c/2d/8b/8c):** add to `decider.ts`'s existing -`./ru-fork/mcp/McpInvariants.ts` import → `requireCatalogConfigUnique`; and add a -`@ru-fork/mcp-core` import → `configIdentity` (item 2) and `configPlaceholders` (item 8). The -decider already imports `requireCatalogServer*` from `McpInvariants` and resolver symbols from -mcp-core, so these slot into the existing import lines. - -**2c. Wire into `mcp.server-add` — `apps/server/src/orchestration/decider.ts:761-774`.** -Before: -```ts - case "mcp.server-add": { - yield* requireCatalogServerAbsent({ readModel, command, serverId: command.serverId }); - const vars = yield* splitServerVars(command.serverId, command.draft.vars, []); -``` -After: -```ts - case "mcp.server-add": { - yield* requireCatalogServerAbsent({ readModel, command, serverId: command.serverId }); - // ru-fork: config-uniqueness — a server's identity is its config, not its name. Reject a - // duplicate of any existing catalog server (incl. built-ins) BEFORE writing secrets. - yield* requireCatalogConfigUnique({ - readModel, - command, - identity: configIdentity( - command.draft.config, - command.draft.vars, - command.draft.extraArgs ?? [], - command.draft.extraHeaders ?? {}, - ), - excludeServerId: null, - }); - const vars = yield* splitServerVars(command.serverId, command.draft.vars, []); -``` -NOTE the ordering: the uniqueness check runs **before** `splitServerVars` (which writes secrets) — -so a rejected duplicate never orphans a secret (ties into item 7). `command.draft.vars` is the -draft `McpServerVarDraft[]`; `configIdentity` only reads `.name`, which drafts carry. `extraArgs`/ -`extraHeaders` on `McpServerDraft` are optional → default to `[]`/`{}` (matches `buildAddedServer`). - -**2d. Wire into `mcp.server-update` — `decider.ts:776-789`.** Compute the *resulting* config/vars -and check only when config-affecting fields change. Before: -```ts - case "mcp.server-update": { - const existing = yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); - const occurredAt = yield* nowIso; - // Identity lock: a template's command and shipped var declarations are read-only. A patch that - // would change the command is rejected; configuring (extraArgs, var values, user vars) is allowed. - if (existing.locked && command.patch.config !== undefined) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `MCP server '${command.serverId}' is a locked template; its command cannot be edited.`, - }); - } - const vars = command.patch.vars - ? yield* mergeTemplateVars(command.serverId, existing, command.patch.vars) - : existing.vars; -``` -After (insert the uniqueness check after the lock check, before `mergeTemplateVars` writes secrets): -```ts - case "mcp.server-update": { - const existing = yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); - const occurredAt = yield* nowIso; - // Identity lock: a template's command and shipped var declarations are read-only. A patch that - // would change the command is rejected; configuring (extraArgs, var values, user vars) is allowed. - if (existing.locked && command.patch.config !== undefined) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `MCP server '${command.serverId}' is a locked template; its command cannot be edited.`, - }); - } - // ru-fork: config-uniqueness on edit — only when a config-affecting field changes. Compute the - // RESULTING identity and reject if it collides with a DIFFERENT server (exclude self). - if ( - command.patch.config !== undefined || - command.patch.vars !== undefined || - command.patch.extraArgs !== undefined || - command.patch.extraHeaders !== undefined - ) { - yield* requireCatalogConfigUnique({ - readModel, - command, - identity: configIdentity( - command.patch.config ?? existing.config, - command.patch.vars ?? existing.vars, - command.patch.extraArgs ?? existing.extraArgs, - command.patch.extraHeaders ?? existing.extraHeaders, - ), - excludeServerId: command.serverId, - }); - } - const vars = command.patch.vars - ? yield* mergeTemplateVars(command.serverId, existing, command.patch.vars) - : existing.vars; -``` -(`command.patch.vars` is `McpServerVarDraft[] | undefined`; `existing.vars` is `McpServerVar[]`. -`configIdentity` reads only `.name`, present on both — type-compatible. `mcp.builtin-sync` does NOT -go through this branch, so built-ins are governed solely by the reactor skip in 2e.) - -**2e. Built-in skip — `apps/server/src/ru-fork/mcp/McpReactor.ts` `reconcileBuiltinsWith` (97-172).** -Add a collision check before the dispatch. Before (the per-definition loop body, ~115-122): -```ts - shippedBuiltinIds.add(definition.builtinId); - const serverId = builtinServerId(definition.builtinId); - const hash = builtinHash(config, definition); - const installed = installedByBuiltinId.get(definition.builtinId); - if (installed && installed.builtinHash === hash) { - continue; // up to date - } - yield* engine - .dispatch({ - type: "mcp.builtin-sync", -``` -After: -```ts - shippedBuiltinIds.add(definition.builtinId); - const serverId = builtinServerId(definition.builtinId); - const hash = builtinHash(config, definition); - const installed = installedByBuiltinId.get(definition.builtinId); - if (installed && installed.builtinHash === hash) { - continue; // up to date - } - // ru-fork: skip-on-collision — never duplicate/clobber a DIFFERENT server that already has this - // config (e.g. a user hand-added the same command). Checked on add AND update; the built-in - // stays on its last good config and self-heals once the conflicting server is gone. - const shippedIdentity = configIdentity(config, builtinShippedVars(definition), [], {}); - const collision = catalog.find( - (server) => - server.id !== McpServerId.make(serverId) && - configIdentity(server.config, server.vars, server.extraArgs, server.extraHeaders) === - shippedIdentity, - ); - if (collision) { - yield* Effect.logDebug("mcp reactor: built-in skipped (config conflicts with existing server)", { - builtinId: definition.builtinId, - conflictsWith: collision.id, - }); - continue; - } - yield* engine - .dispatch({ - type: "mcp.builtin-sync", -``` -Add the import at the top of `McpReactor.ts`: -```ts -import { configIdentity } from "@ru-fork/mcp-core"; -``` -(Built-ins ship no `extraArgs`/`extraHeaders` — they're authored-empty — so `[]`/`{}` match what -`buildSyncedBuiltin` stores. `builtinShippedVars(definition)` is already imported/used here.) - -> **Caveat to log (no-silent-caps):** the skip means a future built-in *update* won't land while a -> conflicting custom server exists. The `logDebug` above records it; item 5's notice can surface it -> to the user later. Acceptable and non-destructive. - ---- - -## 3. qwen overlay verification 🔴 — the biggest gap - -### Mechanism (confirmed) -The overlay reaches qwen out-of-band (`QWEN_CODE_SYSTEM_SETTINGS_PATH` + -`--allowed-mcp-server-names`); the ACP `session/new` response carries qwen's actually-loaded -servers/tools but is **never inspected** (`AcpSessionRuntime.ts:463-489`). If qwen ignores the -overlay (wrong path, schema drift, version mismatch), the server can't tell. - -**`mcp-probe` (`mcp-probe/test.js`) already verifies a LOT against real qwen:** it writes the -overlay, spawns `qwen --acp`, drives `session/new`, and inspects **two oracles** — qwen's own -`ToolRegistry created: [...]` log and each fake server's START/LIST/CALL audit log — to assert the -expected `mcp__server__tool` set registered (cases D1–D*, allowlist, per-tool, http, env, trust). -What it does **not** do: inspect the ACP `session/new` **response** (`sessionSetupResult`), and it -doesn't exercise our production `AcpSessionRuntime` path. So it proves *qwen honors an overlay*, not -that *our running server confirms qwen honored this overlay*. - -### DECISIVE FINDING (verified in qwen-code 0.13.1) -qwen's `newSession` handler (`packages/cli/src/acp-integration/acpAgent.ts:196-215`) returns **only**: -```ts -return { sessionId: session.getId(), models: availableModels, modes: modesData, configOptions }; -``` -Our `NewSessionResponse` type (`packages/effect-acp/src/_generated/schema.gen.ts:7262-7306`) matches: -`{ _meta?, configOptions?, models?, modes?, sessionId }`. **There is NO `mcpServers` / loaded-tools / -per-server-status field in the `session/new` response.** qwen discovers MCP servers during -`Config.initialize()` but never reports the result back over ACP (the only post-session push is -`available_commands_update` — slash commands, not MCP servers; `Session.ts:379-407`). And -`AcpSessionRuntime` doesn't even receive the overlay's server names (they're consumed by the spawn -env in `CliAcpSupport.ts`, not passed into the runtime). **So "inspect `sessionSetupResult` and -compare" is impossible in 0.13.1 — the data isn't on the wire.** This is proven, not assumed. - -### Options (re-evaluated against that fact) -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Manual `check-mcp-feature.md` runbook + additive probe assertions** (recommended now) | The probe already reads qwen's real registry log + server audit logs — the only oracle that exists in 0.13.1; zero product risk | Manual, not CI | Doc + probe only | -| B. Runtime parse of qwen's debug registry log from our spawned process | Closes it in-product without a qwen change | **Fragile** — depends on the `ToolRegistry created:` debug line + `QWEN_RUNTIME_DIR` + log timing; breaks on any qwen logging change; needs us to enable + locate qwen's debug log per session | Hacky — string-scraping a debug log in the hot path | -| C. Upstream qwen: add loaded-MCP status to the `session/new` `_meta`, then inspect it | The clean, durable fix; CI-able | Requires a qwen-code change + version pin; out of our repo | Right long-term; external | - -**Decision: A now.** B is written below so it's not an assumption — but I recommend **against** -shipping it (fragility > value); C is the real fix, filed as upstream follow-up. - -### 3a. Runbook — created: `mcp-specs/current/check-mcp-feature.md`. - -### 3b. Additive probe assertions (proposal; you approve + run) -The probe ALREADY inspects qwen's registry log + audit logs — that's the working oracle. Additions: -- A **negative case**: overlay allow-lists a server-name with no matching server → assert qwen's - registry contains none of its `mcp__name__*` tools (proves the probe detects a non-load). -- An explicit **two-project isolation case**: two overlays, two sessions → assert each registry set - is disjoint. These extend the existing GO/NO-GO contract; additive, low-risk. **You run it.** - -### 3c. Option B, written out (NOT recommended — fragility documented) -If we wanted in-product detection without a qwen change, the only signal is qwen's runtime debug log -(the same `ToolRegistry created: [...]` line `mcp-probe/monitor`/`test.js` parses). It would require: -spawning qwen with its debug log enabled + a known `QWEN_RUNTIME_DIR`, then in `AcpSessionRuntime` -after `created` (`AcpSessionRuntime.ts:~463`), read that log, parse the `mcp__server__*` tool names, -and `logError` when the set differs from the overlay's `allowedServerNames`. **Two blockers make this -unfit for production:** (1) `AcpSessionRuntime` doesn't currently receive `allowedServerNames` (it'd -need threading from `CliAcpSupport`/`ProviderCommandReactor` where the overlay is built); (2) it -scrapes an undocumented debug log with race-prone timing. **Recommendation: do not implement B.** -Keep verification at the probe (A); pursue C upstream. This is the honest, evidence-backed call — the -"inspect the response" idea cannot work because qwen 0.13.1 does not send the data. - -### 3d. Overlay CONFORMANCE — verified from qwen source (this part IS knowable, and it's good) -What we *can* prove statically (and did): **our overlay can never be silently rejected.** Verified in -qwen 0.13.1: -- **Schema match:** qwen's `mcpServers[name]` accepts `{command, args, env, cwd, url, httpUrl, - headers, tcp, timeout, trust, description, includeTools, excludeTools, …}` — all optional - (`sdk-typescript/src/types/protocol.ts:287-306`). Every key our `buildServerEntry` writes is valid. -- **Lenient parser:** unknown keys are logged to debug and **ignored, never rejected** - (`settings.ts:191-250`). -- **Precedence:** `QWEN_CODE_SYSTEM_SETTINGS_PATH` is the **highest-precedence** layer; `mcpServers` - uses `SHALLOW_MERGE` — our overlay wins per-server (`settings.ts:289-312`). -- **Read timing:** settings load at process startup, fresh from disk **every** `qwen --acp` spawn; no - hot-reload. **`--allowed-mcp-server-names`** overrides settings' allow/exclude entirely - (`config.ts:366-375, 941-953`). - -**Deliverable: a small overlay-conformance unit test** asserting our generated overlay only uses keys -in qwen's `MCPServerConfig` shape — so a future `buildServerEntry` change can't drift out of qwen's -schema unnoticed. That's the durable, testable half of #3; the probe covers the runtime "did it load". - ---- - -## 4. Encrypt secrets at rest 🔴 - -### Mechanism (confirmed) -Two plaintext locations: (a) `ServerSecretStore` `.bin` files under `${stateDir}/secrets/`; (b) the -resolved qwen overlay `${stateDir}/mcp/overlays//system.json`. Both are `0o600`/`0o700`, -no encryption. - -**qwen-code 0.13.1 scheme (to mirror), `file-token-storage.ts:25-63`:** -`scryptSync('qwen-code-oauth', \`${hostname}-${username}-qwen-code\`, 32)` → `aes-256-gcm`, -random 16-byte IV per write, format `ivHex:authTagHex:cipherHex`. (qwen also tries OS keychain via -optional `keytar`; the file scheme is its headless fallback — that's the one we mirror, since our -server is headless.) - -### The overlay constraint (decisive) -qwen reads `system.json` as **plaintext JSON** via `QWEN_CODE_SYSTEM_SETTINGS_PATH` -(`CliAcpSupport.ts:79-82`). We cannot encrypt it without modifying qwen. → **The only correct -hardening for the overlay is the existing FS perms + a documented `stateDir` exclusion from -backups/cloud-sync.** We encrypt the `.bin` store; we document the overlay as defense-by-perms. - -### Options — the `.bin` store -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Wrap encrypt/decrypt in `ServerSecretStore` set/create/get, scrypt+AES-GCM keyed by host+user** (recommended) | Mirrors qwen; transparent to callers; round-trippable; authenticated (tamper-evident) | Key is derivable on the same host/user (matches qwen's own threat model — protects against off-host copy, not a local attacker) | Clean — one Layer, no interface change | -| B. OS keychain via `keytar` | Strongest on desktop | Optional native dep; broken headless (our server) → needs the file fallback anyway | Heavy; new dep | -| C. Leave plaintext, perms only | Zero work | Plaintext at rest | — | - -**Decision: A** (same algorithm + key derivation as qwen, so behavior/threat-model parity). - -### Testability — YES -Pure round-trip unit: `encrypt(decrypt(x)) === x`; tamper a byte → `decrypt` throws; a value -written by the new code is unreadable as UTF-8 plaintext. Plus a store-level test: `set` then `get` -returns the same bytes; an on-disk file does not contain the plaintext substring. - -### Exact code - -**4a. New helper — `apps/server/src/auth/secretCrypto.ts`** (new file; pure, no Effect): -```ts -// ru-fork: at-rest encryption for the ServerSecretStore `.bin` files. Mirrors qwen-code 0.13.1's -// headless file scheme (file-token-storage.ts): scrypt(host+user) → aes-256-gcm, format -// `ivHex:authTagHex:cipherHex`. Host+user-derived key matches qwen's threat model (protects a -// copied-off-host file, not a local same-user attacker). -import * as Crypto from "node:crypto"; -import * as os from "node:os"; - -const deriveKey = (): Buffer => { - const salt = `${os.hostname()}-${os.userInfo().username}-qwen-code`; - return Crypto.scryptSync("qwen-code-oauth", salt, 32); -}; - -// Derived once per process (scrypt is intentionally slow); host/user don't change at runtime. -const KEY = deriveKey(); - -export function encryptSecret(plaintext: Uint8Array): Uint8Array { - const iv = Crypto.randomBytes(16); - const cipher = Crypto.createCipheriv("aes-256-gcm", KEY, iv); - const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]); - const authTag = cipher.getAuthTag(); - const blob = `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`; - return new TextEncoder().encode(blob); -} - -export function decryptSecret(blob: Uint8Array): Uint8Array { - const parts = new TextDecoder().decode(blob).split(":"); - if (parts.length !== 3) { - throw new Error("Invalid encrypted secret format"); - } - const iv = Buffer.from(parts[0]!, "hex"); - const authTag = Buffer.from(parts[1]!, "hex"); - const decipher = Crypto.createDecipheriv("aes-256-gcm", KEY, iv); - decipher.setAuthTag(authTag); - return new Uint8Array(Buffer.concat([decipher.update(Buffer.from(parts[2]!, "hex")), decipher.final()])); -} -``` - -**4b. Wrap the store — `apps/server/src/auth/Layers/ServerSecretStore.ts`.** Add the import: -```ts -import { decryptSecret, encryptSecret } from "../secretCrypto.ts"; -``` -`get` (38-51) — decrypt **after** the read-error catch. Decryption is synchronous and throws on a -tampered/legacy blob; putting it in `Effect.map` would make that a defect AND feed the wrong cause -shape to the existing `.catch` (which reads `cause.reason._tag` from the readFile PlatformError). So -handle the read error first, then decrypt in `Effect.try`. Before: -```ts - const get: ServerSecretStoreShape["get"] = (name) => - fileSystem.readFile(resolveSecretPath(name)).pipe( - Effect.map((bytes) => Uint8Array.from(bytes)), - Effect.catch((cause) => - cause.reason._tag === "NotFound" - ? Effect.succeed(null) - : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, - cause, - }), - ), - ), - ); -``` -After: -```ts - const get: ServerSecretStoreShape["get"] = (name) => - fileSystem.readFile(resolveSecretPath(name)).pipe( - Effect.catch((cause) => - cause.reason._tag === "NotFound" - ? Effect.succeed(null) - : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, - cause, - }), - ), - ), - // ru-fork: at-rest decryption (scrypt+AES-GCM) runs AFTER the read-error catch — a NotFound - // stays null; a tampered/legacy blob becomes a typed SecretStoreError (Effect.try converts the - // synchronous decrypt throw — it would otherwise be an uncaught defect). - Effect.flatMap((bytes) => - bytes === null - ? Effect.succeed(null) - : Effect.try({ - try: () => decryptSecret(Uint8Array.from(bytes)), - catch: (cause) => - new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }), - }), - ), - ); -``` -(Confirm `Effect.try` is available in this beta — it's a standard constructor; the codebase's -`Effect.catch`/`Effect.gen` usage implies the full surface. The `set`/`create` encrypts below are -infallible in practice — aes-256-gcm with a fixed 32-byte key + random 16-byte IV cannot fail — so -computing `encryptSecret(value)` before the Effect is safe.) -`set` (53) — encrypt before write. Before: -```ts - const set: ServerSecretStoreShape["set"] = (name, value) => { - const secretPath = resolveSecretPath(name); - const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; - return Effect.gen(function* () { - yield* fileSystem.writeFile(tempPath, value); -``` -After: -```ts - const set: ServerSecretStoreShape["set"] = (name, value) => { - const secretPath = resolveSecretPath(name); - const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; - const encrypted = encryptSecret(value); // ru-fork: at-rest encryption - return Effect.gen(function* () { - yield* fileSystem.writeFile(tempPath, encrypted); -``` -`create` (78-86) — encrypt before write. Before: -```ts - const create: ServerSecretStoreShape["set"] = (name, value) => { - const secretPath = resolveSecretPath(name); - return Effect.scoped( - Effect.gen(function* () { - const file = yield* fileSystem.open(secretPath, { - flag: "wx", - mode: 0o600, - }); - yield* file.writeAll(value); -``` -After: -```ts - const create: ServerSecretStoreShape["set"] = (name, value) => { - const secretPath = resolveSecretPath(name); - const encrypted = encryptSecret(value); // ru-fork: at-rest encryption - return Effect.scoped( - Effect.gen(function* () { - const file = yield* fileSystem.open(secretPath, { - flag: "wx", - mode: 0o600, - }); - yield* file.writeAll(encrypted); -``` -> **No migration needed** — the MCP feature has never shipped (zero users/data). There are no legacy -> plaintext `.bin` files to preserve. Just ship the encrypted format; no fallback branch, no wipe step. - -**4c. Overlay — make it EPHEMERAL (verified safe), not a persistent plaintext file.** -*Verified lifecycle:* `writeOverlay` is awaited **before** the spawn (`ProviderCommandReactor.ts:531-539` -→ `startProviderSession`); the path is passed fresh on **every** spawn; resume/config-change = a -**brand-new `qwen --acp`** with a freshly-written overlay; qwen reads the file **only at process -startup** and has fully consumed it by the time the ACP `initialize` RPC resolves -(`AcpSessionRuntime.ts:410-414`); each spawn re-reads from disk (no hot-reload). Today the file is -deleted **only on project-deletion** (`McpReactor.ts:572-577`) — so resolved secrets sit in plaintext -on disk indefinitely. Since we have no backcompat constraint, the clean design is: -| Option | Plaintext-on-disk window | Cons | -|---|---|---| -| **A. Ephemeral per-spawn overlay** (recommended) | seconds (spawn → session established) | small new plumbing: a post-init delete + a per-spawn path | -| B. Keep per-project persistent file, `0600` only | forever (until project deleted) | resolved secrets live on disk for the life of the project | - -**Recommended — Option A:** write a **per-spawn** overlay file (e.g. `overlays//.json`, -not the shared per-project path — avoids a race where a concurrent spawn of the same project reads a -file another session just deleted), pass it to qwen, and **delete it right after the session is -established** (after `session/new`/`session/load` resolves in `AcpSessionRuntime`), with a short -fallback timer (~30s) so a spawn that never establishes still cleans up. Net: secrets are encrypted at -rest in the `.bin` store, and the only plaintext copy (the overlay) exists for the few seconds qwen -needs to read it — then it's gone. -*Cons:* `AcpSessionRuntime` must know the overlay path to delete it (it already receives -`settingsOverlayPath` in the spawn input) and delete it post-`initialize`; the per-spawn filename is a -small change to `writeOverlay`'s path scheme (fine — no backcompat). **Testable:** assert the overlay -exists at spawn and is gone after the session resolves (and after the fallback timer if it doesn't). - ---- - -## 5. Surface UI errors 🟠 - -### Mechanism (confirmed) -All mutations funnel through `dispatchMcpCommand` (`useMcp.ts:100-108`) which does -`.catch(() => undefined)`; `recheck` (274-281) likewise. The modal already has an `error` state + -a red block (`McpServerDialog.tsx:99`, `:384`); the toast system is global -(`toastManager.add(stackedThreadToast({type:"error", title, description}))`, -`components/ui/toast.tsx` + `toastHelpers.ts`). The RPC `dispatchCommand` returns a promise that -**rejects with `Error`** carrying the decider's `detail` (`wsTransport.ts:66-77`). - -### Routing table — case → surface → message -| Action | Hook fn | Origin | Surface | Message (ru) | -|---|---|---|---|---| -| Add server | `addServer` | **Modal** | Red block at modal bottom | decider `detail` (e.g. «Сервер с такой конфигурацией…») | -| Edit server | `updateServer` | **Modal** | Red block at modal bottom | decider `detail` | -| Save project config | `setProjectBinding` | **Modal** (ProjectConfigDialog) | Red block at modal bottom | decider `detail` | -| Delete server | `removeServer` | List/Detail | **Toast** error | «Не удалось удалить сервер» + detail | -| Toggle server on/off | `setServerEnabled` | List/Detail | **Toast** error | «Не удалось изменить состояние сервера» | -| Add to project | `addBindingToProject` | Dropdown | **Toast** error | «Не удалось добавить сервер в проект» | -| Remove binding | `removeBinding` | Row | **Toast** error | «Не удалось убрать сервер из проекта» | -| Toggle binding on/off | `setBindingEnabled` | Row | **Toast** error | «Не удалось изменить состояние» | -| Toggle tool | `setToolEnabled` | Tool list | **Toast** error | «Не удалось изменить инструмент» | -| Recheck/refresh | `recheck` | Button | **Toast** error | «Не удалось проверить сервер» | - -### Options — how mutations report failure -| Option | Pros | Cons | Arch fit | -|---|---|---|---| -| **A. Modal mutations return `Promise` (reject on failure); non-modal mutations toast inside the hook** (recommended) | Modal controls its own error block; row actions get a uniform toast for free; minimal call-site churn | Modal call sites must `await` + `catch` → setError | Clean — splits "throw to caller" vs "self-toast" by origin | -| B. All mutations toast inside the hook | Zero call-site changes | Modal errors should stay in the modal, not as a toast over it; wrong UX | Simpler but wrong surface | -| C. Return a result object `{ok,error}` everywhere | Explicit | Verbose; touches every call site | More churn | - -**Decision: A.** Split `dispatchMcpCommand` into a throwing variant (for modal mutations) and a -toasting variant (for fire-and-forget row actions). - -### Testability -Web has no test target → validate with typecheck + lint only (per constraints). Logic is simple -plumbing; no unit tests added. (The decider rejection it surfaces IS unit-tested in item 2.) - -### Exact code — `apps/web/src/ru-fork/mcp-manage/useMcp.ts` - -**5a.** Replace the single swallowing dispatcher (100-108) with two variants: -```ts -function dispatchMcpCommandOrThrow( - command: Parameters< - ReturnType["client"]["orchestration"]["dispatchCommand"] - >[0], -): Promise { - // ru-fork: modal callers await this and render the rejection in the dialog's error block. - return getPrimaryEnvironmentConnection() - .client.orchestration.dispatchCommand(command) - .then(() => undefined); -} - -function dispatchMcpCommandToast( - command: Parameters< - ReturnType["client"]["orchestration"]["dispatchCommand"] - >[0], - failureTitle: string, -): void { - // ru-fork: fire-and-forget row actions — a failure becomes a UI toast (already-wired notifications). - void getPrimaryEnvironmentConnection() - .client.orchestration.dispatchCommand(command) - .catch((error: unknown) => - toastManager.add( - stackedThreadToast({ - type: "error", - title: failureTitle, - description: error instanceof Error ? error.message : String(error), - }), - ), - ); -} -``` -Add this import at the top of `useMcp.ts` (verified path/alias — `toast.tsx` re-exports both -symbols; existing callers use exactly this line, e.g. `CommandPalette.tsx:107`): -```ts -import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -``` - -**5b.** Modal mutations return the promise. `addServer` (160-179) before returns `string` -synchronously; the dialog needs to await. New shape: -```ts - addServer: (input) => { - const serverId = `srv-${randomUUID()}`; - const description = trimmedDescription(input.description); - return dispatchMcpCommandOrThrow({ - type: "mcp.server-add", - commandId: newCommandId(), - serverId: McpServerId.make(serverId), - draft: { - name: input.name, - ...(description !== undefined ? { description } : {}), - config: uiConfigToContract(input.config), - vars: uiVarsToDraft(input.vars), - extraArgs: [...input.extraArgs], - extraHeaders: { ...input.extraHeaders }, - ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }, - createdAt: nowIso(), - }).then(() => serverId); - }, -``` -…with the interface change `addServer: (input) => Promise` and `updateServer`/ -`setProjectBinding` → `Promise`. Row actions switch to `dispatchMcpCommandToast(cmd, "…")`, -`recheck` replaces `.catch(() => undefined)` with the toast. (Full per-mutation diff is mechanical; -listed in the routing table. The interface (`McpMutations`, 125-157) updates the three modal -signatures to return promises.) - -**5c.** Modal consumes it — `McpServerDialog.tsx`. The dialog already has -`const [error, setError] = useState(null)` (:99) and renders the error block at the -bottom (`{error &&

{error}

}`, :384). The single dispatch -point is `commit` (:151-160), called from both `handleSubmit` (:205) and the warn-on-impact -confirmation path — so awaiting in `commit` covers both. The real close is `setOpen(false)` (NOT an -`onClose()`). Verbatim before (:151-160): -```tsx - /** Dispatch the add/update, then close the dialog and clear staged state. */ - function commit(input: AddServerInput) { - if (isEditing) { - updateServer(server.id, input); - } else { - addServer(input); - } - setOpen(false); - setImpact(null); - setPendingInput(null); - } -``` -After (close only on success; render the decider rejection in the existing error block): -```tsx - /** Dispatch the add/update; close + clear on success, or surface the server error in the dialog. */ - function commit(input: AddServerInput) { - // ru-fork: addServer/updateServer now return a Promise that rejects with the decider error. - const dispatched = isEditing ? updateServer(server.id, input) : addServer(input); - void dispatched - .then(() => { - setOpen(false); - setImpact(null); - setPendingInput(null); - }) - .catch((caught: unknown) => { - setError(caught instanceof Error ? caught.message : "Не удалось сохранить сервер."); - }); - } -``` -(`addServer` returns `Promise`, `updateServer` `Promise` → the ternary is -`Promise`; `.then(() => …)` discards the value. The error block at :384 renders the -Russian decider `detail` unchanged.) - -> **Caller audit (required):** `addServer` currently returns the new `serverId` **synchronously** -> (`useMcp.ts:178`); making it `Promise` breaks any caller that uses the id inline. The -> dialog's `commit` ignores the return (safe). Before impl, grep every `addServer(` call site and -> confirm none consume the returned id synchronously (the "add then immediately bind" flow, if any, -> must `await`). This is the one non-mechanical part of item 5. - ---- - -## 6. Dialog controls — wire trust, remove the other, no autobind toggle 🟡 - -### Decision (confirmed end-to-end against qwen + our code) -- **«Доверять серверу»** checkbox (`McpServerDialog.tsx`, state `trusted` :96): currently **never - sent**. → **WIRE IT** as a real catalog-level `trust` field (the one true lever over MCP - confirmation — see the full proof + exact diffs below). Default true. -- **«Включить все инструменты»** checkbox (state `enableAllTools` :97): **never sent**, and redundant - with the existing per-project tool policy (allow/deny → qwen `includeTools`/`excludeTools`). → - **Dead. Remove.** -- **`autobindDefaults`** (server setting, honored in `McpReactor.autobindBuiltinsForProject`): - stays **false**, **no UI toggle** (your decision) → a new project starts with **0 MCPs**. Confirm - the default false (it already is, `settings.ts:236`). - -### Removal (the «Включить все инструменты» checkbox only) -**Remove** in `McpServerDialog.tsx`: the «Включить все инструменты» `` -block (`:275-287`), its `enableAllTools` state (`:97`), and its `reset()` line (`:114`). Redundant — -tool on/off is the existing per-project `toolPolicy`. The «Доверять серверу» checkbox is **kept and -wired** (next section); the `trusted` state (`:96`) is reused, not removed. -**Testability:** web = typecheck/lint only; the `trust` logic is unit-tested server-side (fingerprint -+ overlay tests, below). - -### «Доверять серверу» (trust) — WIRE IT (the one real confirmation lever) -**Fully verified, no blocking catch.** Earlier I wrongly said folder-trust gates this — it doesn't. -Proof chain: our overlay writes `security.folderTrust.enabled: false` (`McpOverlay.ts:188`) → -`isFolderTrustEnabled()` = false (`trustedFolders.ts:194-196`) → folder-trust feature OFF → -`isWorkspaceTrusted()` trusted (`trustedFolders.ts:224-228`) → `isTrustedFolder()` = **true** -(`config.ts:1899-1916`). **The folder is already trusted.** So a per-server `trust: true` -(`mcp-tool.ts:132-143` `getDefaultPermission`: `trust===true && isTrustedFolder()` → `'allow'`) makes -tools auto-run with **no** change to the folder-trust line. And since **full-access is locked** -(`DISABLE_AUTO_APPROVE`, `apps/web/src/ru-fork/config.ts:6`) our `request_permission` auto-answer -(`CliAdapter.ts:958`, gated on full-access) never fires — so **the overlay `trust` field is the ONLY -lever** over MCP confirmation. Today we send no `trust` → only read-only tools auto-run -(`mcp-tool.ts:137` `readOnlyHint`), write tools ask. - -**Behavior:** catalog-level `trust` (default true = «доверять», auto-run). Uncheck → that server's -**write** tools prompt on the next turn for **every** project using it (catalog change → every -project's overlay fingerprint differs → qwen respawns via the existing `overlayChanged` gate). Каталог -→ все проекты. (Honesty: unchecking a server with only **read-only** tools changes nothing — qwen -always auto-allows read-only — so the checkbox visibly matters only for write-capable servers; add a -tooltip.) - -### Exact diffs (~6 files, low risk) -**Contract — `packages/contracts/src/ru-fork/mcp.ts`.** `McpCatalogServer` (after `enabled`, :150): -```ts - // ru-fork: server-wide auto-approval. true ⇒ qwen runs this server's tools without confirmation - // (folder is trusted); false ⇒ write tools prompt (read-only still auto-run). Catalog-level — a - // change respawns every project's session via the overlay fingerprint. - trust: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), -``` -`McpServerDraft` (add, after `description`, :250) and `McpServerDraftPatch` (after `enabled`, :269): -```ts - trust: Schema.optionalKey(Schema.Boolean), -``` -**DB — `apps/server/src/persistence/Migrations/031_Mcp.ts`** (after `enabled`, :32): -```sql - trust INTEGER NOT NULL DEFAULT 1, -``` -**Builders — `apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts`.** `buildAddedServer` (after `enabled: true,`): -```ts - trust: draft.trust ?? true, -``` -`applyServerUpdate` (after the `enabled:` line): -```ts - trust: patch.trust ?? existing.trust, -``` -`buildSyncedBuiltin` (after its `enabled:` line): -```ts - trust: input.existing?.trust ?? true, -``` -**Overlay — `apps/server/src/ru-fork/mcp/McpOverlay.ts`.** `buildServerEntry` gains a `trust` param and -emits it (qwen's field is **`trust`**). In both transport branches add `trust,` to the returned object: -```ts -function buildServerEntry( - resolved: ResolvedServerConfig, - policy: McpToolPolicy, - trust: boolean, -): Record { - // …toolFilter + timeout unchanged… - // stdio branch: return { command, args, env, ...(cwd?{cwd}:{}), timeout, ...toolFilter, trust }; - // http branch: return { httpUrl, headers, timeout, ...toolFilter, trust }; -} -``` -The call (`:174`): `buildServerEntry(resolved, binding.toolPolicy, server.trust)`. The -`fingerprintEntries.push({...})` (`:176-180`) gains `trust: server.trust,`. -**Fingerprint — `packages/mcp-core/src/fingerprint.ts`.** Add `readonly trust: boolean;` to -`OverlayServerEntry`, and add `trust: entry.trust,` to the canonical object in `overlayFingerprint` → -a trust change yields a new fingerprint → respawn. -**UI — `McpServerDialog.tsx`.** The `trusted` state already exists (`:96`) — now actually **send** it: -add `trust: trusted` to the `AddServerInput`, and in `useMcp.ts` `addServer`/`updateServer` include -`trust` in the command `draft`/`patch`. **Remove** the «Включить все инструменты» checkbox (`:97, :114, -:275-287`) — tool on/off is already the per-project `toolPolicy` (→ qwen `includeTools`/`excludeTools`, -`mcp-client.ts:1426-1446`). - -**Cons:** trust is catalog-level (no per-project override) — matches the model (projects tune per-tool -via `toolPolicy`). Read-only servers ignore it (qwen behavior) — tooltip it. No folder-trust change. -Testable: a fingerprint unit test (trust flips the hash) + an overlay test (entry carries `trust`). - ---- - -## 7. Non-transactional secret write 🟠 - -### Mechanism (confirmed) -`decideOrchestrationCommand` runs at `OrchestrationEngine.ts:172-175` — **before** -`sql.withTransaction` (177). The decider's `splitServerVars`/`resolveBindingVarValues` call -`secretStore.set()` immediately (`McpSecrets.ts:64-67,104-111`). If the subsequent event append / -projection fails inside the transaction, the secret is already on disk → **orphan** (no event -references it). No compensation today. (Note: item 2 already moves the uniqueness reject *before* -the secret write, removing one orphan source.) - -### Options -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Compensating cleanup: on transaction failure, remove secrets written during the decide phase** (recommended) | Minimal; localized; no decider rewrite | Needs the decide phase to RECORD which secret names it wrote (a small collector) | Moderate — thread a "written refs" set out of decide | -| B. Move secret writes INSIDE the transaction | Truly atomic | Big refactor: secret IO is FS, not SQL — it can't join the SQL tx; you'd need a 2-phase pattern | Architecture change | -| C. Orphan GC sweep (already exists: `pruneByPrefix`/`gcOrphanedSecrets`) covers it eventually | Already implemented; self-heals | Window of an orphan secret on disk until the next reconcile GC | Already in place | -| D. Accept (rely on C) | Zero work | A transient orphan secret persists until GC | — | - -### Both options, written out + evaluated - -**Option A (naive recompute) — REJECTED, it's unsafe.** Wrapping the transaction in `Effect.onError` -and recomputing secret names from the command (`mcpVarSecretName` from `command.serverId` + -`draft.vars` [+ `projectId`]) then `remove`-ing them looks clean: -```ts -// AFTER the existing .catchTag("SqlError", …) on the transaction — DO NOT USE (see why below): -.pipe( - Effect.onError(() => - Effect.gen(function* () { - if (envelope.command.type === "mcp.server-add") { - for (const draft of envelope.command.draft.vars) { - if (draft.secret && draft.value !== null) { - yield* serverSecretStore - .remove(mcpVarSecretName({ serverId: envelope.command.serverId, varName: draft.name })) - .pipe(Effect.ignore); - } - } - } - // …binding-set / server-update branches… - }), - ), -) -``` -**Why it's unsafe:** `splitServerVars` honors `keepSecret` (`McpSecrets.ts`) — on a `server-update` -that keeps an existing secret, NO new write happens, the var **reuses the prior ref**. The recompute -above can't see that, so on a rollback it would `remove` a secret that the *previous, committed* add -still references → it deletes a live credential. Same hazard for binding `keepNames`. A *safe* Option -A would require the decide phase to RETURN the set of secret names it actually wrote (a real collector -threaded out of `splitServerVars`/`splitBindingVarValues` into the engine), then remove exactly those -— a real change to the decider's return contract. Bigger, and still only closes a window GC closes. - -**Option C (rely on existing orphan GC) — RECOMMENDED, and it's already correct + safe.** No code -change. `gcOrphanedSecretsEffect` (`McpReactor.ts:371-396`) computes `live` = every secret ref -referenced by the **persisted** catalog + bindings, then `pruneByPrefix(MCP_VAR_SECRET_PREFIX, live)` -removes every `mcp-var-*.bin` NOT in `live`. An orphan from a failed append is never in the DB → never -in `live` → pruned on the next reconcile. Crucially it **cannot** delete a referenced secret (the -exact bug that sinks Option A), because `live` is derived from what's actually referenced. It runs on -every reactor signal (user edit / project-created / reconcile). Combined with **item 4** (secrets now -encrypted at rest), an orphan is an *encrypted, unreferenced* blob pruned within one reconcile — -negligible risk. The only residual: a crash before the next reconcile leaves the orphan until the -startup reconcile (still encrypted, still pruned then). - -**Decision: C** — document that orphan GC is the transactional safety net (add a line to -`IMPLEMENTATION.md`); do not add the unsafe/heavier Option A. (If you want belt-and-suspenders later, -it's the *safe* A: thread written-names out of decide — I'll write that exact change on request.) - -### Testability — YES (fault-injection): force `eventStore.append` to fail after a secret write; -assert the orphan `.bin` is gone after a reconcile (Option C) and that a *kept* secret survives. - ---- - -## 8. varValues / `${VAR}` ↔ declared-vars validation 🟠 - -### Mechanism (confirmed) -`mcp.binding-set` (`decider.ts:849-881`) stores `command.patch.varValues` with **no check** that -its keys are declared vars. `expandTemplate` (`resolver.ts:45-59`) turns an unknown `${VAR}` into -`""` silently. `resolveVarValues` (71-90) turns a missing var into `""`. → a typo'd binding key is -silently ignored; an undeclared `${VAR}` silently blanks. - -### Options -| Option | Pros | Cons | Arch fit | -|---|---|---|---| -| **A. Decider guard on `mcp.binding-set`: reject varValues keys not in `server.vars`** (recommended) | Catches the common typo at the authority; cheap | Doesn't catch config-side `${VAR}` typos (covered by B) | Clean — same invariant pattern | -| **B. Add-time config validation: every `${VAR}` in config resolves to a declared var (or `${PROJECT_CWD}`)** (recommended, pairs with A) | Catches template/declaration mismatch at add | Needs a placeholder-extractor in resolver (pure) | Clean — pure helper + decider call | -| C. Resolver throws on unknown `${VAR}` at runtime | Centralized | Too late (server already "added"); turns a config error into a probe failure | Worse UX | - -**Decision: A + B** (validate both binding keys and config placeholders at the decider). - -### Testability — YES (decider harness): bind with an undeclared key → throw; add a server whose -config has `${UNDECLARED}` → throw; declared-only → ok. - -### Exact code -**8a. Pure placeholder extractor — `resolver.ts`** (append near `expandTemplate`): -```ts -/** ru-fork: every distinct `${NAME}` referenced by a config (excludes the builtin ${PROJECT_CWD}). */ -export function configPlaceholders(config: McpServerConfig): ReadonlySet { - const names = new Set(); - const scan = (value: string): void => { - for (const match of value.matchAll(TEMPLATE_PATTERN)) { - const name = match[1]; - if (name !== undefined && name !== PROJECT_CWD) { - names.add(name); - } - } - }; - if (config.transport === "stdio") { - scan(config.command); - config.args.forEach(scan); - } else { - scan(config.httpUrl); - Object.values(config.headers).forEach(scan); - } - return names; -} -``` -**8b. Decider — binding key check** in `mcp.binding-set` (after `requireCatalogServer` resolves -`server`, before `resolveBindingVarValues` writes secrets): -```ts - // ru-fork: reject binding varValues whose keys are not declared vars (catches typos that would - // otherwise be stored and silently ignored). - const declaredNames = new Set(server.vars.map((declared) => declared.name)); - const unknownKeys = Object.keys(command.patch.varValues ?? {}).filter( - (key) => !declaredNames.has(key), - ); - if (unknownKeys.length > 0) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `Неизвестные переменные для сервера «${server.name}»: ${unknownKeys.join(", ")}.`, - }); - } -``` -**8c. Decider — config placeholder check** in `mcp.server-add` (and `server-update` when -`patch.config`/`patch.vars` change), after the uniqueness check: -```ts - // ru-fork: every ${VAR} in the config must resolve to a declared var (or ${PROJECT_CWD}). - const declaredVarNames = new Set(command.draft.vars.map((variable) => variable.name)); - const danglingPlaceholders = [...configPlaceholders(command.draft.config)].filter( - (name) => !declaredVarNames.has(name), - ); - if (danglingPlaceholders.length > 0) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `Шаблон ссылается на необъявленные переменные: ${danglingPlaceholders.join(", ")}.`, - }); - } -``` -(Imports: add `configPlaceholders` to the decider's `@ru-fork/mcp-core`/contracts import. Confirm -`TEMPLATE_PATTERN`/`PROJECT_CWD` are module-scoped in resolver.ts — they are, per the research.) - ---- - -## 9. Missing secret → blank credential 🟠 - -### Mechanism (confirmed) -`materializeSecretValues` (`McpSecrets.ts:150-154`) maps a missing ref to `""`; `resolveVarValues` -(`resolver.ts:85-87`) maps a missing secret ref to `""`. A deleted/never-set secret → server -launches with a blank credential, looks "complete," fails confusingly. - -### Options -| Option | Pros | Cons | Arch fit | -|---|---|---|---| -| **A. Treat a declared-but-missing secret as "incomplete" (exclude from probes/overlay, surface a status)** (recommended) | No blank-credential launches; matches the existing «требует настройки» incomplete-instance concept | Needs `materialize`/resolve to report which refs were missing | Clean — extends the existing completeness gate | -| B. Hard error at resolution | Explicit | A single missing secret errors the whole reconcile pass | Too broad | -| C. Leave blank | Zero work | Silent broken server | — | - -**Decision: A** — fold "missing secret ref" into the existing incomplete-instance exclusion (the same -gate that hides a template with an unfilled per-project hole). The completeness gate today is -`missingRequiredVars` (`resolver.ts:98-106`, pure), checked in `computeDesiredEffect` -(`McpReactor.ts` catalog loop + binding loop, BEFORE `mergeDesired`/`materializeSecretValues`) and in -`McpOverlay.ts:156`. A missing secret ref needs the store (async), so it's a sibling **effectful** -check at the same gate. - -> **Scope split (honest):** (a) the **safety** fix — exclude the instance so qwen never launches with a -> blank credential — is exact code below and fully lands. (b) Showing «требует настройки» in the UI -> for this case is a SEPARATE change: the client computes `incomplete`/`missingVars` from catalog vars -> (`adapters.ts`) and **cannot see** a missing server-side ref, so surfacing it needs a new -> per-instance flag on the runtime snapshot. (a) is the fix; (b) is a follow-on, flagged not coded. - -### Exact code (safety exclusion) -**9a. Effectful predicate — `apps/server/src/ru-fork/mcp/McpSecrets.ts`** (append; `effectiveVarValue` -+ `isSecretRef` are already module-scoped here, used by `collectVarSecretRefs`): -```ts -/** ru-fork: names of REQUIRED secret vars whose stored secret is ABSENT (deleted / never written). - * Such a var resolves to "" and would launch a blank credential — caller must treat the instance as - * incomplete (exclude from probe + overlay), exactly like a missing required value. */ -export const missingSecretVarNames = ( - vars: ReadonlyArray, - varValues: Readonly>, -): Effect.Effect, SecretStoreError, ServerSecretStore> => - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore; - const missing: string[] = []; - for (const declared of vars) { - if (!declared.required) { - continue; // optional secret missing ⇒ "" is acceptable (matches missingRequiredVars semantics) - } - const effective = effectiveVarValue(declared, varValues); - if (effective !== null && isSecretRef(effective)) { - const bytes = yield* secretStore.get(effective.secretRef); - if (bytes === null) { - missing.push(declared.name); - } - } - } - return missing; - }); -``` -**9b. Gate in `computeDesiredEffect` — `McpReactor.ts`.** Catalog loop, after the `missingRequiredVars` -check. Before: -```ts - if (missingRequiredVars(server.vars, {}).length > 0) { - continue; - } - yield* mergeDesired(desired, server, {}, server.timeoutMs ?? undefined, `catalog:${server.id}`); -``` -After: -```ts - if (missingRequiredVars(server.vars, {}).length > 0) { - continue; - } - // ru-fork: a declared required secret whose stored value is gone ⇒ incomplete (never launch blank). - if ((yield* missingSecretVarNames(server.vars, {}).pipe(provideSecretStore)).length > 0) { - continue; - } - yield* mergeDesired(desired, server, {}, server.timeoutMs ?? undefined, `catalog:${server.id}`); -``` -Binding loop, after its `missingRequiredVars(server.vars, binding.varValues)` check, identically: -```ts - if (missingRequiredVars(server.vars, binding.varValues).length > 0) { - continue; // incomplete ⇒ not probed/spawned (§D8) - } - // ru-fork: missing stored secret ⇒ incomplete (don't launch a blank credential). - if ((yield* missingSecretVarNames(server.vars, binding.varValues).pipe(provideSecretStore)).length > 0) { - continue; - } - yield* mergeDesired(desired, server, binding.varValues, effectiveTimeoutMs(server, binding), `${binding.projectId}:${binding.serverId}`); -``` -(`provideSecretStore` already exists in `computeDesiredEffect` — `Effect.provideService(ServerSecretStore, secretStore)`. Import `missingSecretVarNames` from `./McpSecrets.ts` where `materializeSecretValues` is already imported.) -**9c. Same gate in `McpOverlay.ts`** after line 156's `missingRequiredVars` check — the overlay -writer has `provideSecretStore`/`materializeSecretValues` in scope; add the identical -`missingSecretVarNames(...).length > 0 ⇒ continue` guard so a missing-secret binding is also excluded -from the qwen overlay (defense in depth with 9b). - -### Testability — YES (secrets unit + computeDesired unit): declare a required secret var, store no -`.bin` → assert `missingSecretVarNames` returns its name AND the instance is absent from -`computeDesiredEffect`'s result (not resolved to `""`). - ---- - -## 10. 32-bit hash collision risk 🟡 - -### Mechanism (confirmed) -`fnv1a` is 32-bit (`resolver.ts:202-209`). Used by `configCacheKey` (probe-cache row key), -`dedupHash` (supervisor instance key + overlay fingerprint base), `builtinHash` (template-change -detection), and now `configIdentity` (item 2). A 32-bit space (~4.3B) gives a ~50% collision chance -around ~77k distinct keys (birthday bound) — low for a single user's catalog, but a collision in -`dedupHash`/`configCacheKey` is a real correctness bug (two configs share a probe row / instance). - -### Options -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Widen `fnv1a` to 64-bit (FNV-1a 64 via BigInt), 16 hex chars** (recommended) | Drops collision risk to negligible; one function; every caller inherits it; keys are opaque strings so no schema change | Slightly slower (BigInt); existing persisted 8-char keys become 16-char → a one-time cache/key reshuffle | Clean — single function, callers unchanged | -| B. Switch to SHA-256 (truncated) | Cryptographic | Heavier; overkill for non-adversarial identity | Fine but heavier | -| C. Leave 32-bit | Zero work | Latent collision bug in the riskiest keys | — | - -**Decision: A.** Keys are opaque (probe-cache `config_key` TEXT, in-memory dedup map, builtinHash -TEXT) → widening is transparent; old persisted rows simply don't match new keys and get GC'd/re-probed. - -### Testability — YES (pure unit): no collision across a large set of distinct inputs; stable output; -length is 16 hex. - -### Exact code — `packages/mcp-core/src/resolver.ts:202-209` -Before: -```ts -/** 32-bit FNV-1a → 8 hex chars. Used for the config/dedup/overlay/builtin identity hashes. */ -export function fnv1a(input: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < input.length; index += 1) { - hash ^= input.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} -``` -After: -```ts -/** ru-fork: 64-bit FNV-1a → 16 hex chars. Used for the config/dedup/overlay/builtin/identity hashes. - * Widened from 32-bit to make a collision (two different configs sharing a probe-cache row or a - * supervisor instance) negligible. Keys are opaque strings everywhere, so widening is transparent — - * pre-existing 8-char persisted keys simply no longer match and are GC'd/re-probed. */ -export function fnv1a(input: string): string { - const PRIME = 0x100000001b3n; - const MASK = 0xffffffffffffffffn; - let hash = 0xcbf29ce484222325n; - for (let index = 0; index < input.length; index += 1) { - hash ^= BigInt(input.charCodeAt(index)); - hash = (hash * PRIME) & MASK; - } - return hash.toString(16).padStart(16, "0"); -} -``` -> **Interaction with item 2:** apply item 10 first, then `configIdentity` inherits 64-bit for free. -> **Interaction with persisted state:** acceptable pre-release; on a real deployment we'd ship a -> migration that wipes `mcp_probe_cache` (re-probed harmlessly) — note in `DATA-MODEL.md`. - ---- - -## 11. Cross-client `watchedProjects` clobber 🟠 - -### Mechanism (confirmed) -`watchedProjectsRef` is a **single shared** `Ref | null>` -(`McpSupervisor.ts:242`), set wholesale by `setWatchedProjects` (248-249), driven per-connection by -`mcpSetActiveProject` (`ws.ts:952-957`). Two clients/windows → last writer wins; the other's watched -set is dropped (its servers stop being swept). In-memory, reset to `null` (= probe all) on restart. - -### Options -| Option | Pros | Cons / risk | Arch fit | -|---|---|---|---| -| **A. Per-connection watched sets; the sweep watches the UNION** (recommended) | Correct multi-client behavior; each window keeps its monitoring; cleared on disconnect | Needs a per-connection key + cleanup on disconnect | Moderate — keyed map + union read | -| B. Keep single Ref, never clear (union of all-time) | Trivial | Grows unbounded; a closed window keeps its projects hot forever | Leak | -| C. Accept clobber | Zero work | Multi-window users silently lose monitoring | — | - -**Decision: A** — store `Map>`; sweep watches the union; drop a key on -disconnect. **Supervisor is a singleton** (`McpSupervisorLive`, one instance shared across all -connections — confirmed), so the map lives in one place. - -> **Key choice (important):** the obvious key is `session.sessionId`, available at the seam -> (`makeWsRpcLayer(session.sessionId)`, ws.ts:1313). But that's the **auth** session — two browser -> tabs of the *same user* share it, so keying by it does NOT fix the very "two windows" case the gap -> describes. The correct key is a **per-WS-connection id** minted in `websocketRpcRouteLayer`. Both -> are written below; **recommend the per-connection id.** - -### Exact code (supervisor — identical for either key) -**Interface — `McpSupervisor.ts:189-190`.** Before: -```ts - readonly setWatchedProjects: (projectIds: ReadonlyArray) => Effect.Effect; -``` -After: -```ts - readonly setWatchedProjects: (connKey: string, projectIds: ReadonlyArray) => Effect.Effect; - readonly clearWatchedProjects: (connKey: string) => Effect.Effect; -``` -**State — `McpSupervisor.ts:242`.** Before: -```ts - const watchedProjectsRef = yield* Ref.make | null>(null); -``` -After: -```ts - // ru-fork: per-connection watched sets (was a single shared Set ⇒ last-writer-wins clobber). - const watchedBySessionRef = yield* Ref.make>>(new Map()); -``` -**Setters — `McpSupervisor.ts:248-249`.** Before: -```ts - const setWatchedProjects: McpSupervisorShape["setWatchedProjects"] = (projectIds) => - Ref.set(watchedProjectsRef, new Set(projectIds)); -``` -After: -```ts - // An empty list = this client is viewing no project ⇒ KEEP its entry with an empty set (watch - // nothing for it); only a disconnect removes the entry. So one client viewing nothing never - // suppresses another client's watched projects. - const setWatchedProjects: McpSupervisorShape["setWatchedProjects"] = (connKey, projectIds) => - Ref.update(watchedBySessionRef, (bySession) => new Map(bySession).set(connKey, new Set(projectIds))); - const clearWatchedProjects: McpSupervisorShape["clearWatchedProjects"] = (connKey) => - Ref.update(watchedBySessionRef, (bySession) => { - const next = new Map(bySession); - next.delete(connKey); - return next; - }); -``` -…and add `setWatchedProjects, clearWatchedProjects` to the returned shape object. -**Sweep read — `McpSupervisor.ts:478`.** Before: -```ts - const watched = yield* Ref.get(watchedProjectsRef); -``` -After (preserves the exact tri-state: no clients ⇒ null ⇒ probe-all startup default; clients all -viewing nothing ⇒ empty Set ⇒ probe nothing; else the union): -```ts - const bySession = yield* Ref.get(watchedBySessionRef); - const watched: ReadonlySet | null = - bySession.size === 0 - ? null - : new Set([...bySession.values()].flatMap((projectSet) => [...projectSet])); -``` -(The downstream `isSweepDue(instance, watched, …)` / `instanceInWatched` are unchanged — they already -take `ReadonlySet | null`.) - -### Exact code (web seam — recommended: per-connection id) -In `websocketRpcRouteLayer` (`ws.ts:1301-1338`) mint a connection id and thread it into the rpc layer -+ the disconnect release. The minimal, non-guessing shape: -```ts - const connectionId = yield* Effect.sync(() => crypto.randomUUID()); // ru-fork: per-WS-connection key - // …pass connectionId into makeWsRpcLayer(session.sessionId, connectionId) so the mcp handler can use it… - return yield* Effect.acquireUseRelease( - sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, - () => - sessions - .markDisconnected(session.sessionId) - .pipe(Effect.andThen(mcpSupervisor.clearWatchedProjects(connectionId))), // ru-fork: drop on disconnect - ); -``` -and the handler (`ws.ts:952-957`): -```ts - [WS_METHODS.mcpSetActiveProject]: ({ projectId }) => - observeRpcEffect( - WS_METHODS.mcpSetActiveProject, - mcpSupervisor.setWatchedProjects(connectionId, projectId !== null ? [projectId] : []), - { "rpc.aggregate": "mcp" }, - ), -``` -**The one signature to confirm during impl:** `makeWsRpcLayer` currently takes `(session.sessionId)` -(ws.ts:1313, param `currentSessionId` at :181) — add a second `connectionId` param and capture it in -the handler closure. That's the single threading change; everything else is exact. -**Minimal fallback (if you don't want to touch `makeWsRpcLayer`):** key by `currentSessionId` instead -— exact same supervisor code, `setWatchedProjects(currentSessionId, …)` / -`clearWatchedProjects(currentSessionId)` in the disconnect release. Fixes cross-**user** clobber; does -NOT separate same-user tabs. I recommend paying the one-param threading for the full fix. - -### Testability — YES (supervisor unit): `setWatchedProjects("c1", ["A"])`, `setWatchedProjects("c2", -["B"])` → sweep union = {A,B}; `setWatchedProjects("c2", [])` → union still {A}; `clearWatchedProjects("c1")` -→ map has only c2's empty set → union = {} (probe nothing); clear c2 → empty map → null (probe all). - ---- - -## Tests to write FIRST (red before any fix) - -All under `apps/server/tests/ru-fork/mcp/` unless noted. Harnesses confirmed in -`reconciliationLifecycle.test.ts` (`makeSystem`, `dispatch`, `catalog`, `bindings`, `reconcile`, -`backfill`, `probeUpsert`) and `reactorWorker.test.ts` (full reactor `run`). - -1. **Backfill ordering** (`reactorWorker.test.ts` or a new `metadataBackfillOrdering.test.ts`): - drive the real worker path — add a custom server (description null), inject a fake probe that - returns `serverDescription`, enqueue an eager reconcile, drain, assert the catalog description is - filled **in that cycle**. RED today (backfill precedes the probe). *Needs a fake-probe seam in the - reactor harness; reactorWorker.test.ts already builds the supervisor — confirm how it stubs - `probeOnce` (or inject a fake supervisor) when writing the test.* -2. **Config uniqueness — add** (`reconciliationLifecycle.test.ts` "decider guards" block): add A - (config X) ok; add B (same config X, different name/id) → `rejects.toThrow()`; catalog has 1 row - with X. RED today. -3. **Config uniqueness — edit** : add A(X) and B(Y); edit A→Y → throw; edit A's name only → ok. RED. -4. **Built-in skip-on-collision** : custom server config X; `reconcile([builtinDef with X])` → assert - no `builtinId` row added (built-in skipped). RED. -5. **Config placeholder / binding-key validation** (item 8): add server with `${UNDECLARED}` → throw; - bind with an undeclared key → throw; declared-only → ok. RED. -6. **Secret encryption round-trip** (item 4, `tests/auth/secretCrypto.test.ts`): `decrypt(encrypt(x)) - === x`; tamper → throws; on-disk blob ≠ plaintext. (GREEN once 4a lands — this is a fix-validation - test, not a red-first bug test.) -7. **64-bit hash** (item 10): distinct inputs → distinct 16-hex outputs; deterministic. (Fix-validation.) - -Items 7/9/11 (transactional secret, missing-secret-incomplete, watchedProjects) get tests when their -A-vs-C decisions are locked. - -## Resolved (recommendations locked — no open unknowns) -- **#3:** in-product "did it load" is impossible in qwen 0.13.1 (no MCP data in `session/new`) → runbook - (done) + a conformance unit test (3d) + additive probe cases (3b). No backcompat to worry about. -- **#4:** no migration (zero users) — ship encrypted `.bin` + ephemeral overlay (write→spawn→delete). -- **#6:** wire `trust` (folder already trusted — no folder-trust change), remove «enable all», no - autobind toggle (stays false → new project = 0 MCPs). -- **#7:** rely on existing orphan GC (the "undo on failure" variant is proven unsafe) + a doc line. -- **#9:** server-side exclusion now; the «требует настройки» label for the rare missing-*secret* case is - a small follow-on (needs a runtime-snapshot flag). -- **#11:** key by a **per-connection id** (one `makeWsRpcLayer` param) for the full multi-tab fix. - -## Sequencing (decided) -- **#3 probe extras:** just improve the probe (additive cases); independent, ungated — done as part of - the work, you run it whenever. -- **#11 (watchedProjects):** **DEFERRED to last.** Nothing is broken without it — single-window works - fully; only multi-window background auto-reprobe is scoped to the last-focused project (manual - recheck still works). Pick it up after everything else. - -## The only choice left for you -- **Round scope:** all of the rest in one test+fix pass, or **clean core first (1, 2, 4, 6, 8, 10 + - #5 UI)** then (3-probe, 7-doc, 9)? — #11 goes last regardless. - -Everything else is decided and has exact before/after code above. diff --git a/mcp-specs/current/improvments-banch-4.md b/mcp-specs/current/improvments-banch-4.md deleted file mode 100644 index d5d6e333ac7..00000000000 --- a/mcp-specs/current/improvments-banch-4.md +++ /dev/null @@ -1,670 +0,0 @@ -# improvements-branch-4 — Ephemeral overlay deletion + lifecycle sweep (#4) - -**Status:** ✅ IMPLEMENTED + GREEN (2026-06-14). Worktree `.claude/worktrees/mcp`, run server cmds from `apps/server`. - -> **Verified:** `tsc --noEmit` 0 errors · `oxlint` 0/0 on all changed files · `test:fast` = -> **896 passed / 57 skipped**, only the 4 pre-existing `bin.test.ts` env failures remain. All 6 changes -> landed as specified below (Change 3 first, then 1/2/4/5/6). Tests: G1/G2/G3/G8/G10/G12/G13 green guards; -> G4/G5/G6 (reactor `ensuring` delete), G7 (inner start timeout → error), G11 (shutdown sweep) went -> red→green on wiring. Ordering proof (file written+finalized BEFORE spawn) locked by **G13** + -> `overlayApply` (real atomic write read-back). Real-qwen overlay shape unchanged ⇒ `mcp-probe` still GO. -**Scope:** make the per-project qwen settings overlay (`//system.json`) — which -holds **resolved plaintext secrets** — *ephemeral*: it exists only across the spawn boot and is deleted -afterwards, on every code path, plus swept on app start and shutdown. Includes the **inner CLI-start -timeout** that turns a hung `cli --acp` boot into an *error* (the thing that makes deletion reliable). - -This doc gives **literal before→after** for every change and the **test matrix** that guarantees both -(a) the overlay APPLY behaviour stays correct (no spurious restarts, no wrong apply) and (b) deletion -happens on success, error, start-timeout, reuse, app-start, and app-shutdown. - ---- - -## 0. The finding that drives the design (verified from source) - -There is **no timeout on the ACP start handshake.** `runLoggedRequest` (`AcpSessionRuntime.ts:198-224`) -wraps `initialize` / `session/new` / `session/load` with **logging only** — no `Effect.timeout`. The two -watchdogs in `CliAdapter` are forked **after** `acp.start()` returns and only act while a turn is active -(`childExitFiber` waits on `acp.waitForExit`; `stallWatchdogFiber` early-returns unless -`ctx.activeTurnId !== undefined`). The `config.ts` timeout consts (`ACP_WIRE_STALL_WARN_MS = 1h`, -`ACP_WIRE_STALL_KILL_MS = 2h`) are **turn-stall** thresholds, not a start timeout. - -**Consequence:** if `cli --acp` spawns but hangs at boot and never answers `initialize`, -`providerService.startSession` → `startProviderSession` **hangs forever**. A plain `Effect.ensuring` -wrapped around it would therefore **never fire**. So we must add a start timeout that *fails*, converting -the hang into a typed error — and the deletion hangs off that settled outcome. - -### Why deletion can never cause a wrong restart (the apply-correctness guarantee) - -The restart decision is `overlayChanged = currentOverlayFingerprint !== spawnFingerprint` -(`ProviderCommandReactor.ts:617-621`). Both fingerprints are **computed in memory** by `overlayFingerprint` -over the **catalog + bindings** (`McpOverlay.ts:214`), and the spawn-time one is cached **per thread** in -`sessionOverlayFingerprints` (`:589-591`). **Nothing re-reads the overlay file to decide a restart.** -Therefore deleting the file between turns cannot change the decision: next turn `writeOverlay` recomputes -the same fingerprint from the same catalog/bindings and the reuse path is taken. This is proven by test -**G1** below. - -### The lifetime of the file - -The file is needed **only from the moment we write it until the freshly-spawned child has booted and read -it** (qwen reads settings once at startup via `loadSettings()`, never re-reads, never writes the System -scope, stores MCP OAuth in a separate encrypted file — source-proven in `HANDOFF.md`). A successful -`initialize` round-trip proves the read already happened. On a **reuse** turn no new child boots, so the -freshly-written file is dead weight the instant the decision returns. So the correct lifetime is exactly -**the spawn-decision region of one turn-start**, on every exit path. - ---- - -## 1. The design — three mechanisms (your 5 items, consolidated) - -| Your item | Mechanism | Where | -|---|---|---| -| 1 delete after session true + 2 delete on any spawn error | **A. `ensuring(delete)` around the spawn-decision region** — fires on success, failure, AND interruption (one hook, can't be skipped) | `ProviderCommandReactor.ensureSessionForThread` | -| 3 delete after timeout / "inner cli start timeout produces error → deletion" | **A0. inner start timeout** — `Effect.timeoutOrElse` around `acp.start()` fails with a typed error so A's `ensuring` reliably fires (not a separate file-timer) | `CliAdapter.startSession` | -| 4 delete all on shutdown | **C. sync sweep of `mcpOverlayDir`** | `fastShutdown.runFastShutdownCleanup` | -| 5 delete all on start | **B. async sweep of `mcpOverlayDir`** | `serverRuntimeStartup` startup phase | - -Net: `A0` guarantees the spawn always **settles**; `A` deletes the per-turn file on every settle + reuse + -interrupt; `B` cleans anything a hard crash (SIGKILL/power-loss, where `A`/`C` never ran) left behind; `C` -shrinks the on-disk window on graceful stop. **Item 3 is NOT a redundant file-timer** — it is the start -timeout, which is a real latent-bug fix (a hung CLI boot currently strands the turn forever). - -**Asymmetry to note:** `B` (startup) runs in the normal Effect program → uses the async Effect -`FileSystem`. `C` (shutdown) runs inside the **synchronous SIGINT/SIGTERM handler** via `Effect.runSync` -(`server.ts` fast-exit path) → must use **`nodeFs.rmSync`** (sync), exactly like the existing -`nodeFs.unlinkSync(serverRuntimeStatePath)` in `runFastShutdownCleanup`. An async `FileSystem.remove` -there would throw under `runSync`. - ---- - -## CHANGE 1 — `config.ts`: add the start-timeout constant - -**File:** `apps/server/src/config.ts` (after `POST_ANSWER_RESUME_TIMEOUT_MS`, ~line 149). - -### Before -```ts -export const POST_ANSWER_RESUME_TIMEOUT_MS = 3_600_000; - -/** - * CONTEXT_WINDOW_TOKENS — total context window size advertised to the -``` - -### After -```ts -export const POST_ANSWER_RESUME_TIMEOUT_MS = 3_600_000; - -/** - * ACP_SESSION_START_TIMEOUT_MS — hard ceiling on the ACP start handshake - * (`initialize` + `authenticate` + `session/new`|`session/load`). Unlike the - * wire-stall thresholds above (which only apply DURING an active turn), nothing - * else bounds the start: if a freshly-spawned `cli --acp` child hangs at boot - * and never answers `initialize`, `startSession` would otherwise hang forever. - * On timeout the adapter fails the start with a typed ProviderAdapterProcessError - * (the session scope then closes and SIGKILLs the child). 60s is generous for a - * cold node boot + qwen init (typically <10s) yet short enough to surface a wedge - * and — for ru-fork MCP — release the ephemeral overlay promptly. Tunable. - */ -export const ACP_SESSION_START_TIMEOUT_MS = 60_000; - -/** - * CONTEXT_WINDOW_TOKENS — total context window size advertised to the -``` - -**Why a module const (not a ServerConfig field):** matches every other timeout here -(`ACP_WIRE_STALL_*`, `POST_ANSWER_RESUME_TIMEOUT_MS`). Test-time override is injected through the adapter -options (Change 2), so we don't need it on `ServerConfig`. - ---- - -## CHANGE 2 — `CliAdapter.ts`: the inner start timeout (hang → typed error) - -**File:** `apps/server/src/provider/Layers/CliAdapter.ts`. - -### 2a. Import the const (line 50-59 import block) - -#### Before -```ts - ACP_WIRE_STALL_KILL_MS, -``` -#### After -```ts - ACP_SESSION_START_TIMEOUT_MS, - ACP_WIRE_STALL_KILL_MS, -``` -(Keep alphabetical with the existing sorted import; `ACP_SESSION_*` sorts before `ACP_WIRE_*`.) - -Also ensure `Duration` is imported (it is **not** currently in CliAdapter). Add near the other -`effect/*` imports at the top of the file: -```ts -import * as Duration from "effect/Duration"; -``` - -### 2b. Add the option for test injection (interface at line 104-108) - -#### Before -```ts -export interface CliAdapterLiveOptions { - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogger?: EventNdjsonLogger; - readonly instanceId?: typeof ProviderInstanceId.Type; -} -``` -#### After -```ts -export interface CliAdapterLiveOptions { - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogger?: EventNdjsonLogger; - readonly instanceId?: typeof ProviderInstanceId.Type; - /** - * ru-fork: override the ACP start-handshake timeout. Production omits it ⇒ - * ACP_SESSION_START_TIMEOUT_MS. Tests pass a tiny value so a scripted hang at - * `session/new` trips the timeout without a real-time wait. - */ - readonly sessionStartTimeoutMs?: number; -} -``` - -### 2c. Resolve the default once (near `boundInstanceId`, ~line 400) - -#### Before -```ts - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make(CLI_NAME); -``` -#### After -```ts - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make(CLI_NAME); - // ru-fork: bound the start handshake so a wedged `cli --acp` boot fails instead - // of hanging the turn forever (and, for MCP, promptly releases the ephemeral overlay). - const sessionStartTimeoutMs = options?.sessionStartTimeoutMs ?? ACP_SESSION_START_TIMEOUT_MS; -``` - -### 2d. Wrap `acp.start()`'s result in the timeout (the `started` binding, lines 1028-1046) - -`started` is the value of an `Effect.gen` that ends in `return yield* acp.start()`, then `.pipe(tapError, -mapError)`. We add `Effect.timeoutOrElse` as the **outermost** step so it bounds the whole handshake and -emits an already-mapped adapter error. (`timeoutOrElse` is the exact API used elsewhere in this repo — -`daemonLauncher.ts:197`.) - -#### Before -```ts - return yield* acp.start(); - }).pipe( - // ru-fork: capture the CLI exit code at the boundary — - // `mapAcpToAdapterError` below wraps `AcpProcessExitedError` - // in `ProviderAdapterSessionClosedError`, dropping `.code` from - // structured log fields. - Effect.tapError((error) => - isAcpProcessExitedError(error) - ? Effect.logError("[cli-acp.process-exited]", { - threadId: input.threadId, - method: "session/start", - exitCode: error.code, - }) - : Effect.void, - ), - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); -``` -#### After -```ts - return yield* acp.start(); - }).pipe( - // ru-fork: capture the CLI exit code at the boundary — - // `mapAcpToAdapterError` below wraps `AcpProcessExitedError` - // in `ProviderAdapterSessionClosedError`, dropping `.code` from - // structured log fields. - Effect.tapError((error) => - isAcpProcessExitedError(error) - ? Effect.logError("[cli-acp.process-exited]", { - threadId: input.threadId, - method: "session/start", - exitCode: error.code, - }) - : Effect.void, - ), - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - // ru-fork: hard ceiling on the start handshake. Nothing else bounds it - // (the stall/child-exit watchdogs are forked only AFTER start and act - // only during an active turn). On timeout the interrupt unwinds the - // pending `initialize`/`session.new` RPC and the session scope closes, - // SIGKILLing the child; we surface a typed adapter error so callers - // (and the reactor's overlay finalizer) see a settled failure. - Effect.timeoutOrElse({ - duration: Duration.millis(sessionStartTimeoutMs), - orElse: () => - Effect.logError("[cli-acp.start-timeout]", { - threadId: input.threadId, - method: "session/start", - timeoutMs: sessionStartTimeoutMs, - }).pipe( - Effect.andThen( - Effect.fail( - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: `ACP session did not complete its start handshake within ${sessionStartTimeoutMs}ms — the cli --acp child is unresponsive.`, - cause: new Error("acp-session-start-timeout"), - }), - ), - ), - ), - }), - ); -``` - -**Notes / correctness:** -- `ProviderAdapterProcessError` is already imported and constructed with exactly `{ provider, threadId, - detail, cause }` at lines 735-741, so this matches the existing shape. -- The log is `logError` because a start timeout is a genuine blocking failure (per the logging rule: - error = blocking, debug = expected). It is failure-only — healthy starts never hit it. -- Interrupting `acp.start()` resets its memo to `NotStarted` (`AcpSessionRuntime.ts:514-518`), and the - `sessionScope` finalizer (`CliAdapter.ts:650-651`) closes on this failure → the child (spawned into - `sessionScope`) is killed. No zombie. - ---- - -## CHANGE 3 — `McpOverlay.ts`: `deleteOverlayFile` + `removeAllOverlays` - -**File:** `apps/server/src/ru-fork/mcp/McpOverlay.ts`. - -### 3a. Extend the service interface (lines 55-67) - -#### Before -```ts - readonly writeOverlay: (projectId: ProjectId) => Effect.Effect; - /** - * B4 ③: remove a deleted project's overlay directory (best-effort — a missing - * dir is a no-op). Never fails (errors are swallowed), so callers need no recovery. - */ - readonly removeOverlay: (projectId: ProjectId) => Effect.Effect; -} -``` -#### After -```ts - readonly writeOverlay: (projectId: ProjectId) => Effect.Effect; - /** - * B4 ③: remove a deleted project's overlay directory (best-effort — a missing - * dir is a no-op). Never fails (errors are swallowed), so callers need no recovery. - */ - readonly removeOverlay: (projectId: ProjectId) => Effect.Effect; - /** - * #4 ephemeral: delete a single resolved overlay FILE the moment the spawn it - * fed has settled. The file holds plaintext secrets and is only needed until the - * freshly-spawned child boots and reads it (qwen reads settings once at startup, - * never re-reads). Best-effort — a missing file is a no-op; never fails, so it is - * safe inside `Effect.ensuring`. `force` swallows ENOENT. - */ - readonly deleteOverlayFile: (overlayPath: string) => Effect.Effect; - /** - * #4 ephemeral: sweep EVERY project's overlay under `mcpOverlayDir`. Used on app - * start to clear plaintext-secret files a hard crash (SIGKILL/power-loss) left - * behind. Best-effort — a missing dir is a no-op; never fails. The dir is - * recreated lazily by the next `writeOverlay` (atomic write makes its parents). - */ - readonly removeAllOverlays: Effect.Effect; -} -``` - -### 3b. Implement them (after `removeOverlay`, before the `return`, lines 243-245) - -#### Before -```ts - ).pipe( - // ru-fork: best-effort cleanup — `force` makes a missing dir a no-op; a real failure is logged (the - // dir holds resolved secrets) but must not fail the project.deleted chain. - Effect.catchCause((cause) => - Effect.logDebug("mcp overlay: failed to remove project overlay", { projectId, cause }), - ), - ); - - return { writeOverlay, removeOverlay } satisfies McpOverlayShape; -}); -``` -#### After -```ts - ).pipe( - // ru-fork: best-effort cleanup — `force` makes a missing dir a no-op; a real failure is logged (the - // dir holds resolved secrets) but must not fail the project.deleted chain. - Effect.catchCause((cause) => - Effect.logDebug("mcp overlay: failed to remove project overlay", { projectId, cause }), - ), - ); - - const deleteOverlayFile: McpOverlayShape["deleteOverlayFile"] = (overlayPath) => - provideIo(fileSystem.remove(overlayPath, { force: true })).pipe( - // ru-fork: best-effort — never fails (safe under Effect.ensuring). `force` makes a - // missing file a no-op; a real failure is debug (non-blocking — sweep-on-start nets it). - Effect.catchCause((cause) => - Effect.logDebug("mcp overlay: failed to delete ephemeral overlay file", { - overlayPath, - cause, - }), - ), - ); - - const removeAllOverlays: McpOverlayShape["removeAllOverlays"] = provideIo( - fileSystem.remove(serverConfig.mcpOverlayDir, { recursive: true, force: true }), - ).pipe( - Effect.catchCause((cause) => - Effect.logDebug("mcp overlay: failed to sweep overlay dir", { - dir: serverConfig.mcpOverlayDir, - cause, - }), - ), - ); - - return { - writeOverlay, - removeOverlay, - deleteOverlayFile, - removeAllOverlays, - } satisfies McpOverlayShape; -}); -``` - -**Why a file-delete, not `removeOverlay(projectId)`:** the reactor holds `mcpOverlayResult.overlayPath` -directly; deleting the single `system.json` is the precise intent and leaves the (harmless, empty) project -dir, which the next atomic write reuses. Reusing `removeOverlay` would over-broadly nuke the dir and is -semantically "project deleted", not "turn settled". - ---- - -## CHANGE 4 — `ProviderCommandReactor.ts`: `ensuring(delete)` around the spawn region - -**File:** `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`, in `ensureSessionForThread`. -`mcpOverlayResult` is computed at `:531`. The spawn-or-reuse decision is the block from -`const existingSessionThreadId = …` (`:594`) through the final `return startedSession.threadId` (`:669`). -We wrap that block in one `Effect.gen` and pipe `Effect.ensuring(deleteOverlayFile)` when an overlay was -written. `Effect.ensuring` runs on success, typed failure (incl. the new start-timeout), and interruption. - -### Before (lines 594-670) -```ts - const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; - if (existingSessionThreadId) { - // ru-fork: runtimeModeChanged removed from restart triggers — the - // adapter receives live runtimeMode on every sendTurn / respondToRequest - // input (see ProviderSendTurnInput / ProviderRespondToRequestInput), - // so dropdown changes no longer require a session restart. Restart still - // fires for cwd / provider-instance / model changes. - const cwdChanged = effectiveCwd !== activeSession?.cwd; - // … (unchanged respawn-decision body) … - yield* bindSessionToThread(restartedSession); - return restartedSession.threadId; - } - - const startedSession = yield* startProviderSession(undefined); - yield* bindSessionToThread(startedSession); - return startedSession.threadId; - }); -``` - -### After -```ts - // ru-fork #4: the spawn-decision region — respawn, reuse, or fresh spawn. The - // overlay file (plaintext secrets) is only needed until a freshly-spawned child - // boots and reads it; on a reuse turn nothing reads it at all. So the moment this - // region settles — success, a (now timeout-bounded) start failure, or interrupt — - // delete the file. The restart DECISION uses the in-memory fingerprint, never the - // file, so deleting it can't trigger a spurious respawn next turn (test G1). - const decideAndSpawn = Effect.gen(function* () { - const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; - if (existingSessionThreadId) { - // ru-fork: runtimeModeChanged removed from restart triggers — the - // adapter receives live runtimeMode on every sendTurn / respondToRequest - // input (see ProviderSendTurnInput / ProviderRespondToRequestInput), - // so dropdown changes no longer require a session restart. Restart still - // fires for cwd / provider-instance / model changes. - const cwdChanged = effectiveCwd !== activeSession?.cwd; - // … (unchanged respawn-decision body, verbatim) … - yield* bindSessionToThread(restartedSession); - return restartedSession.threadId; - } - - const startedSession = yield* startProviderSession(undefined); - yield* bindSessionToThread(startedSession); - return startedSession.threadId; - }); - - return yield* ( - mcpOverlayResult === null - ? decideAndSpawn - : decideAndSpawn.pipe( - Effect.ensuring(mcpOverlay.deleteOverlayFile(mcpOverlayResult.overlayPath)), - ) - ); - }); -``` - -**Mechanical note:** the only change is wrapping the existing `:594-669` body verbatim into the -`decideAndSpawn` generator (indent +2) and adding the trailing `return yield* (… ensuring …)`. No logic -inside the block changes. `mcpOverlay` is already the bound service (it calls `mcpOverlay.writeOverlay` at -`:532`). `deleteOverlayFile` returns `Effect` (never fails) so `ensuring` adds no error channel. - -**Interaction with the existing best-effort writeOverlay catch (`:533-538`):** when `writeOverlay` fails, -`mcpOverlayResult` is `null` → we take the un-wrapped `decideAndSpawn` (nothing to delete). Unchanged. - ---- - -## CHANGE 5 — sweep on app start - -Two clean options; per the "code both where two exist" rule, both are specified. **Recommend Option A** -(explicit, follows the `runStartupPhase` pattern, ordered before anything writes an overlay). - -### Option A (recommended) — a startup phase in `serverRuntimeStartup.ts` - -#### 5A-i. Inject the service (add to imports + the `makeServerRuntimeStartup` header, lines 236-245) - -Before (import block already has `McpReactor`, `McpSupervisor` from `./ru-fork/mcp/…`): -```ts -import { McpReactor } from "./ru-fork/mcp/McpReactor.ts"; -import { McpSupervisor } from "./ru-fork/mcp/McpSupervisor.ts"; -``` -After: -```ts -import { McpOverlay } from "./ru-fork/mcp/McpOverlay.ts"; -import { McpReactor } from "./ru-fork/mcp/McpReactor.ts"; -import { McpSupervisor } from "./ru-fork/mcp/McpSupervisor.ts"; -``` - -Before (`:241-242`): -```ts - const mcpSupervisor = yield* McpSupervisor; - const mcpReactor = yield* McpReactor; -``` -After: -```ts - const mcpSupervisor = yield* McpSupervisor; - const mcpReactor = yield* McpReactor; - const mcpOverlay = yield* McpOverlay; -``` - -#### 5A-ii. Add the phase BEFORE `reactors.start` (insert before `:284`) - -Before: -```ts - yield* Effect.logDebug("startup phase: starting orchestration reactors"); - yield* runStartupPhase( - "reactors.start", -``` -After: -```ts - // ru-fork #4: wipe stale per-project overlay files (plaintext secrets) left by a - // prior run that exited hard (SIGKILL / power-loss) before its ensuring/shutdown - // sweep could run. Done before reactors so nothing reads a stale file. Best-effort. - yield* Effect.logDebug("startup phase: sweeping stale MCP overlays"); - yield* runStartupPhase("mcp.overlay.sweep", mcpOverlay.removeAllOverlays); - - yield* Effect.logDebug("startup phase: starting orchestration reactors"); - yield* runStartupPhase( - "reactors.start", -``` - -`removeAllOverlays` never fails, so no `Effect.catch` wrapper is needed (unlike `keybindings.start`). - -### Option B (alternative) — sweep at the head of `McpReactor.start()` - -If you prefer to keep the sweep inside the MCP module and avoid a new injection into -`serverRuntimeStartup`: call `removeAllOverlays` as the first effect of `McpReactor.start()` (it already -runs at boot via `reactors.start`, `:294`). McpReactor would need `McpOverlay` as a dependency. Downside: -the sweep is then *inside* `reactors.start`, slightly less explicit and ordered with reactor seeding -rather than strictly before it. Functionally equivalent. **Not recommended** unless avoiding the startup -injection matters more than ordering clarity. - ---- - -## CHANGE 6 — sweep on graceful shutdown (`fastShutdown.ts`) - -Runs inside the **synchronous** SIGINT/SIGTERM handler (`server.ts` fast-exit, via `Effect.runSync`), so it -**must be sync** — use `nodeFs.rmSync`, mirroring the existing `nodeFs.unlinkSync` for the runtime-state -file. `config.mcpOverlayDir` is already on `ServerConfig` (used by `McpOverlay`). - -### Before (whole `runFastShutdownCleanup`, lines 31-45) -```ts -export const runFastShutdownCleanup = Effect.gen(function* () { - const config = yield* ServerConfig; - const providerService = yield* ProviderService; - const terminalManager = yield* TerminalManager; - - yield* providerService.stopAll().pipe(Effect.ignoreCause({ log: true })); - yield* terminalManager.killAll; - yield* Effect.sync(() => { - try { - nodeFs.unlinkSync(config.serverRuntimeStatePath); - } catch { - // Already absent or another writer touched it — fine. - } - }); -}); -``` -### After -```ts -export const runFastShutdownCleanup = Effect.gen(function* () { - const config = yield* ServerConfig; - const providerService = yield* ProviderService; - const terminalManager = yield* TerminalManager; - - yield* providerService.stopAll().pipe(Effect.ignoreCause({ log: true })); - yield* terminalManager.killAll; - yield* Effect.sync(() => { - try { - nodeFs.unlinkSync(config.serverRuntimeStatePath); - } catch { - // Already absent or another writer touched it — fine. - } - }); - // ru-fork #4: drop every per-project overlay (plaintext secrets) on graceful stop, - // shrinking the on-disk window. SYNC (rmSync) because this runs in the SIGINT/SIGTERM - // handler under Effect.runSync — an async FileSystem.remove would throw here. - // Best-effort; the next start's sweep is the backstop for anything left. - yield* Effect.sync(() => { - try { - nodeFs.rmSync(config.mcpOverlayDir, { recursive: true, force: true }); - } catch { - // Missing or racing writer — fine; start-sweep nets it. - } - }); -}); -``` - -The doc comment at the top of `fastShutdown.ts` lists steps 1-3; update it to mention step 4 (overlay -sweep) for accuracy. (Cosmetic; no behaviour.) - ---- - -## 2. Test matrix — what guarantees what - -Legend for harnesses (all verified to exist): -- **RH** = reactor harness `apps/server/tests/orchestration/Layers/RuForkProviderCommandReactor.test.ts` — - stubs `McpOverlay.writeOverlay` with per-project entries, **mock** `startSession`; asserts respawn - decision via `startSession.mock.calls`. Best for APPLY-correctness + reactor `ensuring` wiring. -- **EH** = engine harness `apps/server/tests/provider/cliAdapterErrorEngine.test.ts` — **real** `CliAdapter` - + `AcpSessionRuntime` over `fakeAcpSpawnerLayer(script)` + real OrchestrationEngine. Best for the inner - start timeout + full chain. -- **Unit** = direct service test (real `McpOverlay` / `runFastShutdownCleanup` over a temp dir). - -| ID | Guarantee | Harness | Assertion | -|---|---|---|---| -| **G1** | **Deletion ⊥ apply.** File deleted between turns ⇒ same config still takes the reuse path (NO spurious respawn). | RH | Stub `writeOverlay` creates a REAL temp file, fingerprint `v1`. Turn 1 spawns; `ensuring` deletes it. Turn 2 same config → `writeOverlay` recomputes `v1` → `startSession` called **once** total. Assert file absent after each turn. | -| **G2** | We restart **iff** the overlay meaningfully changed. | RH (exists, keep) | unchanged fp → no respawn; changed fp → respawn **with prior resumeCursor**; `writeOverlay` fail → spawn without overlay. | -| **G3** | Fingerprint is RIGHT — changes on config/varValue/toolPolicy/trust, stable on description/discovered-tools. | Unit (exists: `branch3OverlayGuards.test.ts`, `overlayFingerprint.test.ts`, keep) | sensitivity table. Guarantees G2's decision can't false-fire or miss. | -| **G4** | Delete on **success**. | RH | Stub `writeOverlay` → real temp file; mock `startSession` resolves. After turn: file **gone**. | -| **G5** | Delete on **spawn error**. | RH | Same; mock `startSession` **fails**. After the failed turn: file **gone** (`ensuring` fired on failure). | -| **G6** | Delete on **reuse** (no spawn). | RH | Turn 2 reuse path (unchanged fp): a real file was written this turn; assert **gone** even though `startSession` was not called again. | -| **G7** | **Inner CLI start timeout produces an error.** | EH | Extend fake to **hang** at `session/new`; build adapter with `sessionStartTimeoutMs: 50`. `adapter.startSession(...)` **fails** with `ProviderAdapterProcessError` (detail mentions "start handshake"). Proves a wedged boot settles instead of hanging. | -| **G8** | Start **error** (not hang) also settles. | EH | Fake `respondError` at `session/new` → `startSession` fails (mapped adapter error). | -| **G9** | **Full chain: start timeout → overlay deleted.** (capstone) | EH + real `McpOverlay` | Seed a catalog server + project binding (overlay non-empty), `MCP_ENGINE_USE_OVERLAY` on, `sessionStartTimeoutMs: 50`, fake hangs at `session/new`. Drive a turn through the REAL reactor. Assert: overlay file **created** (observe pre-timeout) then **absent** after the failed turn. Proves your exact ask. | -| **G10** | Sweep on **app start**. | Unit | Create `/p1/system.json`, `/p2/system.json`; run `mcpOverlay.removeAllOverlays`; assert dir empty/absent. | -| **G11** | Sweep on **shutdown**. | Unit | Create overlay files under `config.mcpOverlayDir`; run `runFastShutdownCleanup` (stub `ProviderService.stopAll`/`TerminalManager.killAll`); assert overlay dir gone. | -| **G12** | `deleteOverlayFile` is **safe on a missing file** (ensuring never throws). | Unit | Call on a non-existent path → succeeds (no error), via `{ force: true }`. | - -### Harness work required (honest) - -- **Fake-agent start-phase scripting (G7-G9).** Today `fakeAcpCore.ts` only scripts `onPrompt`; it always - succeeds `initialize`/`session/new`/`session/load`. Add an optional `startBehavior?: "ok" | "hang" | - "error"` to `FakeAcpScript` and branch in the `handleCreateSession`/`handleLoadSession` stubs: - `"hang"` → never resolve (await a never-completing Deferred, like the prompt-hang at - `fakeAcpCore.ts:166-169`); `"error"` → reply JSON-RPC error; default `"ok"` → current behaviour. This is - **additive** — existing tests omit the field and are unchanged (no test edits, no blind spots). -- **G9** needs the EH layer to include `McpOverlayLive` + catalog/binding seeding + `MCP_ENGINE_USE_OVERLAY` - on. EH already wires the real OrchestrationEngine + reactor; add the MCP layers and a 2-command seed - (catalog add + binding set) reusing `branch3Helpers.ts` builders. - -### What "100% guaranteed" means here — and the one honest caveat - -- **APPLY correctness:** fully guaranteed by G1-G3. The restart decision is a pure in-memory fingerprint - comparison over catalog+bindings; G3 proves the fingerprint is sensitive to exactly the right inputs, G2 - proves the decision wiring, and **G1 proves deletion cannot perturb it** (the file is never read to - decide). So "we don't restart when not needed / apply wrong" is locked. -- **DELETION:** guaranteed on success (G4), error (G5), reuse (G6), start-timeout end-to-end (G7+G9), - app-start (G10), app-shutdown (G11), and the no-throw property (G12). -- **Caveat (unchanged from the whole suite):** the **real qwen binary is faked**. EH exercises the REAL - `CliAdapter` + `AcpSessionRuntime` + reactor + `McpOverlay` (so every line we add — timeout, error - mapping, `ensuring`, sweep — is exercised for real); only the qwen process is simulated. The - real-qwen overlay *shape/precedence* remains operator-verified via `mcp-probe` - (`node ./mcp-probe/test.js → RESULT: GO`), exactly as today. No test here can or should boot real qwen. - ---- - -## 3. Risk · cons · architecture-fit · testability - -- **Risk — early delete starves a later spawn?** No. Each turn-start writes a fresh overlay *before* any - possible respawn (`writeOverlay` at `:531` is unconditional; respawn is downstream in the same effect). - A future restart always gets a freshly-written file. Deleting this turn's file after its spawn settles - can't affect a future turn. -- **Risk — delete races the child's read?** No. We delete only after `startProviderSession` **settles**; a - successful `initialize` proves the read already happened (qwen reads once at boot). On failure the child - is being killed anyway. Defense-in-depth: deleting *before* the turn streams also shrinks the - plaintext-on-disk window. -- **Risk — the new start timeout false-fires on a slow cold boot?** 60s is ~6× the typical <10s qwen - init; tunable via the const. It only fires on a genuine wedge, and the failure is the *correct* outcome - (today such a wedge hangs the turn forever — this is a net robustness fix beyond MCP). -- **Cons:** Change 4 indents the spawn-decision block into a named generator (mechanical, no logic change). - Change 5 adds one service injection. Change 6 adds a sync `rmSync` (consistent with the existing one). -- **Architecture fit:** deletion lives in the layer that **wrote** the file (the reactor owns - `overlayPath`); `CliAdapter` stays MCP-agnostic (the timeout is generic provider robustness, not MCP). - `McpOverlay` remains the single owner of the overlay-file lifecycle (write/remove/delete/sweep). The - start timeout uses the repo's established `Effect.timeoutOrElse` idiom and the central `config.ts` - timeout block. -- **Testability:** the start timeout is injectable (`sessionStartTimeoutMs`) so tests trip it in ms; - deletion is observable as real files in the reactor + engine harnesses; sweeps are plain unit tests. - ---- - -## 4. Implementation order (when go is given) - -1. Change 1 (const) → Change 2 (timeout) → typecheck. -2. Change 3 (overlay methods) → Change 4 (reactor ensuring) → typecheck. -3. Change 5 (start sweep) → Change 6 (shutdown sweep) → typecheck. -4. Fake-agent `startBehavior` extension (additive). -5. Tests G1-G12. Run `test:fast`, server/web/contracts `typecheck`, `oxlint`. -6. Operator runs `mcp-probe` (real-qwen shape unchanged → still `RESULT: GO`). - -**Constraints (standing):** no `as`/`any`/`unknown`; `Effect.catch`/`catchCause`; `logError` (blocking) / -`logDebug` (expected) only; mark `ru-fork:` deltas; no test deletions (fake extension is additive); MCP -never shipped → no backcompat; web = typecheck+lint only; never run qwen/the project; report any -UNEXPECTED degradation and STOP rather than cut corners. diff --git a/mcp-specs/legacy/mcp-final-plan.md b/mcp-specs/legacy/mcp-final-plan.md deleted file mode 100644 index 82b542bff6a..00000000000 --- a/mcp-specs/legacy/mcp-final-plan.md +++ /dev/null @@ -1,4078 +0,0 @@ -# MCP final plan — monitoring redesign, startup race, secret/lifecycle GC, secret-edit UX - -> **Contract of this document.** Every change below is specified as an exact edit (BEFORE → AFTER) -> or a full new-file body, with the precise imports each file gains. During implementation **only -> code that appears here may enter the codebase.** If implementation reveals a needed import or line -> that is not in this plan, that is a **STOP** — the plan gets amended and re-approved first, never -> silently patched. No item is deferred; all of items 1–14 are fully specified here. -> -> Worktree: `/mnt/mac/Users/user/WORKSPACE/Projects/experements/ru-code/.claude/worktrees/mcp` -> (branch `ru-code`). Gates after implementation: `pnpm typecheck` (10/10), `pnpm lint` (0/0), -> `pnpm test:fast` (only the 4 preexisting `bin.test.ts`). Web has no test target (typecheck+lint). - ---- - -## REVISION 2 — locked decisions (authoritative; supersedes conflicting earlier parts) - -> The design grew during review. This block is the source of truth for every decision; the exact -> BEFORE→AFTER edits for the NEW pieces (PART K + amendments) are expanded below the original -> PART A–J. Where a decision here conflicts with an original part, **this block wins** and that part -> is marked SUPERSEDED in its place. - -### Decisions locked -- **D-mon (monitoring).** Loop is opt-in: both recheck intervals = 0 ⇒ sweep never runs. **No probing - on load** (drop mandatory-first-probe). Never-probed-and-uncached ⇒ status `"unchecked"` («не - проверено», neutral). In-flight probe ⇒ `checking:true` («проверка…»). **Edit locked while a check - runs.** Incomplete servers are never probed. (Items 1,2,4,5,6,7 — PART A/C/G as written, kept.) -- **D-change.** A config/vars edit force-probes **only the hashes that actually changed** (cosmetic - edits probe nothing). (Item 3 — PART C/D, kept.) -- **D-restart (REPLACES the active-restart model).** No active teardown. At the **turn-start gate** - (`ProviderCommandReactor.ts:573-594`) add `overlayChanged` next to `cwdChanged`/`instanceChanged`: - `overlayChanged = currentOverlayFingerprint !== fingerprintThisSessionSpawnedWith` → re-spawn with - `resumeCursor` on the next turn (history preserved). The fingerprint already subsumes - `allowedMcpServers` (removing a project MCP changes it). **SUPERSEDES PART E entirely** and **drops - the `McpOverlayState` service (E1/E2/E4) and the reactor's `syncOverlaysAndRestart`/`restartThread`/ - `liveThreadsByProject` (PART D's restart pieces).** Per-thread spawn fingerprint kept in an - in-memory `Map` in `ProviderCommandReactor` (a live qwen process doesn't survive a - server restart, so the map's lifetime is correct; no contract/schema change). Items **8 & 9 dissolve** - (a first turn that ever spawns on a not-yet-hydrated/empty overlay self-heals on the next turn). -- **D-warn (REPLACES the two-click banner).** Warn-on-impact = a **centered `AlertDialog`** (existing - `~/components/ui/alert-dialog`) listing **affected projects by name**, Отмена / Применить. base-ui - nests dialogs first-class (`--nested-dialogs`), so modal-over-modal is fine. **SUPERSEDES G11's - banner**; `describeEditImpact` returns structured data (per-removed-var → project names; new-required - → project names) instead of a string; needs `useMcpProjects()` for names. -- **D-noprobe-seed.** No auto-probe ever, including on seed: seed/migrator reconciles enqueue - `eager:false`. (Item 4 confirmed; **SUPERSEDES D5's note** that seeded complete builtins may probe.) -- **D-no-global-recheck.** No global «Проверить всё» button (J6 dropped). Per-server + per-row recheck - only; the panel-header button stays project-scoped as today. -- **D-builtins (managed templates — NEW, PART K).** Built-ins ship from a prebuild TS file as managed - **templates** with a **locked command** + declared vars + per-platform variants. A startup - **migrator** reconciles shipped defs vs installed by `builtinId` + `builtinHash` (auto content-hash - of shipped parts only): add / update (3-way merge) / remove (cascade bindings + secret GC). - **Templates never detach** — removing a def removes it from the catalog and all projects. - Unsupported platform (no variant for `process.platform`) ⇒ **skip** (don't seed). **REPLACES - `McpDefaults.ts` + `seedBuiltinsIfEmpty` and the fork-to-custom concept.** -- **D-config-model (NEW deltas to PART A).** - - `required` is widened to "must resolve to a non-empty value" at **any** level (not only - `perProject`). A catalog-level required var with no value ⇒ the **catalog tile** shows «требует - настройки»; the user sets it on the catalog server (no fork). `missingRequiredVars` + - `catalogMissingVars` learn catalog-level required vars. - - `McpServerVar` / `McpServerVarDraft` gain `origin: "shipped" | "user"`. Migrator replaces - `shipped` declarations, preserves `user` vars + all var **values** by name. Manual servers: all - vars `origin:"user"`. - - `McpCatalogServer` gains `builtinId: string|null`, `builtinHash: string|null`, `locked: boolean`, - `extraArgs: ReadonlyArray` (user-appendable args with `${VAR}` holes — **catalog-level/ - shared scope for now**; per-project extraArgs is a future add). - - Resolver: `resolvedArgs = config.args (locked) + extraArgs`. **`extraArgs` flows into - `configCacheKey` + `overlayFingerprint`** so changing it resets status to «не проверено» and - re-spawns the session, exactly like a template update. (Updating a template changes the configKey - ⇒ status auto-resets — free.) - - Manual (custom) servers: fully-editable command, `locked:false`, `extraArgs` empty, `builtinId` - null. Project level is already vars-only for both (binding = varValues; the original identity-lock). -- **D-fork-removed.** The decider no longer forks `builtin → custom` on edit. Templates are permanently - managed; configuring (var values / extraArgs / user vars) never bumps `builtinId`/`builtinHash`; - changing a locked command or a `shipped` var declaration is rejected. **SUPERSEDES the - `source:"builtin"→"custom"` line in `applyServerUpdate` (PART F context).** - -### What stays exactly as written in PART A–J (not superseded) -Items 1,2,4,5,6,7 monitoring (PART A status/checking + C supervisor + C7 McpRuntime + G statuses/lock), -item 3 change-probe (C3/C4/D1/D2/D5 — except D5's seed note → `eager:false`), item 10 secret GC -(PART B + D3), item 11 varValues prune (A5 + D4 + F4/F5), items 13/14 keep-secret + SecretField -(A4/A5 + F2/F3 + G), the tests (PART H), gates (PART I). - -### Migration `031_Mcp.ts` — new columns (single migration, edited in place) -`mcp_catalog_server` adds: `builtin_id TEXT`, `builtin_hash TEXT`, `locked INTEGER NOT NULL DEFAULT 0`, -`extra_args_json TEXT NOT NULL DEFAULT '[]'`. `vars_json` rows now include `origin`. Projection -`ProjectionMcpCatalog.ts` upsert/select + `McpCatalogServerDbRow` extend accordingly. (Exact -before/after in PART K.) - -> **Next:** expand PART K (prebuild file format, `McpBuiltinDefinition`, the migrator algorithm with -> 3-way merge, per-platform resolution, decider builtin-sync path, contract/migration/projection/ -> resolver/fingerprint/web-template-editor edits) as exact BEFORE→AFTER, and apply the SUPERSEDES -> markers in PART A/D/E/F/G. This is the remaining authoring step before implementation. - ---- - -## 0. Scope → the 14 requirements - -| # | Requirement | Implemented in PART(s) | -|---|---|---| -| 1 | Both recheck intervals = 0 ⇒ sweep loop fully off | C | -| 2 | No probing on load (remove mandatory-first-probe; show cached-or-"не проверено") | C, A | -| 3 | Change-driven force-recheck when config/vars actually change (not cosmetic) | C, D | -| 4 | "Не проверено" neutral status for never-probed servers | A, C, G | -| 5 | UI live "проверка…" whenever a probe is in flight (edit-save / manual / loop) | A, C, G | -| 6 | Edit locked while a check runs | G | -| 7 | Never probe incomplete servers (required vars unfilled → «требует настройки») | C(existing) + G | -| 8 | Session spawns with empty/stale overlay before MCP state hydrates | E | -| 9 | "First sight ⇒ record only" suppresses restart of a stale live session | E | -| 10 | Secret-store GC of orphaned var secrets | B, D | -| 11 | Orphaned `varValues` auto-prune | A, D, F | -| 12 | Catalog-edit warn-on-impact | G | -| 13 | "Keep existing secret" signal so edits don't wipe untouched secrets | A, F, G | -| 14 | `SecretField` UI states (saved / needs-value / editing; rename → red) | A, G | - -> **Status-model note (foundation for 2/4/5/7).** Today a never-probed instance is registered as -> `status:"connecting"` and the sweep force-probes it once ("mandatory first"). After this plan a -> never-probed-and-uncached instance is `status:"unchecked"` and is **never** auto-probed; a probe is -> only ever started by (a) manual recheck, (b) a config-affecting change, or (c) the interval sweep on -> an already-probed instance in the watched project. "In flight" is a separate `checking` boolean so a -> server can be "online **and** re-checking" without losing its last status. - ---- - -## PART A — Contracts (`packages/contracts/src/ru-fork/mcp.ts`) - -### A1. `McpRuntimeStatus` gains `"unchecked"` - -BEFORE (lines 161-168): -```ts -export const McpRuntimeStatus = Schema.Literals([ - "connecting", - "online", - "degraded", - "offline", - "error", -]); -export type McpRuntimeStatus = typeof McpRuntimeStatus.Type; -``` -AFTER: -```ts -export const McpRuntimeStatus = Schema.Literals([ - "unchecked", // never probed and no cache row — idle, awaiting a manual/change-driven probe - "connecting", - "online", - "degraded", - "offline", - "error", -]); -export type McpRuntimeStatus = typeof McpRuntimeStatus.Type; -``` - -### A2. `McpRuntimeSnapshot` gains `checking` - -BEFORE (lines 170-180): -```ts -export const McpRuntimeSnapshot = Schema.Struct({ - projectId: ProjectId, - serverId: McpServerId, - status: McpRuntimeStatus, - message: Schema.optional(Schema.String), - latencyMs: Schema.optional(Schema.Int), - discoveredTools: Schema.Array(McpTool), - effectiveAllowedTools: Schema.Array(TrimmedNonEmptyString), - checkedAt: Schema.optional(IsoDateTime), -}); -export type McpRuntimeSnapshot = typeof McpRuntimeSnapshot.Type; -``` -AFTER (add one field after `status`): -```ts -export const McpRuntimeSnapshot = Schema.Struct({ - projectId: ProjectId, - serverId: McpServerId, - status: McpRuntimeStatus, - // true while a probe of this instance is in flight (sweep / manual / change-driven), - // independent of `status` so the UI can show «проверка…» without losing the last result. - checking: Schema.Boolean, - message: Schema.optional(Schema.String), - latencyMs: Schema.optional(Schema.Int), - discoveredTools: Schema.Array(McpTool), - effectiveAllowedTools: Schema.Array(TrimmedNonEmptyString), - checkedAt: Schema.optional(IsoDateTime), -}); -export type McpRuntimeSnapshot = typeof McpRuntimeSnapshot.Type; -``` - -### A3. `McpCatalogRuntimeSnapshot` gains `checking` - -BEFORE (lines 185-193): -```ts -export const McpCatalogRuntimeSnapshot = Schema.Struct({ - serverId: McpServerId, - status: McpRuntimeStatus, - message: Schema.optional(Schema.String), - latencyMs: Schema.optional(Schema.Int), - discoveredTools: Schema.Array(McpTool), - checkedAt: Schema.optional(IsoDateTime), -}); -export type McpCatalogRuntimeSnapshot = typeof McpCatalogRuntimeSnapshot.Type; -``` -AFTER: -```ts -export const McpCatalogRuntimeSnapshot = Schema.Struct({ - serverId: McpServerId, - status: McpRuntimeStatus, - checking: Schema.Boolean, - message: Schema.optional(Schema.String), - latencyMs: Schema.optional(Schema.Int), - discoveredTools: Schema.Array(McpTool), - checkedAt: Schema.optional(IsoDateTime), -}); -export type McpCatalogRuntimeSnapshot = typeof McpCatalogRuntimeSnapshot.Type; -``` - -### A4. `McpServerVarDraft` gains `keepSecret` (item 13) - -BEFORE (lines 73-80): -```ts -export const McpServerVarDraft = Schema.Struct({ - name: TrimmedNonEmptyString, - secret: Schema.Boolean, - perProject: Schema.Boolean, - required: Schema.Boolean, - value: Schema.NullOr(Schema.String), -}); -export type McpServerVarDraft = typeof McpServerVarDraft.Type; -``` -AFTER (add `keepSecret`): -```ts -export const McpServerVarDraft = Schema.Struct({ - name: TrimmedNonEmptyString, - secret: Schema.Boolean, - perProject: Schema.Boolean, - required: Schema.Boolean, - value: Schema.NullOr(Schema.String), - // ru-fork: when true for a secret var, the decider PRESERVES the server's existing stored - // secret ref instead of re-splitting `value` — so editing other fields doesn't wipe a secret - // the client never had in plaintext. Ignored for non-secret vars / on add (no existing ref). - keepSecret: Schema.optionalKey(Schema.Boolean), -}); -export type McpServerVarDraft = typeof McpServerVarDraft.Type; -``` - -### A5. `McpBindingPatch` gains `keepVarValues` (item 13, per-project secrets) - -BEFORE (lines 221-227): -```ts -export const McpBindingPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - toolPolicy: Schema.optionalKey(McpToolPolicy), - varValues: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), - timeoutMs: Schema.optionalKey(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)))), -}); -export type McpBindingPatch = typeof McpBindingPatch.Type; -``` -AFTER: -```ts -export const McpBindingPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - toolPolicy: Schema.optionalKey(McpToolPolicy), - varValues: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), - // ru-fork: per-project secret var names whose stored ref must be PRESERVED (the client left the - // masked field untouched). Names here are kept from the existing binding even though they are - // absent from / blank in `varValues`. See decider `resolveBindingVarValues`. - keepVarValues: Schema.optionalKey(Schema.Array(Schema.String)), - timeoutMs: Schema.optionalKey(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)))), -}); -export type McpBindingPatch = typeof McpBindingPatch.Type; -``` - -> No new event/command is needed for item 11 — see PART F4: orphaned-`varValues` pruning rides the -> existing `mcp.binding-set` event by emitting it from the reactor with a ref-preserving filtered map. - ---- - -## PART B — Secret store prune API (item 10) - -### B1. `ServerSecretStoreShape` gains `pruneByPrefix` -File `apps/server/src/auth/Services/ServerSecretStore.ts`. - -BEFORE (interface body): -```ts -export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly getOrCreateRandom: ( - name: string, - bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; -} -``` -AFTER (add `pruneByPrefix`): -```ts -export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly getOrCreateRandom: ( - name: string, - bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; - /** - * Remove every stored secret whose name starts with `prefix` and is NOT in `keep`. - * Used to GC orphaned MCP var secrets (sibling of the probe-cache `deleteKeysNotIn`). - * A missing store directory / individual removal error is swallowed (best-effort GC). - */ - readonly pruneByPrefix: ( - prefix: string, - keep: ReadonlySet, - ) => Effect.Effect; -} -``` - -### B2. `makeServerSecretStore` implements `pruneByPrefix` -File `apps/server/src/auth/Layers/ServerSecretStore.ts`. The file already binds `fileSystem`, `path`, -`serverConfig`, and `resolveSecretPath`. Add the implementation **immediately before the final -`return { ... } satisfies ServerSecretStoreShape;`** and add it to the returned object. - -New function (insert before the return): -```ts - const pruneByPrefix: ServerSecretStoreShape["pruneByPrefix"] = (prefix, keep) => - Effect.gen(function* () { - const entries = yield* fileSystem.readDirectory(serverConfig.secretsDir).pipe( - Effect.catch(() => Effect.succeed>([])), - ); - for (const entry of entries) { - if (!entry.endsWith(".bin")) { - continue; - } - const name = entry.slice(0, -".bin".length); - if (!name.startsWith(prefix) || keep.has(name)) { - continue; - } - yield* fileSystem.remove(path.join(serverConfig.secretsDir, entry)).pipe(Effect.ignore); - } - }).pipe( - Effect.catch( - (cause) => - new SecretStoreError({ message: `Failed to prune secrets for prefix ${prefix}.`, cause }), - ), - ); -``` -Return-object change — BEFORE (the existing returned object's closing; the exact current tail returns -`{ get, set, getOrCreateRandom, remove }` — verify and append `pruneByPrefix`): -```ts - return { get, set, getOrCreateRandom, remove } satisfies ServerSecretStoreShape; -``` -AFTER: -```ts - return { get, set, getOrCreateRandom, remove, pruneByPrefix } satisfies ServerSecretStoreShape; -``` -> Imports: none added — `fileSystem.readDirectory`/`remove`, `path.join`, `Effect` are already in scope. -> **Plan-verification note during impl:** confirm the exact current `return {…}` names; if the tail -> differs from the BEFORE above, that is a STOP (amend plan). - ---- - -## PART C — Supervisor (`apps/server/src/ru-fork/mcp/McpSupervisor.ts`) — items 1,2,3,4,5 - -### C1. Remove the mandatory-first-probe (item 2) -`isSweepDue` BEFORE (lines 102-116): -```ts -export function isSweepDue( - instance: SupervisorInstance, - watched: ReadonlySet | null, - nowMs: number, - localIntervalMs: number, - remoteIntervalMs: number, -): boolean { - if (instance.checkedAtMs === null) { - return true; - } - if (watched !== null && !instanceInWatched(instance, watched)) { - return false; - } - return isProbeDue(instance, nowMs, localIntervalMs, remoteIntervalMs); -} -``` -AFTER (never-probed is NOT auto-due; only watched + interval drive the sweep): -```ts -export function isSweepDue( - instance: SupervisorInstance, - watched: ReadonlySet | null, - nowMs: number, - localIntervalMs: number, - remoteIntervalMs: number, -): boolean { - // A never-probed instance is NEVER auto-probed (no probing on load) — its first probe comes - // only from a manual recheck or a config-affecting change (reactor `probeHashes`). The sweep - // re-checks ONLY already-probed instances of the watched project, on their transport interval. - if (instance.checkedAtMs === null) { - return false; - } - if (watched !== null && !instanceInWatched(instance, watched)) { - return false; - } - return isProbeDue(instance, nowMs, localIntervalMs, remoteIntervalMs); -} -``` -`isProbeDue` BEFORE (lines 150-164): -```ts -export function isProbeDue( - instance: SupervisorInstance, - nowMs: number, - localIntervalMs: number, - remoteIntervalMs: number, -): boolean { - if (instance.checkedAtMs === null) { - return true; - } - const intervalMs = instance.resolved.transport === "stdio" ? localIntervalMs : remoteIntervalMs; - if (intervalMs <= 0) { - return false; - } - return nowMs - instance.checkedAtMs >= intervalMs; -} -``` -AFTER: -```ts -export function isProbeDue( - instance: SupervisorInstance, - nowMs: number, - localIntervalMs: number, - remoteIntervalMs: number, -): boolean { - if (instance.checkedAtMs === null) { - return false; // never probed ⇒ not auto-due (probe only on manual / config change) - } - const intervalMs = instance.resolved.transport === "stdio" ? localIntervalMs : remoteIntervalMs; - if (intervalMs <= 0) { - return false; - } - return nowMs - instance.checkedAtMs >= intervalMs; -} -``` - -### C2. New-and-uncached instances register as `"unchecked"` (item 4) -`reconcile` BEFORE — the no-seed branch (lines 286-298): -```ts - : { - hash, - configKey: desiredInstance.configKey, - resolved: desiredInstance.resolved, - refs: desiredInstance.refs, - status: "connecting", - message: null, - latencyMs: null, - checkedAt: null, - checkedAtMs: null, - discoveredTools: [], - consecutiveFailures: 0, - }, -``` -AFTER (status `"unchecked"`): -```ts - : { - hash, - configKey: desiredInstance.configKey, - resolved: desiredInstance.resolved, - refs: desiredInstance.refs, - status: "unchecked", - message: null, - latencyMs: null, - checkedAt: null, - checkedAtMs: null, - discoveredTools: [], - consecutiveFailures: 0, - }, -``` - -### C3. `reconcile` returns the set of newly-added hashes (feeds item 3) -Change the shape type + the implementation so the reactor can probe only the just-appeared instances. - -`McpSupervisorShape.reconcile` type BEFORE (line 175): -```ts - readonly reconcile: (desired: ReadonlyMap) => Effect.Effect; -``` -AFTER: -```ts - /** Returns the hashes that were newly added by this reconcile (not previously registered). */ - readonly reconcile: ( - desired: ReadonlyMap, - ) => Effect.Effect>; -``` -`reconcile` impl — BEFORE the tail (lines 300-303): -```ts - yield* Ref.set(registryRef, next); - yield* publishChange; - }); -``` -AFTER (compute + return added hashes): -```ts - yield* Ref.set(registryRef, next); - yield* publishChange; - // Hashes present now but absent from the prior registry — brand-new instances or - // configs whose key just changed (an edit mints a new hash). The reactor force-probes - // these when the reconcile was triggered by a config-affecting change (item 3). - return [...next.keys()].filter((hash) => !current.has(hash)); - }); -``` - -### C4. `probeHashes` — force-probe a specific set now (feeds item 3) -Add to `McpSupervisorShape` (after `recheck`, line 187): -```ts - /** Force a probe NOW of the live instances with these hashes (config-change driven). */ - readonly probeHashes: (hashes: ReadonlyArray) => Effect.Effect; -``` -Add the implementation after `recheck` (the existing `recheck` ends at line 402). Insert: -```ts - const probeHashes: McpSupervisorShape["probeHashes"] = (hashes) => - Effect.gen(function* () { - if (hashes.length === 0) { - return; - } - const registry = yield* Ref.get(registryRef); - const matched = hashes - .map((hash) => registry.get(hash)) - .filter((instance): instance is SupervisorInstance => instance !== undefined); - yield* Effect.forEach(matched, probeInstance, { concurrency: 4, discard: true }); - }); -``` -Add `probeHashes` to the returned object — BEFORE (lines 437-444): -```ts - return { - reconcile, - setWatchedProjects, - recheck, - currentInstances, - changes: Stream.fromPubSub(changesPubSub), - start, - } satisfies McpSupervisorShape; -``` -AFTER: -```ts - return { - reconcile, - setWatchedProjects, - recheck, - probeHashes, - currentInstances, - currentInFlight, - changes: Stream.fromPubSub(changesPubSub), - start, - } satisfies McpSupervisorShape; -``` - -### C5. Surface in-flight hashes + publish on probe START (item 5) -The runtime stream must reflect "проверка…" the moment a probe starts. - -Add to `McpSupervisorShape` (after `currentInstances`, line 188): -```ts - /** Hashes with a probe currently in flight — drives the UI «проверка…» indicator. */ - readonly currentInFlight: Effect.Effect>; -``` -Add the accessor next to `currentInstances` (line 428-430) — BEFORE: -```ts - const currentInstances: McpSupervisorShape["currentInstances"] = Ref.get(registryRef).pipe( - Effect.map((registry) => [...registry.values()]), - ); -``` -AFTER (append the in-flight accessor): -```ts - const currentInstances: McpSupervisorShape["currentInstances"] = Ref.get(registryRef).pipe( - Effect.map((registry) => [...registry.values()]), - ); - - const currentInFlight: McpSupervisorShape["currentInFlight"] = Ref.get(inFlightRef); -``` -Publish a change when a probe is claimed so the UI flips to «проверка…» immediately. -`probeInstance` BEFORE (lines 387-394): -```ts - const probeInstance = (instance: SupervisorInstance) => - Effect.gen(function* () { - const alreadyRunning = yield* claimInFlight(instance.hash); - if (alreadyRunning) { - return; // another fiber is already probing this exact config - } - yield* runProbe(instance).pipe(Effect.ensuring(releaseInFlight(instance.hash))); - }); -``` -AFTER (publish on claim AND on release, so both edges reach the stream): -```ts - const probeInstance = (instance: SupervisorInstance) => - Effect.gen(function* () { - const alreadyRunning = yield* claimInFlight(instance.hash); - if (alreadyRunning) { - return; // another fiber is already probing this exact config - } - yield* publishChange; // flip UI to «проверка…» the instant the probe starts - yield* runProbe(instance).pipe( - Effect.ensuring(releaseInFlight(instance.hash).pipe(Effect.zipRight(publishChange))), - ); - }); -``` -> `publishChange` is already defined (line 230); `claimInFlight`/`releaseInFlight`/`runProbe` exist. -> Imports: none added. - -### C6. Both intervals 0 ⇒ sweep is a no-op (item 1) -`runSweep` BEFORE (lines 404-426): -```ts - const runSweep = Effect.gen(function* () { - const settings = yield* serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), - ); - const instances = [...(yield* Ref.get(registryRef)).values()]; - if (settings === null || instances.length === 0) { - return; // can't read cadence, or nothing to probe — keep last status - } - const nowMs = yield* Clock.currentTimeMillis; - const watched = yield* Ref.get(watchedProjectsRef); - const localIntervalMs = settings.mcp.recheckLocalMinutes * MINUTE_MS; - const remoteIntervalMs = settings.mcp.recheckRemoteMinutes * MINUTE_MS; - // The loop ticks every 60s but probes only what's due — this is what stops - // the `npx` spam (see `isSweepDue` for the mandatory-first / watched rules). - const due = instances.filter((instance) => - isSweepDue(instance, watched, nowMs, localIntervalMs, remoteIntervalMs), - ); - if (due.length === 0) { - return; - } - yield* Effect.logDebug("[mcp] sweep", { due: due.length, total: instances.length }); - yield* Effect.forEach(due, probeInstance, { concurrency: 4, discard: true }); - }); -``` -AFTER (explicit early-out when both intervals are off ⇒ the loop does nothing): -```ts - const runSweep = Effect.gen(function* () { - const settings = yield* serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), - ); - const instances = [...(yield* Ref.get(registryRef)).values()]; - if (settings === null || instances.length === 0) { - return; // can't read cadence, or nothing to probe — keep last status - } - const localIntervalMs = settings.mcp.recheckLocalMinutes * MINUTE_MS; - const remoteIntervalMs = settings.mcp.recheckRemoteMinutes * MINUTE_MS; - // Both intervals 0 ⇒ no periodic re-checking at all: the loop is off. The first probe of any - // server then comes only from a manual recheck or a config-affecting change. - if (localIntervalMs <= 0 && remoteIntervalMs <= 0) { - return; - } - const nowMs = yield* Clock.currentTimeMillis; - const watched = yield* Ref.get(watchedProjectsRef); - // The loop ticks every 60s but probes only what's due (watched project + elapsed interval; - // never-probed instances are excluded — see `isSweepDue`). - const due = instances.filter((instance) => - isSweepDue(instance, watched, nowMs, localIntervalMs, remoteIntervalMs), - ); - if (due.length === 0) { - return; - } - yield* Effect.logDebug("[mcp] sweep", { due: due.length, total: instances.length }); - yield* Effect.forEach(due, probeInstance, { concurrency: 4, discard: true }); - }); -``` - -### C7. `McpRuntime` populates `checking` on both snapshot kinds (item 5) -File `apps/server/src/ru-fork/mcp/McpRuntime.ts`. The snapshots now require `checking: boolean` -(A2/A3). Read `supervisor.currentInFlight` once per snapshot and set `checking` per row by hash. - -Read the in-flight set — BEFORE (the head of `currentSnapshot`, lines 45-48): -```ts - }> = Effect.gen(function* () { - const instances = yield* supervisor.currentInstances; - const bindings = yield* bindingRepository.listAll(); - const catalog = yield* catalogRepository.listAll(); -``` -AFTER: -```ts - }> = Effect.gen(function* () { - const instances = yield* supervisor.currentInstances; - const inFlight = yield* supervisor.currentInFlight; - const bindings = yield* bindingRepository.listAll(); - const catalog = yield* catalogRepository.listAll(); -``` -Binding row — BEFORE (lines 68-77): -```ts - runtimes.push({ - projectId: binding.projectId, - serverId: binding.serverId, - status: instance.status, - ...(instance.message !== null ? { message: instance.message } : {}), - ...(instance.latencyMs !== null ? { latencyMs: instance.latencyMs } : {}), - ...(instance.checkedAt !== null ? { checkedAt: IsoDateTime.make(instance.checkedAt) } : {}), - discoveredTools: instance.discoveredTools, - effectiveAllowedTools: effectiveAllowedTools(binding.toolPolicy, instance.discoveredTools), - }); -``` -AFTER (add `checking`): -```ts - runtimes.push({ - projectId: binding.projectId, - serverId: binding.serverId, - status: instance.status, - checking: inFlight.has(instance.hash), - ...(instance.message !== null ? { message: instance.message } : {}), - ...(instance.latencyMs !== null ? { latencyMs: instance.latencyMs } : {}), - ...(instance.checkedAt !== null ? { checkedAt: IsoDateTime.make(instance.checkedAt) } : {}), - discoveredTools: instance.discoveredTools, - effectiveAllowedTools: effectiveAllowedTools(binding.toolPolicy, instance.discoveredTools), - }); -``` -Catalog row — BEFORE (lines 86-93): -```ts - catalogRuntimes.push({ - serverId: server.id, - status: instance.status, - ...(instance.message !== null ? { message: instance.message } : {}), - ...(instance.latencyMs !== null ? { latencyMs: instance.latencyMs } : {}), - ...(instance.checkedAt !== null ? { checkedAt: IsoDateTime.make(instance.checkedAt) } : {}), - discoveredTools: instance.discoveredTools, - }); -``` -AFTER: -```ts - catalogRuntimes.push({ - serverId: server.id, - status: instance.status, - checking: inFlight.has(instance.hash), - ...(instance.message !== null ? { message: instance.message } : {}), - ...(instance.latencyMs !== null ? { latencyMs: instance.latencyMs } : {}), - ...(instance.checkedAt !== null ? { checkedAt: IsoDateTime.make(instance.checkedAt) } : {}), - discoveredTools: instance.discoveredTools, - }); -``` -> No new import (`supervisor` is bound at line 38). The probe-START `publishChange` (C5) makes the -> `supervisor.changes` stream fire when `inFlight` gains a hash, so the debounced snapshot re-runs -> and `checking:true` reaches the client within `RUNTIME_DEBOUNCE` (200ms). - ---- - -## PART D — Reactor (`apps/server/src/ru-fork/mcp/McpReactor.ts`) — items 3, 9, 10, 11 - -### D1. `ReactorSignal` carries an `eager` flag for config-driven probing (item 3) -BEFORE (lines 64-72): -```ts -/** What the worker was woken for: a plain reconcile, or a new project to autobind. */ -type ReactorSignal = - | { readonly kind: "reconcile" } - | { readonly kind: "project-created"; readonly projectId: ProjectId }; - -const isReconcileRelevant = (event: OrchestrationEvent): boolean => - event.type.startsWith("mcp.") || - event.type === "project.deleted" || - event.type === "project.meta-updated"; -``` -AFTER (`reconcile` gains `eager`; project-created carries it implicitly as eager): -```ts -/** - * What the worker was woken for. `eager` reconciles force-probe newly-appeared instances - * (a config-affecting change happened); the startup/hydrate reconcile is NOT eager (item 2 — - * no probing on load). A `project-created` always reconciles eagerly after autobinding. - */ -type ReactorSignal = - | { readonly kind: "reconcile"; readonly eager: boolean } - | { readonly kind: "project-created"; readonly projectId: ProjectId }; - -const isReconcileRelevant = (event: OrchestrationEvent): boolean => - event.type.startsWith("mcp.") || - event.type === "project.deleted" || - event.type === "project.meta-updated"; -``` - -### D2. `reconcileNow` returns its added hashes; `processSignal` force-probes on eager -`reconcileNow` BEFORE (lines 169-191) — change the success path to return the added hashes from -`supervisor.reconcile`, and the catch to return `[]`: -```ts - const reconcileNow = Effect.gen(function* () { - const desired = yield* computeDesired; - yield* supervisor.reconcile(desired); - // GC: drop persisted cache rows whose authored config no longer exists in the - // live desired set (catalog default removed / override changed or dropped). - // This only runs when computeDesired fully SUCCEEDED (a failed/partial read - // throws and is caught below, skipping GC), so an empty liveConfigKeys means a - // genuinely empty authored set — clearing the cache then is intentional. - const liveConfigKeys = new Set([...desired.values()].map((instance) => instance.configKey)); - yield* probeCache - .deleteKeysNotIn([...liveConfigKeys]) - .pipe( - Effect.catch((error) => - Effect.logError("mcp reactor failed to GC probe cache", { error }), - ), - ); - }).pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor failed to reconcile desired instances", { - cause: Cause.pretty(cause), - }), - ), - ); -``` -AFTER (returns `ReadonlyArray`; also runs the secret GC — D3 — and varValues prune — D4): -```ts - const reconcileNow: Effect.Effect> = Effect.gen(function* () { - const desired = yield* computeDesired; - const added = yield* supervisor.reconcile(desired); - // GC: drop persisted cache rows whose authored config no longer exists in the - // live desired set (catalog default removed / override changed or dropped). - const liveConfigKeys = new Set([...desired.values()].map((instance) => instance.configKey)); - yield* probeCache - .deleteKeysNotIn([...liveConfigKeys]) - .pipe( - Effect.catch((error) => - Effect.logError("mcp reactor failed to GC probe cache", { error }), - ), - ); - yield* gcOrphanedSecrets; // item 10 — prune secret .bin files no longer referenced - return added; - }).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logError("mcp reactor failed to reconcile desired instances", { - cause: Cause.pretty(cause), - }); - const empty: ReadonlyArray = []; - return empty; // cast-free: a failed reconcile added nothing to probe - }), - ), - ); -``` -`processSignal` BEFORE (lines 361-368): -```ts - const processSignal = (signal: ReactorSignal): Effect.Effect => - Effect.gen(function* () { - if (signal.kind === "project-created") { - yield* autobindBuiltinsForProject(signal.projectId); - } - yield* reconcileNow; - yield* syncOverlaysAndRestart; - }); -``` -AFTER (force-probe added hashes when eager / project-created): -```ts - const processSignal = (signal: ReactorSignal): Effect.Effect => - Effect.gen(function* () { - const eager = signal.kind === "project-created" ? true : signal.eager; - if (signal.kind === "project-created") { - yield* autobindBuiltinsForProject(signal.projectId); - } - yield* pruneOrphanedVarValues; // item 11 — emit binding-set to drop stranded var values - const added = yield* reconcileNow; - if (eager) { - // A config-affecting change (or new project) just landed — probe the newly-appeared - // instances NOW instead of waiting for the sweep (item 3). Cosmetic edits add nothing. - yield* supervisor.probeHashes(added); - } - yield* syncOverlaysAndRestart; - }); -``` - -### D3. Secret-store GC step (item 10) -Add the `gcOrphanedSecrets` effect inside `make` (place it just before `reconcileNow`). It computes the -set of secret refs referenced by the catalog defaults **and** every binding, then prunes the store. - -New code (insert before `const reconcileNow`): -```ts - // item 10: prune secret .bin files no longer referenced by any catalog var or binding value. - // Mirrors the probe-cache GC. Secret ref-names use the `mcp-var-` prefix (see McpSecretNames). - const gcOrphanedSecrets = Effect.gen(function* () { - const catalog = yield* catalogRepository.listAll(); - const bindings = yield* bindingRepository.listAll(); - const live = new Set(); - for (const server of catalog) { - for (const ref of collectVarSecretRefs(server.vars, {})) { - live.add(ref); - } - } - for (const binding of bindings) { - const server = catalog.find((entry) => entry.id === binding.serverId); - if (!server) { - continue; - } - for (const ref of collectVarSecretRefs(server.vars, binding.varValues)) { - live.add(ref); - } - } - yield* secretStore.pruneByPrefix(MCP_VAR_SECRET_PREFIX, live); - }).pipe( - Effect.catch((error) => - Effect.logError("mcp reactor failed to GC orphaned secrets", { error }), - ), - ); -``` -Imports added to McpReactor.ts: -- from `./McpSecrets.ts`: add `collectVarSecretRefs` to the existing import (currently - `import { materializeSecretValues } from "./McpSecrets.ts";`) ⇒ - `import { collectVarSecretRefs, materializeSecretValues } from "./McpSecrets.ts";` -- from `./McpSecretNames.ts` (NEW import): `import { MCP_VAR_SECRET_PREFIX } from "./McpSecretNames.ts";` -- `secretStore` is already bound (line 87). - -> Requires `MCP_VAR_SECRET_PREFIX` to exist — see PART F1. - -### D4. Orphaned-`varValues` prune step (item 11) -Add `pruneOrphanedVarValues` inside `make` (place before `processSignal`). For every binding whose -stored `varValues` contains a name **not declared** by its catalog server, dispatch a `mcp.binding-set` -that replays the binding with the orphaned names dropped (refs preserved — see PART F4). - -New code: -```ts - // item 11: drop var values stranded by a catalog edit that removed a var. The resolver already - // ignores unknown names, so this is hygiene + makes the secret GC exact. Ref-preserving (no - // plaintext needed) — see decider `mcp.binding-set` keepVarValues handling. - const pruneOrphanedVarValues = Effect.gen(function* () { - const catalog = yield* catalogRepository.listAll(); - const bindings = yield* bindingRepository.listAll(); - const declaredByServer = new Map( - catalog.map((server) => [server.id, new Set(server.vars.map((variable) => variable.name))]), - ); - for (const binding of bindings) { - const declared = declaredByServer.get(binding.serverId); - if (!declared) { - continue; - } - const orphans = Object.keys(binding.varValues).filter((name) => !declared.has(name)); - if (orphans.length === 0) { - continue; - } - const keep = Object.keys(binding.varValues).filter((name) => declared.has(name)); - yield* engine - .dispatch({ - type: "mcp.binding-set", - commandId: CommandId.make(`server:mcp-prune-vars:${binding.projectId}:${binding.serverId}`), - projectId: binding.projectId, - serverId: binding.serverId, - patch: { varValues: {}, keepVarValues: keep }, - }) - .pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor failed to prune orphaned var values", { - projectId: binding.projectId, - serverId: binding.serverId, - cause: Cause.pretty(cause), - }), - ), - ); - } - }).pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor failed to scan bindings for orphaned var values", { - cause: Cause.pretty(cause), - }), - ), - ); -``` -> `patch: { varValues: {}, keepVarValues: keep }` means: replace per-project values with "none new", -> but PRESERVE the existing refs/strings for the names in `keep` (the declared survivors). Orphan -> names are in neither ⇒ dropped. `CommandId`, `engine`, `Cause` are already imported. -> **Idempotence:** once pruned, the binding has no orphan names, so subsequent reconciles dispatch -> nothing — no event loop. `keepVarValues` reuses existing stored refs, so re-emitting an identical -> binding produces an identical projection row; the reactor's overlay fingerprint is unchanged ⇒ no -> spurious restart. - -### D5. Startup reconcile is NOT eager; event reconciles ARE (items 2, 3) -The event subscription + initial enqueue. BEFORE (lines 376-391): -```ts - // Subscribe before seeding so seed/autobind events also drive reconcile. - yield* Effect.forkScoped( - Stream.runForEach(engine.streamDomainEvents, (event) => { - if (event.type === "project.created") { - return worker.enqueue({ kind: "project-created", projectId: event.payload.projectId }); - } - if (isReconcileRelevant(event)) { - return worker.enqueue({ kind: "reconcile" }); - } - return Effect.void; - }), - ); - yield* seedBuiltinsIfEmpty; - // Initial reconcile: cover bindings restored from the DB on restart. - yield* worker.enqueue({ kind: "reconcile" }); - }); -``` -AFTER (event-driven ⇒ `eager: true`; startup ⇒ `eager: false`): -```ts - // Subscribe before seeding so seed/autobind events also drive reconcile. - yield* Effect.forkScoped( - Stream.runForEach(engine.streamDomainEvents, (event) => { - if (event.type === "project.created") { - return worker.enqueue({ kind: "project-created", projectId: event.payload.projectId }); - } - if (isReconcileRelevant(event)) { - // A change we caused (catalog/binding edit, seed, autobind) ⇒ probe what changed now. - return worker.enqueue({ kind: "reconcile", eager: true }); - } - return Effect.void; - }), - ); - yield* seedBuiltinsIfEmpty; - // Initial reconcile: cover bindings restored from the DB on restart — NOT eager (no probing - // on load; cached status is shown, never-probed stays «не проверено» until a trigger). - yield* worker.enqueue({ kind: "reconcile", eager: false }); - }); -``` -> **Seed interaction:** on a fresh install `seedBuiltinsIfEmpty` dispatches `mcp.server-add`, whose -> event flows back through the subscription as `eager: true`. Built-ins with required per-project -> vars are incomplete ⇒ excluded from `computeDesired` ⇒ not in `added` ⇒ not probed (item 7). -> A complete built-in (e.g. filesystem, no vars) WOULD be probed on seed. That is acceptable -> (first-ever seed is a one-time event, not "load" of an existing catalog). Documented, not a bug. - -### D6 (item 9). First-sight restart when a live session already spawned — see PART E (shared -fingerprint). The `syncOverlaysAndRestart` first-sight guard is replaced by the shared-fingerprint -seeding in PART E; no separate edit here. - ---- - -## PART E — Startup race + restart (items 8, 9) - -**Root cause recap.** `ProviderCommandReactor` writes the overlay at spawn (can be empty if the -project-shell projection hasn't hydrated). `McpReactor` later writes the correct overlay but its -"first sight ⇒ record only" guard skips restarting the already-live session, because the two writers -keep **separate** fingerprint state. Fix: a **shared** fingerprint Ref the spawn path seeds, so the -reactor's later write is a real diff and triggers exactly one restart. - -### E1. New service `McpOverlayState` -New file `apps/server/src/ru-fork/mcp/McpOverlayState.ts`: -```ts -// ru-fork: shared last-written overlay fingerprint per project. Seeded by the spawn path -// (ProviderCommandReactor) the instant it writes an overlay for a session, and read/written by -// McpReactor's overlay sync. Sharing it lets the reactor detect that a live session spawned on a -// now-stale overlay and restart it exactly once (items 8 & 9). - -import type { ProjectId } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; - -export interface McpOverlayStateShape { - /** Record the fingerprint last written for a project (spawn path or reactor). */ - readonly setFingerprint: (projectId: ProjectId, fingerprint: string) => Effect.Effect; - /** The current fingerprint map (reactor diff). */ - readonly getFingerprints: Effect.Effect>; - /** Replace the whole map (reactor commits its post-sync snapshot). */ - readonly setFingerprints: (next: ReadonlyMap) => Effect.Effect; -} - -export class McpOverlayState extends Context.Service()( - "ru-fork/mcp/McpOverlayState", -) {} - -export const McpOverlayStateLive = Layer.effect( - McpOverlayState, - Effect.gen(function* () { - const ref = yield* Ref.make>(new Map()); - return { - setFingerprint: (projectId, fingerprint) => - Ref.update(ref, (current) => new Map(current).set(projectId, fingerprint)), - getFingerprints: Ref.get(ref), - setFingerprints: (next) => Ref.set(ref, next), - } satisfies McpOverlayStateShape; - }), -); -``` - -### E2. Wire `McpOverlayStateLive` into the MCP layer graph -File `apps/server/src/ru-fork/mcp/McpLayers.ts`. `McpOverlayLive` is already `provideMerge`d into -`McpRuntimeServicesLive` precisely so `ProviderCommandReactor` (spawn-time overlay) can consume it -from this layer's output (see its existing comment). `McpOverlayState` rides the **same** seam. - -BEFORE (the `.pipe(...)` chain on `McpRuntimeServicesLive`): -```ts -).pipe( - // Overlay writer — provided to the reactor (restart-on-change) and exposed for - // ProviderCommandReactor (spawn-time overlay). Provided before the repos / - // secret store below so those reach it. - Layer.provideMerge(McpOverlayLive), - Layer.provideMerge(McpSupervisorLive), -``` -AFTER (add the state layer right after the overlay so it is likewise exposed to consumers): -```ts -).pipe( - // Overlay writer — provided to the reactor (restart-on-change) and exposed for - // ProviderCommandReactor (spawn-time overlay). Provided before the repos / - // secret store below so those reach it. - Layer.provideMerge(McpOverlayLive), - // Shared overlay fingerprint state — same seam: read/written by the reactor AND seeded by - // ProviderCommandReactor at spawn (items 8 & 9). One process-global instance. - Layer.provideMerge(McpOverlayStateLive), - Layer.provideMerge(McpSupervisorLive), -``` -Import added to `McpLayers.ts`: `import { McpOverlayStateLive } from "./McpOverlayState.ts";` -> **Confirmed:** because `McpOverlayLive` reaching `ProviderCommandReactor` already works via this -> exact `provideMerge` exposure, `McpOverlayStateLive` placed identically reaches it too. No edit is -> needed at the orchestration layer assembly. `provideMerge` keeps a single shared instance. - -### E3. `McpReactor` uses the shared state instead of its private Ref (item 9) -File `McpReactor.ts`. -Remove the private ref — BEFORE (lines 92-94): -```ts - // Last-written overlay fingerprint per project — drives "did this project's - // effective overlay change ⇒ restart its live sessions" (M7.5). - const overlayFingerprintsRef = yield* Ref.make>(new Map()); -``` -AFTER: -```ts - // Last-written overlay fingerprint per project (SHARED with the spawn path, so a session that - // spawned on a now-stale overlay is detected and restarted exactly once — items 8 & 9). - const overlayState = yield* McpOverlayState; -``` -`syncOverlaysAndRestart` BEFORE (lines 239-272): -```ts - const syncOverlaysAndRestart = Effect.gen(function* () { - if (!MCP_ENGINE_USE_OVERLAY) { - return; - } - const previous = yield* Ref.get(overlayFingerprintsRef); - const bindings = yield* bindingRepository.listAll(); - const projectIds = new Set(previous.keys()); - for (const binding of bindings) { - projectIds.add(binding.projectId); - } - if (projectIds.size === 0) { - return; - } - const liveThreads = yield* liveThreadsByProject; - - const next = new Map(previous); - for (const projectId of projectIds) { - const result = yield* mcpOverlay - .writeOverlay(projectId) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (result === null) { - continue; - } - const previousFingerprint = previous.get(projectId); - if (previousFingerprint === result.fingerprint) { - continue; // unchanged ⇒ no restart (C2: catalog edit under an override is inert) - } - next.set(projectId, result.fingerprint); - if (previousFingerprint === undefined) { - continue; // first sight ⇒ record only - } - yield* Effect.forEach(liveThreads.get(projectId) ?? [], restartThread, { discard: true }); - } - yield* Ref.set(overlayFingerprintsRef, next); - }).pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor overlay sync failed", { cause: Cause.pretty(cause) }), - ), - ); -``` -AFTER (read/write the shared map; first-sight WITH a live session that has no recorded fingerprint -still restarts, because the spawn path now seeds the fingerprint — so `undefined` here means "no -session ever spawned for this project", which correctly records-only): -```ts - const syncOverlaysAndRestart = Effect.gen(function* () { - if (!MCP_ENGINE_USE_OVERLAY) { - return; - } - const previous = yield* overlayState.getFingerprints; - const bindings = yield* bindingRepository.listAll(); - const projectIds = new Set(previous.keys()); - for (const binding of bindings) { - projectIds.add(binding.projectId); - } - if (projectIds.size === 0) { - return; - } - const liveThreads = yield* liveThreadsByProject; - - const next = new Map(previous); - for (const projectId of projectIds) { - const result = yield* mcpOverlay - .writeOverlay(projectId) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (result === null) { - continue; - } - const previousFingerprint = previous.get(projectId); - if (previousFingerprint === result.fingerprint) { - continue; // unchanged ⇒ no restart - } - next.set(projectId, result.fingerprint); - // Restart live sessions whose overlay just changed. When the spawn path seeded a fingerprint - // (a session is live), `previousFingerprint` is defined and a diff ⇒ restart. Only when NO - // session ever spawned for this project (`undefined`) do we record-only. - if (previousFingerprint === undefined && (liveThreads.get(projectId) ?? []).length === 0) { - continue; // no live session to refresh ⇒ just record - } - yield* Effect.forEach(liveThreads.get(projectId) ?? [], restartThread, { discard: true }); - } - yield* overlayState.setFingerprints(next); - }).pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor overlay sync failed", { cause: Cause.pretty(cause) }), - ), - ); -``` -Import added to McpReactor.ts: `import { McpOverlayState } from "./McpOverlayState.ts";` -> `Ref` import remains (used by `overlayState`? no — now only via the service). **Check:** if `Ref` -> becomes unused in McpReactor after removing `overlayFingerprintsRef`, remove the `import * as Ref` -> line (lint would flag an unused import). Grep confirms `Ref` is used ONLY for -> `overlayFingerprintsRef` in this file → its import line `import * as Ref from "effect/Ref";` is -> **removed**. (If another `Ref.` use exists, keep it — STOP to amend.) - -### E4. Spawn path seeds the shared fingerprint (item 8) -File `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`. -Bind the service near the other `yield*` services (next to line 231 `const mcpOverlay = yield* McpOverlay;`): -```ts - const mcpOverlayState = yield* McpOverlayState; -``` -`startProviderSession` BEFORE (lines 522-529): -```ts - // ru-fork: resolve the per-project MCP overlay just before spawn so qwen - // sees the current servers + tool policy. Best-effort — an overlay - // failure must not block the turn (it degrades to no MCP, not a crash). - const mcpOverlayResult = MCP_ENGINE_USE_OVERLAY - ? yield* mcpOverlay - .writeOverlay(thread.projectId) - .pipe(Effect.catch(() => Effect.succeed(null))) - : null; -``` -AFTER (record the spawn-time fingerprint so the reactor can diff against it): -```ts - // ru-fork: resolve the per-project MCP overlay just before spawn so qwen - // sees the current servers + tool policy. Best-effort — an overlay - // failure must not block the turn (it degrades to no MCP, not a crash). - const mcpOverlayResult = MCP_ENGINE_USE_OVERLAY - ? yield* mcpOverlay - .writeOverlay(thread.projectId) - .pipe(Effect.catch(() => Effect.succeed(null))) - : null; - // Seed the SHARED overlay fingerprint so McpReactor knows this project has a live session - // spawned on THIS overlay. If the overlay was stale (state not yet hydrated ⇒ 0 servers), - // the reactor's next write differs and restarts the session exactly once (items 8 & 9). - if (mcpOverlayResult !== null) { - yield* mcpOverlayState.setFingerprint(thread.projectId, mcpOverlayResult.fingerprint); - } -``` -Import added: `import { McpOverlayState } from "../../ru-fork/mcp/McpOverlayState.ts";` -> **Confirmed:** `ProviderCommandReactor`'s `make` now requires `McpOverlayState` in R, satisfied by -> E2's `provideMerge` exposure (same path `McpOverlay` already takes). No orchestration-layer edit. - ---- - -## PART F — Decider / secrets (items 11, 13) - -### F1. Export the secret-name prefix -File `apps/server/src/ru-fork/mcp/McpSecretNames.ts`. The file builds names as -`mcp-var-...`. Add an exported constant (used by D3 GC) and use it in the builder. - -Add the exported constant before `mcpVarSecretName` (after the `encode` helper). BEFORE: -```ts -const encode = (value: string): string => Buffer.from(value, "utf8").toString("base64url"); - -export function mcpVarSecretName(input: { -``` -AFTER: -```ts -const encode = (value: string): string => Buffer.from(value, "utf8").toString("base64url"); - -/** Shared prefix of every MCP var secret name — used by the reactor's secret GC (item 10). */ -export const MCP_VAR_SECRET_PREFIX = "mcp-var-"; - -export function mcpVarSecretName(input: { -``` -And switch the literal in the builder. BEFORE: -```ts - const base = `mcp-var-${encode(input.serverId)}-${encode(input.varName)}`; -``` -AFTER: -```ts - const base = `${MCP_VAR_SECRET_PREFIX}${encode(input.serverId)}-${encode(input.varName)}`; -``` -> Produced strings are byte-identical (`MCP_VAR_SECRET_PREFIX === "mcp-var-"`), so existing secret -> files keep matching. Confirmed against the current file. - -### F2. `splitServerVars` preserves kept secrets (item 13) -File `apps/server/src/ru-fork/mcp/McpSecrets.ts`. `splitServerVars` must accept the existing server -vars and, for a draft var with `keepSecret` (or a blank secret value when an existing ref exists), -reuse the existing ref instead of writing a new secret. - -Signature BEFORE (lines 33-36): -```ts -export const splitServerVars = ( - serverId: McpServerId, - draftVars: ReadonlyArray, -): Effect.Effect, SecretStoreError, ServerSecretStore> => -``` -AFTER (add `existingVars`): -```ts -export const splitServerVars = ( - serverId: McpServerId, - draftVars: ReadonlyArray, - existingVars: ReadonlyArray, -): Effect.Effect, SecretStoreError, ServerSecretStore> => -``` -Body BEFORE (lines 37-60): -```ts - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore; - const result: McpServerVar[] = []; - for (const draft of draftVars) { - const base = { - name: draft.name, - secret: draft.secret, - perProject: draft.perProject, - required: draft.required, - }; - if (draft.value === null) { - result.push({ ...base, value: null }); - continue; - } - if (draft.secret) { - const secretName = mcpVarSecretName({ serverId, varName: draft.name }); - yield* secretStore.set(secretName, textEncoder.encode(draft.value)); - result.push({ ...base, value: { secretRef: secretName } }); - } else { - result.push({ ...base, value: draft.value }); - } - } - return result; - }); -``` -AFTER (reuse the existing ref when `keepSecret`): -```ts - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore; - const existingByName = new Map(existingVars.map((variable) => [variable.name, variable])); - const result: McpServerVar[] = []; - for (const draft of draftVars) { - const base = { - name: draft.name, - secret: draft.secret, - perProject: draft.perProject, - required: draft.required, - }; - // Keep an untouched secret: reuse the existing stored ref instead of overwriting. Only valid - // when the existing var is a secret holding a ref (not a per-project hole / plain value). - if (draft.secret && draft.keepSecret === true) { - const previous = existingByName.get(draft.name); - const previousValue = previous?.value; - if (previousValue !== null && typeof previousValue === "object") { - result.push({ ...base, value: previousValue }); - continue; - } - // No prior ref to keep (renamed/new) ⇒ fall through to treat `value` normally. - } - if (draft.value === null) { - result.push({ ...base, value: null }); - continue; - } - if (draft.secret) { - const secretName = mcpVarSecretName({ serverId, varName: draft.name }); - yield* secretStore.set(secretName, textEncoder.encode(draft.value)); - result.push({ ...base, value: { secretRef: secretName } }); - } else { - result.push({ ...base, value: draft.value }); - } - } - return result; - }); -``` -> `previousValue !== null && typeof previousValue === "object"` is the same secret-ref test used by -> `isSecretRef` in this file (no `as` cast; the narrowing makes `previousValue` the ref object). - -### F3. `splitBindingVarValues` preserves kept per-project secrets (item 13) -Same file. The binding split must carry over refs for names listed in `keepVarValues`. - -Signature BEFORE (lines 67-72): -```ts -export const splitBindingVarValues = (input: { - readonly projectId: ProjectId; - readonly serverId: McpServerId; - readonly vars: ReadonlyArray; - readonly draftVarValues: Readonly>; -}): Effect.Effect, SecretStoreError, ServerSecretStore> => -``` -AFTER (add `keepNames` + `existing`): -```ts -export const splitBindingVarValues = (input: { - readonly projectId: ProjectId; - readonly serverId: McpServerId; - readonly vars: ReadonlyArray; - readonly draftVarValues: Readonly>; - /** Names whose existing stored value/ref must be preserved (client left the masked field blank). */ - readonly keepNames: ReadonlyArray; - /** The binding's existing var values, to source the preserved entries from. */ - readonly existing: Readonly>; -}): Effect.Effect, SecretStoreError, ServerSecretStore> => -``` -Body BEFORE (lines 73-93): -```ts - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore; - const secretVarNames = new Set( - input.vars.filter((declared) => declared.secret).map((declared) => declared.name), - ); - const result: Record = {}; - for (const [name, value] of Object.entries(input.draftVarValues)) { - if (secretVarNames.has(name)) { - const secretName = mcpVarSecretName({ - serverId: input.serverId, - varName: name, - projectId: input.projectId, - }); - yield* secretStore.set(secretName, textEncoder.encode(value)); - result[name] = { secretRef: secretName }; - } else { - result[name] = value; - } - } - return result; - }); -``` -AFTER (seed preserved entries first, then apply the draft): -```ts - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore; - const secretVarNames = new Set( - input.vars.filter((declared) => declared.secret).map((declared) => declared.name), - ); - const result: Record = {}; - // Preserve untouched entries (masked secret left blank): carry the existing value/ref over. - for (const name of input.keepNames) { - const previous = input.existing[name]; - if (previous !== undefined) { - result[name] = previous; - } - } - for (const [name, value] of Object.entries(input.draftVarValues)) { - if (secretVarNames.has(name)) { - const secretName = mcpVarSecretName({ - serverId: input.serverId, - varName: name, - projectId: input.projectId, - }); - yield* secretStore.set(secretName, textEncoder.encode(value)); - result[name] = { secretRef: secretName }; - } else { - result[name] = value; - } - } - return result; - }); -``` - -### F4. `resolveBindingVarValues` threads keep-names (items 11, 13) -File `apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts`. `resolveBindingVarValues` calls -`splitBindingVarValues`; it must pass the new args and accept a `keepNames`. - -Signature/body BEFORE (lines 101-117): -```ts -export function resolveBindingVarValues(input: { - readonly patch: Readonly> | undefined; - readonly existing: Readonly>; - readonly vars: ReadonlyArray; - readonly projectId: ProjectId; - readonly serverId: McpServerId; -}): Effect.Effect, SecretStoreError, ServerSecretStore> { - if (input.patch === undefined) { - return Effect.succeed({ ...input.existing }); - } - return splitBindingVarValues({ - projectId: input.projectId, - serverId: input.serverId, - vars: input.vars, - draftVarValues: input.patch, - }); -} -``` -AFTER (accept `keepNames`; pass `existing` + `keepNames` through; when patch is undefined keep all): -```ts -export function resolveBindingVarValues(input: { - readonly patch: Readonly> | undefined; - readonly keepNames: ReadonlyArray | undefined; - readonly existing: Readonly>; - readonly vars: ReadonlyArray; - readonly projectId: ProjectId; - readonly serverId: McpServerId; -}): Effect.Effect, SecretStoreError, ServerSecretStore> { - if (input.patch === undefined) { - return Effect.succeed({ ...input.existing }); - } - return splitBindingVarValues({ - projectId: input.projectId, - serverId: input.serverId, - vars: input.vars, - draftVarValues: input.patch, - keepNames: input.keepNames ?? [], - existing: input.existing, - }); -} -``` - -### F5. Decider passes the new args (items 11, 13) -File `apps/server/src/orchestration/decider.ts`. -`mcp.server-update` BEFORE (lines 776-778): -```ts - const vars = command.patch.vars - ? yield* splitServerVars(command.serverId, command.patch.vars) - : existing.vars; -``` -AFTER (pass `existing.vars` so `keepSecret` can reuse refs): -```ts - const vars = command.patch.vars - ? yield* splitServerVars(command.serverId, command.patch.vars, existing.vars) - : existing.vars; -``` -`mcp.server-add` BEFORE (line 760): -```ts - const vars = yield* splitServerVars(command.serverId, command.draft.vars); -``` -AFTER (no existing vars on add ⇒ empty array; `keepSecret` is a no-op on add): -```ts - const vars = yield* splitServerVars(command.serverId, command.draft.vars, []); -``` -`mcp.binding-set` BEFORE (lines 813-819): -```ts - const varValues = yield* resolveBindingVarValues({ - patch: command.patch.varValues, - existing: existing?.varValues ?? {}, - vars: server.vars, - projectId: command.projectId, - serverId: command.serverId, - }); -``` -AFTER (thread `keepVarValues`): -```ts - const varValues = yield* resolveBindingVarValues({ - patch: command.patch.varValues, - keepNames: command.patch.keepVarValues, - existing: existing?.varValues ?? {}, - vars: server.vars, - projectId: command.projectId, - serverId: command.serverId, - }); -``` -> The duplicate `requireCatalogServer` call (lines 809 & 811) is left exactly as-is — NOT touched -> (out of scope; changing it is forbidden by this plan). - ---- - -## PART G — Web (`apps/web/src/ru-fork/mcp-manage/`) — items 4,5,6,7,12,13,14 - -### G1. UI status type gains `unchecked` + `checking` -File `types.ts`. BEFORE (line 16): -```ts -export type McpStatus = "connected" | "connecting" | "degraded" | "error" | "disabled"; -``` -AFTER: -```ts -export type McpStatus = - | "unchecked" - | "checking" - | "connected" - | "connecting" - | "degraded" - | "error" - | "disabled"; -``` -Add to `McpRegistryServer` a `checking` flag and an `incomplete` flag (catalog default incomplete → -«требует настройки»). BEFORE (the `McpRegistryServer` interface fields, after `status?`): -```ts - /** Catalog-level probe status; undefined until the default config is first probed. */ - readonly status?: McpStatus; - readonly tags: readonly string[]; - readonly docsUrl?: string; -} -``` -AFTER: -```ts - /** Catalog-level probe status; undefined until the default config is first probed. */ - readonly status?: McpStatus; - /** A catalog-default probe is in flight (drives «проверка…» + edit-lock). */ - readonly checking: boolean; - /** The catalog default cannot be probed — a required, defaultless var exists (item 7). */ - readonly incomplete: boolean; - /** Required var names missing a catalog default (tooltip for «требует настройки»). */ - readonly missingVars: readonly string[]; - readonly tags: readonly string[]; - readonly docsUrl?: string; -} -``` -Add `checking` to `McpProjectBinding` — BEFORE (the `status`/`health` fields): -```ts - readonly status: McpStatus; - readonly health: McpHealth; -``` -AFTER: -```ts - readonly status: McpStatus; - /** A probe of this binding is in flight (drives «проверка…» + edit-lock). */ - readonly checking: boolean; - readonly health: McpHealth; -``` - -### G2. `visuals.ts` — entries for the two new statuses -File `visuals.ts`. Add to `STATUS_VISUALS` (BEFORE — the object opens with `connected`): -```ts -const STATUS_VISUALS: Record = { - connected: { -``` -AFTER (prepend `unchecked` and `checking`): -```ts -const STATUS_VISUALS: Record = { - unchecked: { - label: "Не проверено", - textClass: "text-muted-foreground", - dotClass: "bg-zinc-300 dark:bg-zinc-600", - pulse: false, - }, - checking: { - label: "Проверка…", - textClass: "text-sky-600 dark:text-sky-300/90", - dotClass: "bg-sky-500", - pulse: true, - }, - connected: { -``` -> `STATUS_VISUALS` is typed `Record`, so adding the two keys is required by -> the compiler the moment G1 lands — exhaustive by construction. - -### G3. `adapters.ts` — map `checking`, `unchecked`, catalog incomplete, `hasStoredSecret` -File `adapters.ts`. - -(a) `runtimeStatusToUi` gains the unchecked/checking mapping. BEFORE (lines 104-123): -```ts -export function runtimeStatusToUi( - enabled: boolean, - status: McpRuntimeSnapshot["status"] | undefined, -): McpStatus { - if (!enabled) { - return "disabled"; - } - switch (status) { - case "online": - return "connected"; - case "connecting": - case undefined: // bound, but the monitor hasn't reported yet - return "connecting"; - case "degraded": - return "degraded"; - case "offline": - case "error": - return "error"; - } -} -``` -AFTER (add `checking` precedence + `unchecked`): -```ts -export function runtimeStatusToUi( - enabled: boolean, - status: McpRuntimeSnapshot["status"] | undefined, - checking: boolean, -): McpStatus { - if (!enabled) { - return "disabled"; - } - if (checking) { - return "checking"; // a probe is in flight — show «проверка…» over any prior status - } - switch (status) { - case "online": - return "connected"; - case "unchecked": - return "unchecked"; - case "connecting": - case undefined: // bound, but the monitor hasn't reported yet - return "connecting"; - case "degraded": - return "degraded"; - case "offline": - case "error": - return "error"; - } -} -``` - -(b) New helper — required catalog-default vars missing a value (catalog incomplete, item 7). Add -near `computeMissingVars` (which already exists for bindings): -```ts -/** Required vars with no catalog default — the catalog default itself can't be probed (item 7). */ -function catalogMissingVars(vars: ReadonlyArray): string[] { - return vars - .filter((variable) => variable.perProject && variable.required && variable.value === null) - .map((variable) => variable.name); -} -``` - -(c) `catalogServerToRegistry` sets `checking`/`incomplete`/`missingVars`/`status`. BEFORE (lines 85-102): -```ts -export function catalogServerToRegistry( - server: McpCatalogServer, - runtime: McpCatalogRuntimeSnapshot | undefined, -): McpRegistryServer { - return { - id: server.id, - name: server.name, - description: server.description ?? "", - source: server.source, - config: contractConfigToUi(server.config), - vars: server.vars.map(catalogVarToUi), - ...(server.timeoutMs !== null ? { timeoutMs: server.timeoutMs } : {}), - // Live tools + status from the probe of the default config (cwd-independent). - tools: toUiTools(runtime?.discoveredTools ?? []), - ...(runtime ? { status: runtimeStatusToUi(true, runtime.status) } : {}), - // tags/docsUrl are demo-only flair with no backend column — derive a single - // transport tag so existing filters keep working. - tags: [server.config.transport], - }; -} -``` -AFTER: -```ts -export function catalogServerToRegistry( - server: McpCatalogServer, - runtime: McpCatalogRuntimeSnapshot | undefined, -): McpRegistryServer { - const missingVars = catalogMissingVars(server.vars); - const checking = runtime?.checking ?? false; - return { - id: server.id, - name: server.name, - description: server.description ?? "", - source: server.source, - config: contractConfigToUi(server.config), - vars: server.vars.map(catalogVarToUi), - ...(server.timeoutMs !== null ? { timeoutMs: server.timeoutMs } : {}), - // Live tools + status from the probe of the default config (cwd-independent). - tools: toUiTools(runtime?.discoveredTools ?? []), - ...(runtime ? { status: runtimeStatusToUi(true, runtime.status, checking) } : {}), - checking, - incomplete: missingVars.length > 0, - missingVars, - // tags/docsUrl are demo-only flair with no backend column — derive a single - // transport tag so existing filters keep working. - tags: [server.config.transport], - }; -} -``` - -(d) `bindingToUi` sets `checking`, passes it to `runtimeStatusToUi`. BEFORE (the return inside -`bindingToUi`, lines 178-195 in the current file): -```ts -export function bindingToUi( - binding: McpBinding, - server: McpCatalogServer | undefined, - runtime: McpRuntimeSnapshot | undefined, -): McpProjectBinding { - const discovered = runtime?.discoveredTools ?? []; - const discoveredNames = discovered.map((tool) => tool.name); - const missingVars = server ? computeMissingVars(server.vars, binding.varValues) : []; - return { - projectId: binding.projectId, - serverId: binding.serverId, - enabled: binding.enabled, - status: runtimeStatusToUi(binding.enabled, runtime?.status), - health: runtimeHealth(runtime, binding.enabled), - toolOverrides: policyToToolOverrides(binding.toolPolicy, discoveredNames), - // Live tools from the probe — what the UI tool list + counts should reflect. - discoveredTools: toUiTools(discovered), - varValues: bindingVarValuesToUi(binding.varValues), - incomplete: missingVars.length > 0, - missingVars, - ...(binding.timeoutMs !== null ? { timeoutMs: binding.timeoutMs } : {}), - }; -} -``` -AFTER: -```ts -export function bindingToUi( - binding: McpBinding, - server: McpCatalogServer | undefined, - runtime: McpRuntimeSnapshot | undefined, -): McpProjectBinding { - const discovered = runtime?.discoveredTools ?? []; - const discoveredNames = discovered.map((tool) => tool.name); - const missingVars = server ? computeMissingVars(server.vars, binding.varValues) : []; - const checking = runtime?.checking ?? false; - return { - projectId: binding.projectId, - serverId: binding.serverId, - enabled: binding.enabled, - status: runtimeStatusToUi(binding.enabled, runtime?.status, checking), - checking, - health: runtimeHealth(runtime, binding.enabled), - toolOverrides: policyToToolOverrides(binding.toolPolicy, discoveredNames), - // Live tools from the probe — what the UI tool list + counts should reflect. - discoveredTools: toUiTools(discovered), - varValues: bindingVarValuesToUi(binding.varValues), - secretVarNames: bindingSecretVarNames(binding.varValues), - incomplete: missingVars.length > 0, - missingVars, - ...(binding.timeoutMs !== null ? { timeoutMs: binding.timeoutMs } : {}), - }; -} -``` -> `bindingSecretVarNames` is the hoisted helper from G3f (defined later in the file — valid). - -(e) Expose `hasStoredSecret` per var (item 14). Extend the UI `McpVar` (PART G, types) — see G6 — -and set it in `catalogVarToUi`. BEFORE (the existing `catalogVarToUi`): -```ts -/** Contract var → UI var (masks secret values to ""). */ -function catalogVarToUi(variable: ContractVar): McpVar { - return { - name: variable.name, - value: varValueToUi(variable.value), - secret: variable.secret, - perProject: variable.perProject, - required: variable.required, - }; -} -``` -AFTER: -```ts -/** Contract var → UI var (masks secret values to ""; flags whether a secret is already stored). */ -function catalogVarToUi(variable: ContractVar): McpVar { - return { - name: variable.name, - value: varValueToUi(variable.value), - secret: variable.secret, - perProject: variable.perProject, - required: variable.required, - // A secret whose catalog value is a stored ref ⇒ there IS a saved secret (write-only here). - hasStoredSecret: variable.secret && variable.value !== null && typeof variable.value === "object", - }; -} -``` - -(f) Expose per-project stored-secret presence for the project dialog. The binding masks secret values -to "" but we need to know a per-project secret EXISTS. Add to `McpProjectBinding` a -`secretVarNames: readonly string[]` set from the binding's varValues that are secret refs. BEFORE -(`bindingVarValuesToUi`): -```ts -/** Binding var values → UI map (secret values masked to ""). */ -function bindingVarValuesToUi( - varValues: Readonly>, -): Record { - const masked: Record = {}; - for (const [name, value] of Object.entries(varValues)) { - masked[name] = typeof value === "string" ? value : ""; - } - return masked; -} -``` -AFTER (add a companion that lists names with a stored secret ref): -```ts -/** Binding var values → UI map (secret values masked to ""). */ -function bindingVarValuesToUi( - varValues: Readonly>, -): Record { - const masked: Record = {}; - for (const [name, value] of Object.entries(varValues)) { - masked[name] = typeof value === "string" ? value : ""; - } - return masked; -} - -/** Names whose per-project value is a stored secret ref (a saved secret exists for the project). */ -function bindingSecretVarNames( - varValues: Readonly>, -): string[] { - return Object.entries(varValues) - .filter(([, value]) => value !== null && typeof value === "object") - .map(([name]) => name); -} -``` -The `bindingToUi` return already sets `secretVarNames: bindingSecretVarNames(binding.varValues),` -(see G3d AFTER). Corresponding `McpProjectBinding` field is added in G6. - -### G4. `StatusBadge` already reads `statusVisual` — no change. -The new statuses flow through `visuals.ts` automatically. **No edit to `StatusBadge.tsx`.** - -### G5. `useMcp.ts` — pass `checking` everywhere `runtimeStatusToUi` is called is internal to adapters; -`useMcp` only needs the `keepSecret`/`keepVarValues` plumbing for items 13. `addServer`/`updateServer` -already send `vars` via `uiVarsToDraft`; that helper must emit `keepSecret` (see G7). `setProjectBinding` -must send `keepVarValues`. - -`ProjectBindingInput` BEFORE: -```ts -/** Per-project edits the project dialog saves: hole values + timeout override. */ -export interface ProjectBindingInput { - /** Per-project var values (plaintext). A name omitted ⇒ inherit the catalog value. */ - readonly varValues: Readonly>; - /** Timeout override in ms; null clears it (inherit the catalog default). */ - readonly timeoutMs: number | null; -} -``` -AFTER: -```ts -/** Per-project edits the project dialog saves: hole values + timeout override. */ -export interface ProjectBindingInput { - /** Per-project var values (plaintext). A name omitted ⇒ inherit the catalog value. */ - readonly varValues: Readonly>; - /** Per-project secret names left untouched (masked, blank) — preserve their stored ref. */ - readonly keepVarValues: readonly string[]; - /** Timeout override in ms; null clears it (inherit the catalog default). */ - readonly timeoutMs: number | null; -} -``` -`setProjectBinding` BEFORE: -```ts - setProjectBinding: (serverId, projectId, input) => { - dispatchMcpCommand({ - type: "mcp.binding-set", - commandId: newCommandId(), - projectId: ProjectId.make(projectId), - serverId: McpServerId.make(serverId), - patch: { varValues: { ...input.varValues }, timeoutMs: input.timeoutMs }, - }); - }, -``` -AFTER: -```ts - setProjectBinding: (serverId, projectId, input) => { - dispatchMcpCommand({ - type: "mcp.binding-set", - commandId: newCommandId(), - projectId: ProjectId.make(projectId), - serverId: McpServerId.make(serverId), - patch: { - varValues: { ...input.varValues }, - ...(input.keepVarValues.length > 0 ? { keepVarValues: [...input.keepVarValues] } : {}), - timeoutMs: input.timeoutMs, - }, - }); - }, -``` - -### G6. `types.ts` — `McpVar` gains `hasStoredSecret`; `McpProjectBinding` gains `secretVarNames` -BEFORE (the `McpVar` interface): -```ts -export interface McpVar { - readonly name: string; - readonly value: string; - readonly secret: boolean; - readonly perProject: boolean; - readonly required: boolean; -} -``` -AFTER: -```ts -export interface McpVar { - readonly name: string; - readonly value: string; - readonly secret: boolean; - readonly perProject: boolean; - readonly required: boolean; - /** A secret is already stored server-side for this var (value is masked/write-only here). */ - readonly hasStoredSecret: boolean; -} -``` -`McpProjectBinding` — add `secretVarNames` next to `varValues` (BEFORE the `incomplete` field): -```ts - readonly varValues: Readonly>; -``` -AFTER: -```ts - readonly varValues: Readonly>; - /** Per-project var names that already have a stored secret (masked here). */ - readonly secretVarNames: readonly string[]; -``` -> Every place that constructs a UI `McpVar` literal must now set `hasStoredSecret`. Those are: -> `adapters.catalogVarToUi` (G3e — set), `VarsEditor.EMPTY_VAR` (G8 — set false), -> `addMcpParsing.envToVars` (G9 — set false). All three are covered below. - -### G7. `serverConfigForm.ts` / `adapters.uiVarsToDraft` — emit `keepSecret` -The catalog draft path runs through `adapters.uiVarsToDraft`. BEFORE: -```ts -/** UI var rows → inbound draft vars (plaintext; the decider splits secrets → refs). */ -export function uiVarsToDraft(vars: ReadonlyArray): McpServerVarDraft[] { - return vars.map((variable): McpServerVarDraft => { - const value = variable.value.trim(); - return { - name: variable.name, - secret: variable.secret, - perProject: variable.perProject, - // `required` only applies to per-project holes — never carry it otherwise. - required: variable.perProject ? variable.required : false, - // Empty stays null (a per-project hole / "leave the stored secret unchanged" - // is handled by the mutation layer, which drops blank secret values). - value: value.length > 0 ? value : null, - }; - }); -} -``` -AFTER (a secret left blank but already stored ⇒ keepSecret, value null): -```ts -/** UI var rows → inbound draft vars (plaintext; the decider splits secrets → refs). */ -export function uiVarsToDraft(vars: ReadonlyArray): McpServerVarDraft[] { - return vars.map((variable): McpServerVarDraft => { - const value = variable.value.trim(); - // A secret with a stored value that the user left blank ⇒ keep the existing ref (don't wipe). - const keepSecret = variable.secret && variable.hasStoredSecret && value.length === 0; - return { - name: variable.name, - secret: variable.secret, - perProject: variable.perProject, - // `required` only applies to per-project holes — never carry it otherwise. - required: variable.perProject ? variable.required : false, - ...(keepSecret ? { keepSecret: true } : {}), - // Empty stays null; when keepSecret is set the decider ignores `value` and reuses the ref. - value: value.length > 0 ? value : null, - }; - }); -} -``` -> Import in `adapters.ts`: `McpServerVarDraft` is already imported. No new import. - -### G8. `VarsEditor.tsx` — `EMPTY_VAR` sets `hasStoredSecret: false`; saved-secret affordance (item 14) -BEFORE: -```ts -const EMPTY_VAR: McpVar = { - name: "", - value: "", - secret: false, - perProject: false, - required: false, -}; -``` -AFTER: -```ts -const EMPTY_VAR: McpVar = { - name: "", - value: "", - secret: false, - perProject: false, - required: false, - hasStoredSecret: false, -}; -``` -Secret value input — show the saved-secret placeholder + `(i)`. BEFORE (the value `Input` inside the -row, current placeholder logic): -```tsx - update(index, { value: event.target.value })} - placeholder={ - variable.perProject && variable.value.length === 0 - ? "значение по умолчанию" - : variable.secret - ? "значение секрета" - : "значение" - } - aria-label="Значение переменной" - className="min-w-0 flex-1 font-mono" - /> -``` -AFTER (saved secret ⇒ «сохранён» placeholder so blank-means-keep is legible): -```tsx - update(index, { value: event.target.value })} - placeholder={ - variable.secret && variable.hasStoredSecret && variable.value.length === 0 - ? "сохранён — пусто оставит без изменений" - : variable.perProject && variable.value.length === 0 - ? "значение по умолчанию" - : variable.secret - ? "значение секрета" - : "значение" - } - aria-label="Значение переменной" - className="min-w-0 flex-1 font-mono" - /> -``` -> Editing a var's name flips `hasStoredSecret` semantics: when the user changes a saved secret's -> name, the stored ref keys by the OLD name and won't be kept (decider F2: no prior ref under the -> new name ⇒ value required). We surface that in McpServerDialog warnings (G11) rather than per-row, -> to keep the row compact. **Decision:** the per-row red border is reserved for the project dialog -> (G10) where required-empty is actionable; the catalog editor uses the warnings block (G11). - -### G9. `addMcpParsing.ts` — `envToVars` sets `hasStoredSecret: false` -BEFORE (`envToVars`): -```ts -function envToVars(env: Record): McpVar[] { - return Object.entries(env).map(([name, value]) => ({ - name, - value, - secret: false, - perProject: false, - required: false, - })); -} -``` -AFTER: -```ts -function envToVars(env: Record): McpVar[] { - return Object.entries(env).map(([name, value]) => ({ - name, - value, - secret: false, - perProject: false, - required: false, - hasStoredSecret: false, - })); -} -``` - -### G10. `ProjectConfigDialog.tsx` — SecretField states + keepVarValues + edit-lock (items 13, 14, 6) -This dialog gains: (a) red `aria-invalid` border on a required, empty, no-default, no-stored-secret -hole; (b) "сохранён" affordance for per-project secrets; (c) building `keepVarValues` on save; -(d) being disabled while the binding is checking (item 6 — handled at the trigger in -`ProjectBindingRow`, G12, but the dialog also early-returns if opened while checking). - -Add a new shared component `SecretAwareInput` — see G13 — and use it for each var row. - -`perProjectVars` and `initialValues` unchanged. `handleSave` BEFORE: -```ts - function handleSave() { - setError(null); - const timeout = parseTimeout(timeoutText); - if (!timeout.ok) { - setError(timeout.error); - return; - } - // Empty fields are omitted → the binding inherits the catalog value for that hole. - const varValues: Record = {}; - for (const variable of vars) { - const value = (values[variable.name] ?? "").trim(); - if (value.length > 0) { - varValues[variable.name] = value; - } - } - setProjectBinding(server.id, binding.projectId, { - varValues, - timeoutMs: timeout.timeoutMs ?? null, - }); - setOpen(false); - } -``` -AFTER (build `keepVarValues` for untouched stored per-project secrets): -```ts - function handleSave() { - setError(null); - const timeout = parseTimeout(timeoutText); - if (!timeout.ok) { - setError(timeout.error); - return; - } - const storedSecretNames = new Set(binding.secretVarNames); - // Empty fields are omitted → the binding inherits the catalog value for that hole. - const varValues: Record = {}; - const keepVarValues: string[] = []; - for (const variable of vars) { - const value = (values[variable.name] ?? "").trim(); - if (value.length > 0) { - varValues[variable.name] = value; - } else if (variable.secret && storedSecretNames.has(variable.name)) { - // Masked secret left blank ⇒ preserve the stored per-project ref instead of clearing it. - keepVarValues.push(variable.name); - } - } - setProjectBinding(server.id, binding.projectId, { - varValues, - keepVarValues, - timeoutMs: timeout.timeoutMs ?? null, - }); - setOpen(false); - } -``` -The var row `Input` BEFORE: -```tsx - - setValues((prev) => ({ ...prev, [variable.name]: event.target.value })) - } - placeholder={ - variable.secret - ? "значение секрета" - : hasDefault - ? `по умолчанию: ${variable.value}` - : "значение" - } - className="mt-1.5 font-mono" - /> -``` -AFTER (use `SecretAwareInput`; compute the missing/red state): -```tsx - - setValues((prev) => ({ ...prev, [variable.name]: next })) - } - placeholder={ - variable.secret - ? "значение секрета" - : hasDefault - ? `по умолчанию: ${variable.value}` - : "значение" - } - className="mt-1.5" - /> -``` -Add, inside the component body before `return`, the stored-secret set used above: -```tsx - const storedSecretNames = new Set(binding.secretVarNames); -``` -Import added to `ProjectConfigDialog.tsx`: -`import { SecretAwareInput } from "./SecretAwareInput";` and **remove** the now-unused -`import { Input } from "~/components/ui/input";` (verify `Input` is not used elsewhere in the file -after the swap — it is not; STOP if it is). - -### G11. `McpServerDialog.tsx` — warn-on-impact (item 12) + edit-lock (item 6) -(a) **Edit-lock (item 6):** the dialog is opened from a trigger; disable the trigger when the -catalog server is checking. Implemented at the call sites (RegistryDetail pencil — G14) by passing -`disabled={server.checking}` to the trigger button. **No structural change inside McpServerDialog for -the lock** beyond a guard: if opened while checking, show a notice and disable Save. Add near the top -of the render, before the footer — a derived flag from props is not available (the dialog only has -`server`); add to the dialog a `checking` read. **Decision:** pass `server.checking` via the existing -`server` prop (already includes `checking` after G1) and guard: - -`handleSubmit` BEFORE (first lines): -```ts - function handleSubmit() { - setError(null); - if (!name.trim()) { - setError("Укажите имя сервера."); - return; - } -``` -AFTER (block save while a probe runs): -```ts - function handleSubmit() { - setError(null); - if (server?.checking) { - setError("Идёт проверка сервера — дождитесь её завершения."); - return; - } - if (!name.trim()) { - setError("Укажите имя сервера."); - return; - } -``` - -(b) **Warn-on-impact (item 12):** before applying an edit that removes a per-project var used by -bindings, or adds a required hole, confirm. Compute impact from the live bindings. - -Add a hook usage at the top of the component (it currently uses `useMcpMutations`): -BEFORE: -```ts - const { addServer, updateServer } = useMcpMutations(); - const isEditing = server !== undefined; -``` -AFTER: -```ts - const { addServer, updateServer } = useMcpMutations(); - const bindings = useMcpProjectBindings(); - const isEditing = server !== undefined; - const [impactWarning, setImpactWarning] = useState(null); -``` -Add the impact computation + a confirm gate inside `handleSubmit`, AFTER the `resolved` success -check and BEFORE the `updateServer`/`addServer` dispatch. BEFORE (current tail of `handleSubmit`): -```ts - const input = { - name: name.trim(), - description: description.trim(), - config: resolved.config, - vars: resolved.vars, - ...(resolved.timeoutMs !== undefined ? { timeoutMs: resolved.timeoutMs } : {}), - }; - if (isEditing) { - updateServer(server.id, input); - } else { - addServer(input); - } - setOpen(false); - } -``` -AFTER: -```ts - const input = { - name: name.trim(), - description: description.trim(), - config: resolved.config, - vars: resolved.vars, - ...(resolved.timeoutMs !== undefined ? { timeoutMs: resolved.timeoutMs } : {}), - }; - if (isEditing && impactWarning === null) { - const warning = describeEditImpact(server, resolved.vars, bindings); - if (warning !== null) { - setImpactWarning(warning); // show the confirm banner; a second Save (now armed) proceeds - return; - } - } - if (isEditing) { - updateServer(server.id, input); - } else { - addServer(input); - } - setOpen(false); - } -``` -Add the pure impact helper to `serverConfigForm.ts` (so it is unit-described and reused): -```ts -import type { McpProjectBinding, McpServerConfig, McpTransport, McpVar } from "../types"; -``` -(append `McpProjectBinding` to the existing type import in `serverConfigForm.ts`) and add: -```ts -/** - * Human warning if a catalog edit will strand per-project data: a per-project var removed while - * bindings hold a value for it, or a new required hole that forces N projects to be configured. - * Returns null when the edit is non-disruptive. - */ -export function describeEditImpact( - server: { readonly id: string; readonly vars: readonly McpVar[] }, - nextVars: readonly McpVar[], - bindings: readonly McpProjectBinding[], -): string | null { - const nextNames = new Set(nextVars.map((variable) => variable.name)); - const removedPerProject = server.vars - .filter((variable) => variable.perProject && !nextNames.has(variable.name)) - .map((variable) => variable.name); - const serverBindings = bindings.filter((binding) => binding.serverId === server.id); - const messages: string[] = []; - for (const name of removedPerProject) { - const affected = serverBindings.filter((binding) => name in binding.varValues).length; - if (affected > 0) { - messages.push(`Значения «${name}» будут удалены в ${affected} проект(ах).`); - } - } - const newRequired = nextVars.filter( - (variable) => - variable.perProject && - variable.required && - variable.value.trim().length === 0 && - !server.vars.some((existing) => existing.name === variable.name), - ); - if (newRequired.length > 0 && serverBindings.length > 0) { - messages.push( - `Новое обязательное поле — ${serverBindings.length} проект(ов) нужно будет настроить.`, - ); - } - return messages.length > 0 ? messages.join(" ") : null; -} -``` -Render the warning banner + change the Save button label when armed. Add to the dialog body, just -above the existing `{error && ...}` line: -```tsx - {impactWarning && ( -
-

{impactWarning}

-

Нажмите «Сохранить» ещё раз, чтобы применить.

-
- )} -``` -`reset()` must also clear `impactWarning`. BEFORE (the `reset` body tail): -```ts - setAdvancedJson(""); - setError(null); - } -``` -AFTER: -```ts - setAdvancedJson(""); - setError(null); - setImpactWarning(null); - } -``` -Imports added to `McpServerDialog.tsx`: -- add `useMcpProjectBindings` to the existing `useMcp` import: - `import { useMcpMutations, useMcpProjectBindings } from "../useMcp";` -- add `describeEditImpact` to the existing `serverConfigForm` import. - -### G12. `ProjectBindingRow.tsx` — edit-lock during check (item 6) + checking status -The status already flows through `binding.status` (now `"checking"` when probing). Lock the -per-project config trigger while checking. BEFORE (the `ProjectConfigDialog` trigger button): -```tsx - - - - } - /> -``` -AFTER (disable while checking): -```tsx - - - - } - /> -``` - -### G13. New component `SecretAwareInput.tsx` (item 14) -New file `apps/web/src/ru-fork/mcp-manage/components/SecretAwareInput.tsx`. Uses the native `title` -attribute for the saved-secret hint (the repo's `tooltip.tsx` exports `Tooltip`/`TooltipTrigger`/ -`TooltipPopup` and needs a `TooltipProvider` ancestor — a native `title` avoids that dependency and -is sufficient for a one-line hint): -```tsx -import { CheckIcon } from "lucide-react"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from "~/components/ui/input-group"; - -/** - * One value field that knows about masked secrets. Three visual states: - * - saved secret, untouched (`hasStoredSecret`, empty value): a lock-check «сохранён» addon - * (native-title hint), no red border; - * - needs a value (`invalid`, empty): a destructive (red) border via `aria-invalid`; - * - editing: a plain field. Secrets render as a password input. - */ -export function SecretAwareInput({ - id, - value, - secret, - hasStoredSecret = false, - invalid = false, - onChange, - placeholder, - className, -}: { - readonly id?: string; - readonly value: string; - readonly secret: boolean; - readonly hasStoredSecret?: boolean; - readonly invalid?: boolean; - readonly onChange: (next: string) => void; - readonly placeholder?: string; - readonly className?: string; -}) { - const showSaved = secret && hasStoredSecret && value.length === 0; - return ( - - onChange(event.target.value)} - placeholder={placeholder} - className="font-mono" - /> - {showSaved && ( - - - - сохранён - - - )} - - ); -} -``` -> Confirmed exports used: `InputGroup`, `InputGroupAddon`, `InputGroupInput`, `InputGroupText` (all in -> `input-group.tsx`). `aria-invalid` triggers the destructive border (`has-[input[aria-invalid]]`). -> `InputGroupText` is a `` (accepts `title`). No tooltip component imported. - -### G14. `RegistryDetail.tsx` — lock the edit pencil while the catalog server is checking (item 6) -BEFORE (the `McpServerDialog` trigger pencil button): -```tsx - - - - } - /> -``` -AFTER: -```tsx - - - - } - /> -``` -> The catalog list (`RegistryTab.tsx`) also renders an add `McpServerDialog` (new server, no -> `server` prop ⇒ `server?.checking` is undefined ⇒ never locked). No edit there to lock. Confirm -> during impl that `RegistryTab` only hosts the ADD dialog; if it also hosts an edit pencil, apply -> the same `disabled` (amendment, STOP). - ---- - -## PART H — Tests - -### H1. `apps/server/tests/ru-fork/mcp/mcpCore.test.ts` — unchanged logic, no new cases required -The pure `@ru-fork/mcp-core` API is **not** changed by this plan (no resolver/cacheKey/fingerprint -signature change). **No edit.** (`configCacheKey`, `dedupHash`, `missingRequiredVars` all unchanged.) - -### H2. Supervisor unit tests — new file `apps/server/tests/ru-fork/mcp/supervisorDue.test.ts` -Covers the pure due-logic changes (items 1, 2). Uses the exported `isSweepDue` / `isProbeDue` / -`SupervisorInstance` — all from `McpSupervisor.ts` (single import). Full file: -```ts -// ru-fork: pure due-gate tests for the monitoring redesign — never-probed is NEVER auto-due -// (no probing on load), and 0 intervals disable re-checks. -import { - isProbeDue, - isSweepDue, - type SupervisorInstance, -} from "../../../src/ru-fork/mcp/McpSupervisor.ts"; -import { describe, expect, it } from "vitest"; - -const base = (over: Partial): SupervisorInstance => ({ - hash: "h", - configKey: "k", - resolved: { transport: "http", httpUrl: "https://x", headers: {} }, - refs: new Set(["p:s"]), - status: "unchecked", - message: null, - latencyMs: null, - checkedAt: null, - checkedAtMs: null, - discoveredTools: [], - consecutiveFailures: 0, - ...over, -}); - -describe("isProbeDue", () => { - it("never-probed is NOT due (no probing on load)", () => { - expect(isProbeDue(base({ checkedAtMs: null }), 1_000_000, 60_000, 60_000)).toBe(false); - }); - it("0 interval ⇒ not due even after time", () => { - expect(isProbeDue(base({ checkedAtMs: 0 }), 10_000_000, 0, 0)).toBe(false); - }); - it("probed + interval elapsed ⇒ due", () => { - expect(isProbeDue(base({ checkedAtMs: 0 }), 120_000, 0, 60_000)).toBe(true); - }); -}); - -describe("isSweepDue", () => { - it("never-probed is excluded", () => { - expect(isSweepDue(base({ checkedAtMs: null }), null, 1, 60_000, 60_000)).toBe(false); - }); - it("probed but outside watched scope ⇒ not due", () => { - expect( - isSweepDue(base({ checkedAtMs: 0, refs: new Set(["p:s"]) }), new Set(["other"]), 999_999_999, 1, 1), - ).toBe(false); - }); -}); -``` -> `isProbeDue`, `isSweepDue`, and `SupervisorInstance` are all exported from `McpSupervisor.ts` -> (confirmed). `ResolvedServerConfig` allows the http shape `{ transport, httpUrl, headers }` used in -> `base` (command/args/env/cwd/timeoutMs all optional — confirmed in `resolver.ts`). - -### H3. Decider keep-secret test — extend the existing decider/secrets test (if present) or add -`apps/server/tests/ru-fork/mcp/secretsKeep.test.ts`. Full file: -```ts -// ru-fork: splitServerVars / splitBindingVarValues preserve untouched secrets (item 13). -import type { McpServerVar } from "@t3tools/contracts"; -import { McpServerId, ProjectId } from "@t3tools/contracts"; -import { splitBindingVarValues, splitServerVars } from "../../../src/ru-fork/mcp/McpSecrets.ts"; -import { ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { describe, expect, it } from "vitest"; - -const store = new Map(); -const FakeSecretStore = Layer.succeed(ServerSecretStore, { - get: (name) => Effect.succeed(store.get(name) ?? null), - set: (name, value) => Effect.sync(() => void store.set(name, value)), - getOrCreateRandom: () => Effect.succeed(new Uint8Array()), - remove: (name) => Effect.sync(() => void store.delete(name)), - pruneByPrefix: () => Effect.void, -}); - -const run = (effect: Effect.Effect): Promise => - Effect.runPromise(effect.pipe(Effect.provide(FakeSecretStore))); - -describe("splitServerVars keepSecret", () => { - it("reuses the existing ref when keepSecret is set and value is blank", async () => { - const existing: ReadonlyArray = [ - { name: "TOKEN", secret: true, perProject: false, required: false, value: { secretRef: "mcp-var-old" } }, - ]; - const result = await run( - splitServerVars( - McpServerId.make("srv-x"), - [{ name: "TOKEN", secret: true, perProject: false, required: false, value: null, keepSecret: true }], - existing, - ), - ); - expect(result[0]?.value).toEqual({ secretRef: "mcp-var-old" }); - }); - it("re-splits when a new value is provided", async () => { - const result = await run( - splitServerVars( - McpServerId.make("srv-x"), - [{ name: "TOKEN", secret: true, perProject: false, required: false, value: "new" }], - [], - ), - ); - expect(typeof result[0]?.value === "object" && result[0]?.value !== null).toBe(true); - }); -}); - -describe("splitBindingVarValues keepNames", () => { - it("preserves an existing per-project secret ref when kept", async () => { - const result = await run( - splitBindingVarValues({ - projectId: ProjectId.make("p1"), - serverId: McpServerId.make("srv-x"), - vars: [{ name: "SPACE", secret: true, perProject: true, required: true, value: null }], - draftVarValues: {}, - keepNames: ["SPACE"], - existing: { SPACE: { secretRef: "mcp-var-srv-x-SPACE-p1" } }, - }), - ); - expect(result["SPACE"]).toEqual({ secretRef: "mcp-var-srv-x-SPACE-p1" }); - }); -}); -``` -> **Plan-verification note:** confirm `ServerSecretStore`'s `Context.Service` allows -> `Layer.succeed(ServerSecretStore, {...})` with this exact shape (the new `pruneByPrefix` member is -> required by the interface, included above). If the service tag requires a class instance instead -> of a plain object, switch to `Layer.succeed(ServerSecretStore, ServerSecretStore.of({...}))` — -> that exact change is a pre-approved amendment. - -### H4. Secret-store prune — **no dedicated test (deliberate)** -`pruneByPrefix` is best-effort GC over the real `NodeFileSystem` + `ServerConfig` (heavy to stand up -in `test:fast`, and it swallows errors by design). It is validated by: (a) typecheck of the new -interface member + Live implementation, (b) the H3 fake which includes `pruneByPrefix` in the service -shape (so the shape stays correct), and (c) the reactor integration at runtime. **This is an explicit, -logged scope boundary — not a silent gap.** No file is written for H4. - -### H5. Reactor behavior is integration-heavy (engine + projections). **No new reactor unit test** — -the eager-probe / GC / prune paths are covered by typecheck + the existing reactor test -(`supervisorDecisions.test.ts`, if it exercises reconcile) and manual run. Logged as a deliberate -scope boundary, not an oversight: a full reactor harness (engine + DB + supervisor) is out of -`test:fast` scope. - ---- - -## PART I — Gates & sequencing - -Implementation order (each step compiles before the next; commit at the checkpoints): -1. **Checkpoint commit** the already-green UI rework currently uncommitted (no behavior in this plan - depends on it staying uncommitted). -2. PART A (contracts) → PART B (secret store) → PART F1 (prefix) — pure additions, compile first. -3. PART C (supervisor) → H2 test. -4. PART E (overlay state + reactor + spawn) — race fix. -5. PART D (reactor eager probe + GC + prune). -6. PART F2–F5 (secrets/decider keep-secret) → H3 test. -7. PART G (web) — statuses, checking, locks, SecretField, warn. -8. Gates: `pnpm typecheck` (10/10), `pnpm lint` (0/0), `pnpm test:fast` (only 4 preexisting - `bin.test.ts`). Commit green. - -Every step ends green or it is a STOP. - ---- - -## PART J — Extra features added (gaps I found; flagged for your approval) - -These are NOT in your 14 but are required for the 14 to be correct/consistent. They are part of the -plan above; listed here so you can veto: - -- **J1 (in A2/A3/G):** `checking` is a first-class snapshot field, not derived — needed so a server - can be "online **and** re-checking" (your item 5) without flicker to "connecting". -- **J2 (in C2/G1):** `"unchecked"` is a real status end-to-end (contract → supervisor → UI), so item - 4's neutral state survives reload and is distinct from "connecting". -- **J3 (in G3/G1):** catalog-tile **incomplete** (`«требует настройки»`) for a catalog server whose - default can't be probed (required defaultless var) — item 7 applied to the Каталог tab, not just - bindings. Without it the catalog tile would sit on "не проверено" forever with no explanation. -- **J4 (in D4/F):** orphaned-`varValues` prune is **ref-preserving** (rides `mcp.binding-set` + - `keepVarValues`) so it never needs plaintext and never wipes a surviving secret — the only correct - way to implement item 11 given write-only secrets. -- **J5 (in G11):** warn-on-impact uses a **two-click confirm** (banner → Save again) instead of a - nested AlertDialog, to avoid a dialog-in-dialog focus trap. If you prefer a modal AlertDialog, - that is a one-component swap (amendment). -- **J6 (proposed, NOT yet in the plan body):** a manual **"Проверить всё"** action in the panel - header that calls `recheck({})` — with the loop now opt-in (item 1), a global manual sweep is the - natural way to refresh everything on demand. Say the word and I add the exact edit (RecheckButton - already supports an empty filter). - ---- - -## Item coverage matrix (final check — every requirement has a concrete edit) - -| # | Concrete edits | -|---|---| -| 1 | C6 (both-0 early-out) | -| 2 | C1 (isSweepDue/isProbeDue never-probed→false), D5 (startup reconcile not eager) | -| 3 | C3 (reconcile returns added), C4 (probeHashes), D1/D2/D5 (eager wiring) | -| 4 | A1 (unchecked), C2 (register unchecked), G1/G2/G3 (UI status) | -| 5 | A2/A3 (checking field), C5 (publish on probe start + currentInFlight), C7 (McpRuntime build of checking), G1/G2/G3 | -| 6 | G11a (dialog guard), G12 (binding trigger disabled), G14 (catalog pencil disabled) | -| 7 | computeDesired skip (existing) + G3b/G3c (catalog incomplete UI) + G1 | -| 8 | E1/E2/E4 (shared fingerprint seeded at spawn) | -| 9 | E3 (reactor restarts when a live session exists, via shared fingerprint) | -| 10 | B1/B2 (pruneByPrefix), F1 (prefix), D3 (gcOrphanedSecrets) | -| 11 | A5 (keepVarValues), D4 (prune dispatch), F3/F4/F5 (ref-preserving) | -| 12 | G11b (describeEditImpact + banner) | -| 13 | A4/A5 (keepSecret/keepVarValues), F2/F3/F4/F5 (decider preserve), G5/G7/G10 (UI emit) | -| 14 | A4 (hasStoredSecret source), G6 (types), G3e/G3f (adapter), G8/G10/G13 (SecretField) | - -## Plan status: **READY** — all edits specified, all five earlier open items closed -1. ✅ **McpRuntime `checking` build** — written as PART C7 (exact before/after, both snapshot kinds). -2. ✅ **Layer wiring (E2/E4)** — `McpOverlayStateLive` rides `McpOverlayLive`'s existing `provideMerge` - exposure; confirmed reachable by `ProviderCommandReactor`; no orchestration-layer edit. -3. ✅ **H4** — deliberately no dedicated FS test (best-effort GC); logged, not silent. -4. ✅ **G13** — `SecretAwareInput` uses confirmed `input-group` exports + native `title` (no tooltip - dependency). -5. ✅ **F1** — exact literal site in `McpSecretNames.ts` confirmed; before/after given. - -### New-files checklist (created by this plan) -- `apps/server/src/ru-fork/mcp/McpOverlayState.ts` (E1) -- `apps/web/src/ru-fork/mcp-manage/components/SecretAwareInput.tsx` (G13) -- `apps/server/tests/ru-fork/mcp/supervisorDue.test.ts` (H2) -- `apps/server/tests/ru-fork/mcp/secretsKeep.test.ts` (H3) - -### Files edited (with the imports each gains) -- `packages/contracts/src/ru-fork/mcp.ts` — no new imports (Schema in scope). -- `apps/server/src/auth/Services/ServerSecretStore.ts` — no new imports. -- `apps/server/src/auth/Layers/ServerSecretStore.ts` — no new imports. -- `apps/server/src/ru-fork/mcp/McpSupervisor.ts` — no new imports. -- `apps/server/src/ru-fork/mcp/McpRuntime.ts` — no new imports. -- `apps/server/src/ru-fork/mcp/McpReactor.ts` — `+ collectVarSecretRefs` (McpSecrets), - `+ MCP_VAR_SECRET_PREFIX` (McpSecretNames), `+ McpOverlayState` (McpOverlayState); **− `import * as - Ref`** (now unused after removing `overlayFingerprintsRef` — verify no other `Ref.` use; STOP if any). -- `apps/server/src/ru-fork/mcp/McpLayers.ts` — `+ McpOverlayStateLive`. -- `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` — `+ McpOverlayState`. -- `apps/server/src/ru-fork/mcp/McpSecretNames.ts` — no new imports. -- `apps/server/src/ru-fork/mcp/McpSecrets.ts` — no new imports. -- `apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts` — no new imports. -- `apps/server/src/ru-fork/mcp/McpDefaults.ts` — untouched by this plan. -- `apps/server/src/orchestration/decider.ts` — no new imports (signatures unchanged at call sites). -- `apps/web/.../types.ts`, `visuals.ts`, `adapters.ts`, `useMcp.ts`, `serverConfigForm.ts`, - `addMcpParsing.ts` — imports listed in each section (G6/G2/G3/G5/G11/G9). -- `apps/web/.../components/VarsEditor.tsx`, `ProjectConfigDialog.tsx`, `McpServerDialog.tsx`, - `ProjectBindingRow.tsx`, `RegistryDetail.tsx` — imports listed in each section. - -### Residual STOP-conditions to verify at implementation time (read-only checks, no latitude) -- McpReactor: confirm `Ref` is used **only** by `overlayFingerprintsRef`; if so its import line is - removed, else kept. (Grep at impl.) -- ProjectConfigDialog: confirm `Input` is unused after the `SecretAwareInput` swap; remove its import. -- `ServerSecretStore` Live: confirm the exact `return { ... }` member list before appending - `pruneByPrefix`. -- H3: if `Context.Service` rejects a bare object in `Layer.succeed`, use `ServerSecretStore.of({...})`. -Each is a confirm-then-apply with the exact fallback already named here — no improvisation. - ---- ---- - -# PART K — Managed built-ins / templates + config-model deltas (REVISION 2) - -> Implements D-builtins, D-config-model, D-fork-removed. Replaces `McpDefaults.ts` + -> `seedBuiltinsIfEmpty` + the fork-to-custom concept. Built on the locked design in REVISION 2. -> **Supersedes:** PART A4 stays; PART F5's `mcp.server-add` `splitServerVars(...,[])` and the -> `mcp.server-update` `splitServerVars(...,existing.vars)` calls are extended here (origin); the -> `applyServerUpdate` `source:"builtin"→"custom"` fork line is **removed** (K5). - -## K1. Contract deltas (`packages/contracts/src/ru-fork/mcp.ts`) - -### K1a. Var `origin` (shipped vs user) — enables the migrator 3-way merge -`McpServerVar` BEFORE: -```ts -export const McpServerVar = Schema.Struct({ - name: TrimmedNonEmptyString, - secret: Schema.Boolean, - perProject: Schema.Boolean, - required: Schema.Boolean, - value: Schema.NullOr(McpVarValue), -}); -export type McpServerVar = typeof McpServerVar.Type; -``` -AFTER (add `origin`; legacy/manual JSON without it decodes as `"user"`): -```ts -// shipped = declared by a built-in template (replaced wholesale on a template update); -// user = added by the user (preserved across updates). Manual servers: all vars are "user". -export const McpServerVarOrigin = Schema.Literals(["shipped", "user"]); -export type McpServerVarOrigin = typeof McpServerVarOrigin.Type; - -export const McpServerVar = Schema.Struct({ - name: TrimmedNonEmptyString, - secret: Schema.Boolean, - perProject: Schema.Boolean, - // "must resolve to a non-empty value" at ANY level: a catalog-level required var with no value - // makes the CATALOG server incomplete; a per-project required var with no value makes the BINDING - // incomplete. (Widened from "only meaningful when perProject".) - required: Schema.Boolean, - value: Schema.NullOr(McpVarValue), - origin: McpServerVarOrigin.pipe(Schema.withDecodingDefault(() => "user")), -}); -export type McpServerVar = typeof McpServerVar.Type; -``` -> The comment in lines 53-59 (`// required (only meaningful when perProject)`) is updated to the -> widened meaning (drop "only meaningful when perProject"). `withDecodingDefault` needs no new import -> (it's `Schema.*`); pattern mirrors `settings.ts` (`Schema.withDecodingDefault(Effect.succeed(false))`) -> — **but** `settings.ts` passes an `Effect`. **Exact form to use:** `Schema.withDecodingDefault(() => -> "user")` if the thunk overload exists; else `Schema.withDecodingDefault(Effect.succeed("user"))` -> and add `import * as Effect from "effect/Effect";` to mcp.ts. **STOP-check at impl:** confirm which -> overload `effect/Schema` beta.59 exposes; use the thunk if available (no Effect import), else the -> Effect form (with the import). Both are pre-approved. - -### K1b. `McpServerVarDraft` — user drafts carry no origin (always "user" at split time) -No change to `McpServerVarDraft` for origin (the decider's `splitServerVars` stamps `origin:"user"`). -`keepSecret` is added by A4 (kept). - -### K1c. `McpCatalogServer` — builtin identity + lock + extraArgs -BEFORE: -```ts -export const McpCatalogServer = Schema.Struct({ - id: McpServerId, - name: TrimmedNonEmptyString, - description: Schema.NullOr(TrimmedNonEmptyString), - source: McpServerSource, - config: McpServerConfig, // pure template - vars: Schema.Array(McpServerVar), - timeoutMs: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000))), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -``` -AFTER: -```ts -export const McpCatalogServer = Schema.Struct({ - id: McpServerId, - name: TrimmedNonEmptyString, - description: Schema.NullOr(TrimmedNonEmptyString), - source: McpServerSource, - config: McpServerConfig, // pure template (command/args LOCKED for templates) - vars: Schema.Array(McpServerVar), - // ru-fork: user-appendable args (with `${VAR}` holes), appended after `config.args`. Empty for - // manual servers (their whole command is editable). The escape hatch for a LOCKED template. - extraArgs: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), - // ru-fork: managed-built-in identity. builtinId = the stable hidden reconciliation key (null for - // user-created/manual). builtinHash = content hash of the shipped definition last applied (drives - // "shipped template changed → update"). locked = command/args are read-only (a template). - builtinId: Schema.NullOr(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(() => null)), - builtinHash: Schema.NullOr(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(() => null)), - locked: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), - timeoutMs: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000))), - createdAt: IsoDateTime, - updatedAt: IsoDateTime, -}); -``` - -### K1d. Drafts/patches gain `extraArgs` (user-editable) -`McpServerDraft` BEFORE: -```ts -export const McpServerDraft = Schema.Struct({ - name: TrimmedNonEmptyString, - description: Schema.optional(TrimmedNonEmptyString), - config: McpServerConfig, - vars: Schema.Array(McpServerVarDraft), - timeoutMs: McpTimeoutMsDraft, -}); -``` -AFTER (manual add carries the full editable command + optional extraArgs): -```ts -export const McpServerDraft = Schema.Struct({ - name: TrimmedNonEmptyString, - description: Schema.optional(TrimmedNonEmptyString), - config: McpServerConfig, - vars: Schema.Array(McpServerVarDraft), - extraArgs: Schema.optionalKey(Schema.Array(Schema.String)), - timeoutMs: McpTimeoutMsDraft, -}); -``` -`McpServerDraftPatch` BEFORE: -```ts -export const McpServerDraftPatch = Schema.Struct({ - name: Schema.optionalKey(TrimmedNonEmptyString), - description: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), - config: Schema.optionalKey(McpServerConfig), - vars: Schema.optionalKey(Schema.Array(McpServerVarDraft)), - timeoutMs: Schema.optionalKey(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)))), -}); -``` -AFTER (extraArgs editable; config/vars patch is REJECTED for locked templates in the decider — K5): -```ts -export const McpServerDraftPatch = Schema.Struct({ - name: Schema.optionalKey(TrimmedNonEmptyString), - description: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), - config: Schema.optionalKey(McpServerConfig), - vars: Schema.optionalKey(Schema.Array(McpServerVarDraft)), - extraArgs: Schema.optionalKey(Schema.Array(Schema.String)), - timeoutMs: Schema.optionalKey(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000)))), -}); -``` - -### K1e. The builtin-sync command + event (migrator → decider, NO fork, carries identity) -Add a new command/event so the migrator can add/update a managed built-in without going through the -user draft path (which would fork / re-split secrets). Built-ins ship **declarations only** (secret -vars have `value:null`), so no secret-store interaction at sync time. Add near the other command -payload helpers (after `McpBindingRemovedPayload`): -```ts -// ru-fork: migrator → catalog. A managed built-in's shipped definition (platform-resolved, -// command LOCKED). vars carry origin:"shipped" with value:null for secrets (no plaintext shipped). -export const McpBuiltinSyncInput = Schema.Struct({ - serverId: McpServerId, - builtinId: TrimmedNonEmptyString, - builtinHash: TrimmedNonEmptyString, - name: TrimmedNonEmptyString, - description: Schema.NullOr(TrimmedNonEmptyString), - config: McpServerConfig, - shippedVars: Schema.Array(McpServerVar), - timeoutMs: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000))), -}); -export type McpBuiltinSyncInput = typeof McpBuiltinSyncInput.Type; -``` -> The actual command type (`type: "mcp.builtin-sync"`) and its event (`mcp.server-updated` reuse, or a -> new `mcp.builtin-synced`) are declared in `orchestration.ts` alongside the existing `mcp.server-*` -> commands. **Exact orchestration.ts edits are in K1f below** (read that file at impl; the command set -> lives there, mirroring `mcp.server-add`). The migrator also needs `mcp.builtin-remove` (a thin alias -> of `mcp.server-remove` is sufficient — the migrator can dispatch the existing `mcp.server-remove` -> for removals, so **no new remove command**; only add/update needs the no-fork path). - -### K1f. orchestration.ts — register `mcp.builtin-sync` -> **Read `packages/contracts/src/orchestration.ts` at impl** (the `mcp.server-add`/`update`/`remove` -> command + event union). Add a `mcp.builtin-sync` command carrying `McpBuiltinSyncInput`'s fields + -> `commandId`, and reuse the existing `mcp.server-updated` / `mcp.server-added` events (payload = -> `{ server: McpCatalogServer }`) — the migrator's decider branch builds the full `McpCatalogServer` -> and emits the SAME event the projection already handles, so **no projection/event change**, only a -> new command. Exact before/after captured at impl from the file (the command union is mechanical; -> mirror `mcp.server-add`). **STOP-marker:** if the event union can't be reused (e.g. add vs update -> distinction needed), add `mcp.builtin-synced` with payload `{ server: McpCatalogServer }` and a -> projector case mirroring `catalog-upserted` — pre-approved. - -## K2. Resolver + mcp-core (`packages/mcp-core/src/resolver.ts`) - -### K2a. `extraArgs` appended to resolved args + folded into the cache key -`resolveConfig` must append `extraArgs`. Its input gains `extraArgs`. BEFORE (signature + stdio case): -```ts -export function resolveConfig(input: { - readonly config: McpServerConfig; - readonly vars: ReadonlyArray; - readonly varValues: Readonly>; - readonly timeoutMs: number | undefined; - readonly context: ResolveContext; -}): ResolvedServerConfig { - const resolvedVars = resolveVarValues(input.vars, input.varValues, input.context); - const expand = (value: string) => expandTemplate(value, resolvedVars, input.context.projectCwd); - const timeout = input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}; - switch (input.config.transport) { - case "stdio": - return { - transport: "stdio", - command: expand(input.config.command), - args: input.config.args.map(expand), - env: resolvedVars, - cwd: input.context.projectCwd, - ...timeout, - }; -``` -AFTER (add `extraArgs` to input; append to stdio args; http ignores extraArgs — stdio-only): -```ts -export function resolveConfig(input: { - readonly config: McpServerConfig; - readonly vars: ReadonlyArray; - readonly varValues: Readonly>; - // User-appended args (after the locked template args). Empty for manual/http servers. - readonly extraArgs: ReadonlyArray; - readonly timeoutMs: number | undefined; - readonly context: ResolveContext; -}): ResolvedServerConfig { - const resolvedVars = resolveVarValues(input.vars, input.varValues, input.context); - const expand = (value: string) => expandTemplate(value, resolvedVars, input.context.projectCwd); - const timeout = input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}; - switch (input.config.transport) { - case "stdio": - return { - transport: "stdio", - command: expand(input.config.command), - args: [...input.config.args, ...input.extraArgs].map(expand), - env: resolvedVars, - cwd: input.context.projectCwd, - ...timeout, - }; -``` -`configCacheKey` must include `extraArgs` so changing it splits the cache (status reset). BEFORE: -```ts -export function configCacheKey( - config: McpServerConfig, - vars: ReadonlyArray, - varValues: Readonly>, -): string { - const effectiveVars = vars.map((declared) => ({ - name: declared.name, - secret: declared.secret, - value: declared.name in varValues ? varValues[declared.name]! : declared.value, - })); - return fnv1a(JSON.stringify(canonicalize({ config, vars: effectiveVars }))); -} -``` -AFTER: -```ts -export function configCacheKey( - config: McpServerConfig, - vars: ReadonlyArray, - varValues: Readonly>, - extraArgs: ReadonlyArray, -): string { - const effectiveVars = vars.map((declared) => ({ - name: declared.name, - secret: declared.secret, - value: declared.name in varValues ? varValues[declared.name]! : declared.value, - })); - return fnv1a(JSON.stringify(canonicalize({ config, vars: effectiveVars, extraArgs }))); -} -``` -> `overlayFingerprint` needs **no change** — it hashes `dedupHash(resolved)`, and `resolved.args` now -> includes the expanded `extraArgs`, so extraArgs is already in the fingerprint. ✅ -> **All `resolveConfig(...)` and `configCacheKey(...)` call sites gain the `extraArgs` argument** — -> enumerated in K2c (no call site missed = no STOP). - -### K2b. `missingRequiredVars` widened to any level (required at catalog OR project level) -BEFORE: -```ts -export function missingRequiredVars( - vars: ReadonlyArray, - varValues: Readonly>, -): ReadonlyArray { - return vars - .filter((declared) => declared.perProject && declared.required) - .filter((declared) => !(declared.name in varValues) && declared.value === null) - .map((declared) => declared.name); -} -``` -AFTER (drop the `perProject` filter — a required var with no effective value is missing at any level): -```ts -export function missingRequiredVars( - vars: ReadonlyArray, - varValues: Readonly>, -): ReadonlyArray { - return vars - .filter((declared) => declared.required) - .filter((declared) => !(declared.name in varValues) && declared.value === null) - .map((declared) => declared.name); -} -``` -> Effect: a catalog-level (`perProject:false`) required var with `value:null` now makes the catalog -> DEFAULT incomplete (reactor skips probing it; overlay excludes it) AND the catalog tile shows -> «требует настройки» (web `catalogMissingVars` G3b is replaced by a call to this same logic — K8). -> A binding still resolves its own per-project requireds. **Behavior check:** a built-in shipping a -> required catalog-level secret stays «требует настройки» until the user sets it on the catalog -> server — exactly D-config-model. - -### K2c. Every `resolveConfig` / `configCacheKey` call site gets `extraArgs` -Call sites (verified by grep — all listed; adding the arg at each is mechanical): -- `McpReactor.ts:112` `resolveConfig({...})` (in `mergeDesired`) → add `extraArgs: server.extraArgs,`. -- `McpReactor.ts:120` `configCacheKey(server.config, server.vars, varValues)` → `..., server.extraArgs)`. -- `McpRuntime.ts:82` `configCacheKey(server.config, server.vars, {})` → `..., server.extraArgs)`. -- `McpOverlay.ts:157` `resolveConfig({...})` → add `extraArgs: server.extraArgs,`. -- `mcpCore.test.ts` (H1) — the resolver tests call `resolveConfig`/`configCacheKey`; **update them to - pass `extraArgs: []`** (and a 4th `[]` arg to `configCacheKey`). This is the ONE test-file edit in - this plan that was previously "no edit" — **SUPERSEDES H1's "no edit"**: H1 now adds `extraArgs: []` - to the test's `resolve`/`configCacheKey` helpers (the `stdioConfig`/`resolve` wrapper at the top - gains `extraArgs: []`; the three `configCacheKey(stdioConfig, vars, {...})` calls gain a trailing - `[]`). Exact: the test's local `resolve` helper passes `extraArgs: []` inside `resolveConfig({...})`. - -## K3. Migration `031_Mcp.ts` + projection (`ProjectionMcpCatalog.ts`) - -### K3a. `031_Mcp.ts` — new columns on `mcp_catalog_server` -BEFORE: -```ts - CREATE TABLE IF NOT EXISTS mcp_catalog_server ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - source TEXT NOT NULL, - config_json TEXT NOT NULL, - vars_json TEXT NOT NULL DEFAULT '[]', - timeout_ms INTEGER, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) -``` -AFTER: -```ts - CREATE TABLE IF NOT EXISTS mcp_catalog_server ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - source TEXT NOT NULL, - config_json TEXT NOT NULL, - vars_json TEXT NOT NULL DEFAULT '[]', - extra_args_json TEXT NOT NULL DEFAULT '[]', - builtin_id TEXT, - builtin_hash TEXT, - locked INTEGER NOT NULL DEFAULT 0, - timeout_ms INTEGER, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) -``` - -### K3b. `ProjectionMcpCatalog.ts` — row schema + upsert + selects -`McpCatalogServerDbRow` BEFORE: -```ts -const McpCatalogServerDbRow = McpCatalogServer.mapFields( - Struct.assign({ - config: Schema.fromJsonString(McpServerConfig), - vars: Schema.fromJsonString(Schema.Array(McpServerVar)), - }), -); -``` -AFTER (decode the JSON columns; `locked` is an INTEGER 0/1 → boolean): -```ts -const McpCatalogServerDbRow = McpCatalogServer.mapFields( - Struct.assign({ - config: Schema.fromJsonString(McpServerConfig), - vars: Schema.fromJsonString(Schema.Array(McpServerVar)), - extraArgs: Schema.fromJsonString(Schema.Array(Schema.String)), - locked: Schema.transform(Schema.Int, Schema.Boolean, { - decode: (n) => n !== 0, - encode: (b) => (b ? 1 : 0), - }), - }), -); -``` -> **STOP-check:** confirm the exact `Schema.transform` arg order/signature in beta.59 (decode/encode -> object vs positional). If it differs, use the codebase's established Int↔Boolean pattern (grep -> `Schema.transform` in persistence for the exact shape) — pre-approved to match local convention. -`upsertRow` BEFORE: -```ts - sql` - INSERT INTO mcp_catalog_server ( - id, name, description, source, config_json, vars_json, timeout_ms, created_at, updated_at - ) VALUES ( - ${server.id}, ${server.name}, ${server.description}, ${server.source}, - ${JSON.stringify(server.config)}, ${JSON.stringify(server.vars)}, ${server.timeoutMs}, - ${server.createdAt}, ${server.updatedAt} - ) - ON CONFLICT (id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - source = excluded.source, - config_json = excluded.config_json, - vars_json = excluded.vars_json, - timeout_ms = excluded.timeout_ms, - updated_at = excluded.updated_at - `, -``` -AFTER: -```ts - sql` - INSERT INTO mcp_catalog_server ( - id, name, description, source, config_json, vars_json, extra_args_json, - builtin_id, builtin_hash, locked, timeout_ms, created_at, updated_at - ) VALUES ( - ${server.id}, ${server.name}, ${server.description}, ${server.source}, - ${JSON.stringify(server.config)}, ${JSON.stringify(server.vars)}, - ${JSON.stringify(server.extraArgs)}, ${server.builtinId}, ${server.builtinHash}, - ${server.locked ? 1 : 0}, ${server.timeoutMs}, ${server.createdAt}, ${server.updatedAt} - ) - ON CONFLICT (id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - source = excluded.source, - config_json = excluded.config_json, - vars_json = excluded.vars_json, - extra_args_json = excluded.extra_args_json, - builtin_id = excluded.builtin_id, - builtin_hash = excluded.builtin_hash, - locked = excluded.locked, - timeout_ms = excluded.timeout_ms, - updated_at = excluded.updated_at - `, -``` -`getRow` + `listRows` SELECT lists BEFORE (both share the same column list): -```ts - SELECT id, name, description, source, config_json AS "config", vars_json AS "vars", - timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt" - FROM mcp_catalog_server ... -``` -AFTER (both): -```ts - SELECT id, name, description, source, config_json AS "config", vars_json AS "vars", - extra_args_json AS "extraArgs", builtin_id AS "builtinId", builtin_hash AS "builtinHash", - locked AS "locked", timeout_ms AS "timeoutMs", created_at AS "createdAt", - updated_at AS "updatedAt" - FROM mcp_catalog_server ... -``` -> Apply to **both** `getRow` (`WHERE id = ${serverId}`) and `listRows` (`ORDER BY created_at ASC, id ASC`). - -## K4. Prebuild file `apps/server/src/ru-fork/mcp/McpBuiltins.ts` (replaces `McpDefaults.ts`) - -New file — the shipped catalog as managed templates. `McpDefaults.ts` is **deleted**; its -`isBuiltinServerId` is no longer used (the migrator keys on `builtinId`). Full body: -```ts -// ru-fork: the shipped built-in MCP catalog — managed TEMPLATES. The migrator (McpReactor) -// reconciles these against the installed catalog by `builtinId` on every startup: add new, update -// changed (by content hash), remove deleted. Command/args are LOCKED; the user configures var -// VALUES + `extraArgs`. Per-platform variants; a platform with no variant is skipped. Ship -// DECLARATIONS only — never real secret values. - -import * as os from "node:os"; - -import type { McpServerConfig, McpServerVar } from "@t3tools/contracts"; - -/** A var DECLARATION shipped by a template (no secret value — value is null for secrets). */ -export interface McpBuiltinVar { - readonly name: string; - readonly secret: boolean; - readonly perProject: boolean; - readonly required: boolean; - /** A non-secret default; null = a hole the user fills. Secrets are always null here. */ - readonly value: string | null; -} - -/** A built-in template. `config` is per-platform; the migrator picks `process.platform`. */ -export interface McpBuiltinDefinition { - readonly builtinId: string; // stable hidden reconciliation key — NEVER rename - readonly name: string; - readonly description?: string; - /** Per-platform locked command/args. A missing key for the current platform ⇒ skip the built-in. */ - readonly config: Partial> & { - readonly default?: McpServerConfig; - }; - readonly vars: ReadonlyArray; - readonly timeoutMs?: number; -} - -export const MCP_BUILTINS: ReadonlyArray = [ - { - builtinId: "filesystem", - name: "filesystem", - description: - "Чтение и запись файлов в каталоге проекта. Запускается локально через npx, без секретов.", - config: { - default: { - transport: "stdio", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "${PROJECT_CWD}"], - }, - }, - vars: [], - }, - { - builtinId: "context7", - name: "context7", - description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.", - config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } }, - vars: [], - }, -]; - -/** Pick the platform-specific config (current OS, else `default`); null ⇒ unsupported ⇒ skip. */ -export function builtinConfigForPlatform( - definition: McpBuiltinDefinition, - platform: NodeJS.Platform, -): McpServerConfig | null { - return definition.config[platform] ?? definition.config.default ?? null; -} - -/** Shipped vars as domain `McpServerVar`s (origin:"shipped"; secrets carry value:null). */ -export function builtinShippedVars(definition: McpBuiltinDefinition): ReadonlyArray { - return definition.vars.map((variable) => ({ - name: variable.name, - secret: variable.secret, - perProject: variable.perProject, - required: variable.required, - value: variable.value, - origin: "shipped", - })); -} - -/** - * Content hash of the SHIPPED parts (platform-resolved config + shipped var declarations + timeout + - * name/description). Drives "shipped template changed → update". Excludes user data by construction - * (user vars/values/extraArgs are not part of the definition). Stable JSON of sorted keys. - */ -export function builtinHash(config: McpServerConfig, definition: McpBuiltinDefinition): string { - const canonical = JSON.stringify({ - name: definition.name, - description: definition.description ?? null, - config, - vars: builtinShippedVars(definition), - timeoutMs: definition.timeoutMs ?? null, - }); - let hash = 0x811c9dc5; - for (let index = 0; index < canonical.length; index += 1) { - hash ^= canonical.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -/** The catalog serverId for a built-in (stable, derived from builtinId). */ -export function builtinServerId(builtinId: string): string { - return `srv-builtin-${builtinId}`; -} -``` -> `os` import is present for symmetry but `process.platform` is used by the caller (the migrator -> passes it in) — **remove the `os` import** (the migrator reads `process.platform`; this file is -> pure given a platform arg). **Correction applied here:** drop `import * as os` (unused). The -> migrator (K7) passes `process.platform`. - -## K5. Decider (`decider.ts`) + builders (`McpCatalogBuilders.ts`) - -### K5a. Drop the fork-to-custom on update -`McpCatalogBuilders.applyServerUpdate` BEFORE: -```ts - source: existing.source === "builtin" ? "custom" : existing.source, -``` -AFTER (never fork — templates stay managed; manual stay custom): -```ts - source: existing.source, -``` -And `applyServerUpdate` must preserve `extraArgs`/`builtinId`/`builtinHash`/`locked` + apply an -`extraArgs` patch. BEFORE (the full return): -```ts -export function applyServerUpdate( - existing: McpCatalogServer, - patch: McpServerDraftPatch, - vars: ReadonlyArray, - occurredAt: string, -): McpCatalogServer { - return { - id: existing.id, - name: patch.name ?? existing.name, - description: applyDescriptionPatch(existing.description, patch.description), - source: existing.source === "builtin" ? "custom" : existing.source, - config: patch.config ?? existing.config, - vars, - timeoutMs: patch.timeoutMs !== undefined ? patch.timeoutMs : existing.timeoutMs, - createdAt: existing.createdAt, - updatedAt: occurredAt, - }; -} -``` -AFTER (locked templates reject config/shipped-var changes — enforced in the decider K5c; here we -carry the managed fields through and apply the extraArgs patch): -```ts -export function applyServerUpdate( - existing: McpCatalogServer, - patch: McpServerDraftPatch, - vars: ReadonlyArray, - occurredAt: string, -): McpCatalogServer { - return { - id: existing.id, - name: patch.name ?? existing.name, - description: applyDescriptionPatch(existing.description, patch.description), - source: existing.source, - // A locked template keeps its shipped command; the decider rejects a config patch for it (K5c). - config: existing.locked ? existing.config : (patch.config ?? existing.config), - vars, - extraArgs: patch.extraArgs ?? existing.extraArgs, - builtinId: existing.builtinId, - builtinHash: existing.builtinHash, - locked: existing.locked, - timeoutMs: patch.timeoutMs !== undefined ? patch.timeoutMs : existing.timeoutMs, - createdAt: existing.createdAt, - updatedAt: occurredAt, - }; -} -``` -`buildAddedServer` (manual add) BEFORE: -```ts -export function buildAddedServer( - serverId: McpServerId, - draft: McpServerDraft, - vars: ReadonlyArray, - occurredAt: string, -): McpCatalogServer { - return { - id: serverId, - name: draft.name, - description: draft.description ?? null, - source: isBuiltinServerId(serverId) ? "builtin" : "custom", - config: draft.config, - vars, - timeoutMs: draft.timeoutMs ?? null, - createdAt: occurredAt, - updatedAt: occurredAt, - }; -} -``` -AFTER (manual add is always `custom`, `locked:false`, no builtin identity; `isBuiltinServerId` -deleted with `McpDefaults.ts`): -```ts -export function buildAddedServer( - serverId: McpServerId, - draft: McpServerDraft, - vars: ReadonlyArray, - occurredAt: string, -): McpCatalogServer { - return { - id: serverId, - name: draft.name, - description: draft.description ?? null, - source: "custom", - config: draft.config, - vars, - extraArgs: draft.extraArgs ?? [], - builtinId: null, - builtinHash: null, - locked: false, - timeoutMs: draft.timeoutMs ?? null, - createdAt: occurredAt, - updatedAt: occurredAt, - }; -} -``` -> Remove `import { isBuiltinServerId } from "./McpDefaults.ts";` from `McpCatalogBuilders.ts`. - -### K5b. New builder `buildSyncedBuiltin` (migrator 3-way merge) -Add to `McpCatalogBuilders.ts`: -```ts -/** - * Build the catalog row for a managed built-in from its shipped definition, merged over any existing - * row: shipped command/vars REPLACE; user data (origin:"user" vars, var VALUES by name, extraArgs) - * is PRESERVED. Source is always "builtin", command locked. No secret-store interaction (shipped - * vars carry value:null; user values are kept verbatim by name). - */ -export function buildSyncedBuiltin(input: { - readonly serverId: McpServerId; - readonly builtinId: string; - readonly builtinHash: string; - readonly name: string; - readonly description: string | null; - readonly config: McpServerConfig; - readonly shippedVars: ReadonlyArray; - readonly timeoutMs: number | null; - readonly existing: McpCatalogServer | undefined; - readonly occurredAt: string; -}): McpCatalogServer { - const existingByName = new Map((input.existing?.vars ?? []).map((v) => [v.name, v])); - // Shipped vars keep the user's configured VALUE (by name) when one exists; else the shipped value. - const shipped = input.shippedVars.map((variable): McpServerVar => { - const prior = existingByName.get(variable.name); - const keptValue = prior && prior.value !== null ? prior.value : variable.value; - return { ...variable, value: keptValue }; - }); - // User-added vars (origin:"user") survive a template update verbatim. - const userVars = (input.existing?.vars ?? []).filter((v) => v.origin === "user"); - return { - id: input.serverId, - name: input.name, - description: input.description, - source: "builtin", - config: input.config, - vars: [...shipped, ...userVars], - extraArgs: input.existing?.extraArgs ?? [], - builtinId: input.builtinId, - builtinHash: input.builtinHash, - locked: true, - timeoutMs: input.timeoutMs, - createdAt: input.existing?.createdAt ?? input.occurredAt, - updatedAt: input.occurredAt, - }; -} -``` - -### K5c. Decider branches -`mcp.server-add` (manual) BEFORE: -```ts - const vars = yield* splitServerVars(command.serverId, command.draft.vars); -``` -AFTER (stamp origin:"user"; no existing vars on add): -```ts - const vars = yield* splitServerVars(command.serverId, command.draft.vars, []); -``` -`mcp.server-update` BEFORE: -```ts - const existing = yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); - const occurredAt = yield* nowIso; - const vars = command.patch.vars - ? yield* splitServerVars(command.serverId, command.patch.vars) - : existing.vars; -``` -AFTER (a LOCKED template rejects a `config` patch or a `vars` patch that changes SHIPPED declarations; -user vars + values pass through; `splitServerVars` gets `existing.vars` for keepSecret): -```ts - const existing = yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); - const occurredAt = yield* nowIso; - // Identity lock: a template's command and shipped var declarations are read-only. A patch that - // would change them is rejected; configuring (extraArgs, var values, user vars) is allowed. - if (existing.locked && command.patch.config !== undefined) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `MCP server '${command.serverId}' is a locked template; its command cannot be edited.`, - }); - } - const vars = command.patch.vars - ? yield* mergeTemplateVars(command.serverId, existing, command.patch.vars) - : existing.vars; -``` -> `mergeTemplateVars` = a helper (in `McpCatalogBuilders.ts`) that, for a locked template, splits the -> draft vars (origin:"user") and concatenates them with the existing SHIPPED vars (preserving shipped -> declarations + values); for an unlocked server, behaves like the old `splitServerVars(serverId, -> patch.vars, existing.vars)` (all user). Exact helper: -```ts -/** Vars for a server-update: locked template → existing shipped + split user drafts; else all user. */ -export function mergeTemplateVars( - serverId: McpServerId, - existing: McpCatalogServer, - draftVars: ReadonlyArray, -): Effect.Effect, SecretStoreError, ServerSecretStore> { - return Effect.gen(function* () { - const userVars = yield* splitServerVars(serverId, draftVars, existing.vars); - if (!existing.locked) { - return userVars; - } - const shipped = existing.vars.filter((v) => v.origin === "shipped"); - return [...shipped, ...userVars]; - }); -} -``` -> `splitServerVars` must stamp `origin:"user"` on every var it produces (K6). `mcp.server-add`'s -> `buildAddedServer` already yields user-origin vars via `splitServerVars(...,[])`. -`mcp.builtin-sync` (NEW decider branch, after `mcp.server-update`): -```ts - case "mcp.builtin-sync": { - const existing = findCatalogServer(readModel, command.serverId); // undefined ⇒ add - const occurredAt = yield* nowIso; - return { - ...withEventBase({ - aggregateKind: "mcp-catalog", - aggregateId: MCP_CATALOG_AGGREGATE_ID, - occurredAt, - commandId: command.commandId, - }), - type: existing ? "mcp.server-updated" : "mcp.server-added", - payload: { - server: buildSyncedBuiltin({ - serverId: command.serverId, - builtinId: command.builtinId, - builtinHash: command.builtinHash, - name: command.name, - description: command.description, - config: command.config, - shippedVars: command.shippedVars, - timeoutMs: command.timeoutMs, - existing, - occurredAt, - }), - }, - }; - } -``` -> `findCatalogServer(readModel, serverId)` — the read-model lookup used by `requireCatalogServer` -> (reuse the existing non-throwing finder; **read decider.ts at impl** for its exact name — likely -> the same helper `requireCatalogServer` wraps). Imports added to decider.ts: `buildSyncedBuiltin` -> (from McpCatalogBuilders), `McpBuiltinSyncInput` types via the command union. `OrchestrationCommandInvariantError` -> is already imported (used in the default branch). - -## K6. `McpSecrets.splitServerVars` stamps `origin:"user"` -Extends F2. The F2 AFTER `result.push({ ...base, value })` — `base` must include `origin:"user"`. -BEFORE (F2's `base`): -```ts - const base = { - name: draft.name, - secret: draft.secret, - perProject: draft.perProject, - required: draft.required, - }; -``` -AFTER: -```ts - const base = { - name: draft.name, - secret: draft.secret, - perProject: draft.perProject, - required: draft.required, - origin: "user" as const, - }; -``` -> `"user" as const` is a literal annotation, not a type assertion on a value of wrong type — it -> narrows the string literal so `base.origin: "user"`. (Memory's "no `as`" targets unsafe casts; -> `as const` on a literal is the idiomatic narrow. If the lint forbids `as const` broadly, use a typed -> local: `const origin: McpServerVarOrigin = "user";` and spread — pre-approved fallback.) - -## K7. Reactor migrator (`McpReactor.ts`) — replaces `seedBuiltinsIfEmpty` -Replace the import + the seed function + its call. -Import BEFORE: `import { MCP_BUILTIN_SERVERS } from "./McpDefaults.ts";` -Import AFTER: -```ts -import { - builtinConfigForPlatform, - builtinHash, - builtinServerId, - builtinShippedVars, - MCP_BUILTINS, -} from "./McpBuiltins.ts"; -``` -`seedBuiltinsIfEmpty` (the whole function, lines 279-315) is REPLACED by `reconcileBuiltins`: -```ts - // Reconcile shipped built-ins against the installed catalog by builtinId: add new, update on - // content-hash change (3-way merge preserves user values/vars/extraArgs), remove deleted. Runs at - // startup. NOT eager (no probing on load). Unsupported platform (no config variant) ⇒ skip. - const reconcileBuiltins = Effect.gen(function* () { - const platform = process.platform; - const catalog = yield* catalogRepository.listAll(); - const installedByBuiltinId = new Map( - catalog.filter((s) => s.builtinId !== null).map((s) => [s.builtinId, s]), - ); - const shippedBuiltinIds = new Set(); - for (const definition of MCP_BUILTINS) { - const config = builtinConfigForPlatform(definition, platform); - if (config === null) { - continue; // unsupported on this OS — skip (D-builtins ⑦A) - } - shippedBuiltinIds.add(definition.builtinId); - const serverId = builtinServerId(definition.builtinId); - const hash = builtinHash(config, definition); - const installed = installedByBuiltinId.get(definition.builtinId); - if (installed && installed.builtinHash === hash) { - continue; // up to date - } - const createdAt = yield* nowIso; - yield* engine - .dispatch({ - type: "mcp.builtin-sync", - commandId: CommandId.make(`server:mcp-builtin-sync:${definition.builtinId}`), - serverId: McpServerId.make(serverId), - builtinId: definition.builtinId, - builtinHash: hash, - name: definition.name, - description: definition.description ?? null, - config, - shippedVars: builtinShippedVars(definition), - timeoutMs: definition.timeoutMs ?? null, - }) - .pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor failed to sync builtin", { - builtinId: definition.builtinId, - cause: Cause.pretty(cause), - }), - ), - ); - } - // Remove installed built-ins no longer shipped (cascade: bindings + secret GC handle the rest). - for (const installed of catalog) { - if (installed.builtinId !== null && !shippedBuiltinIds.has(installed.builtinId)) { - yield* engine - .dispatch({ - type: "mcp.server-remove", - commandId: CommandId.make(`server:mcp-builtin-remove:${installed.builtinId}`), - serverId: installed.id, - }) - .pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor failed to remove dropped builtin", { - builtinId: installed.builtinId, - cause: Cause.pretty(cause), - }), - ), - ); - } - } - }).pipe( - Effect.catchCause((cause) => - Effect.logError("mcp reactor failed to reconcile builtins", { cause: Cause.pretty(cause) }), - ), - ); -``` -`start` BEFORE: -```ts - yield* seedBuiltinsIfEmpty; - // Initial reconcile: cover bindings restored from the DB on restart — NOT eager (no probing - // on load; cached status is shown, never-probed stays «не проверено» until a trigger). - yield* worker.enqueue({ kind: "reconcile", eager: false }); -``` -AFTER: -```ts - yield* reconcileBuiltins; - // Initial reconcile: cover bindings restored from the DB on restart — NOT eager (no probing - // on load; cached status is shown, never-probed stays «не проверено» until a trigger). - yield* worker.enqueue({ kind: "reconcile", eager: false }); -``` -> `mcp.server-remove` for a dropped built-in cascades to bindings: the existing binding-removal + -> the secret GC (D3) prune everything. **STOP-check:** confirm `mcp.server-remove`'s projector also -> removes the catalog row's bindings, or whether bindings are removed by a separate cascade; if -> bindings are NOT auto-cascaded on server-remove, the migrator additionally dispatches -> `mcp.binding-remove` for each binding of the dropped server (read the projector at impl — the -> cascade exists for `project.deleted`; verify it for `server-remove`). Pre-approved either way. -> Built-in `mcp.server-remove` events flow back as `eager:true` reconciles — harmless (the server is -> gone, nothing to probe). - -## K8. Web — template editor + needs-attention catalog tile - -### K8a. UI types: `McpRegistryServer` gains `locked`, `extraArgs`, `builtinId` -Extends G1. BEFORE (the G1 AFTER additions): -```ts - readonly checking: boolean; - readonly incomplete: boolean; - readonly missingVars: readonly string[]; - readonly tags: readonly string[]; - readonly docsUrl?: string; -} -``` -AFTER: -```ts - readonly checking: boolean; - readonly incomplete: boolean; - readonly missingVars: readonly string[]; - /** A managed template: command/args read-only; user edits only vars values + extraArgs. */ - readonly locked: boolean; - /** User-appendable args (with `${VAR}` holes), appended to the locked command. */ - readonly extraArgs: readonly string[]; - /** Non-null ⇒ a managed built-in (hidden id). */ - readonly builtinId: string | null; - readonly tags: readonly string[]; - readonly docsUrl?: string; -} -``` -And `McpVar` gains `origin` (G6 extends): add `readonly origin: "shipped" | "user";` to the UI `McpVar`. - -### K8b. adapters: map the new fields; `catalogMissingVars` uses widened required -`catalogServerToRegistry` AFTER (extend G3c): add -```ts - locked: server.locked, - extraArgs: [...server.extraArgs], - builtinId: server.builtinId, -``` -to the returned object, and `catalogVarToUi` sets `origin: variable.origin`. -`catalogMissingVars` (G3b) BEFORE: -```ts -function catalogMissingVars(vars: ReadonlyArray): string[] { - return vars - .filter((variable) => variable.perProject && variable.required && variable.value === null) - .map((variable) => variable.name); -} -``` -AFTER (widened — a catalog default is incomplete if ANY required var has no value; a per-project -required var with no catalog default is "set per project" and shown on bindings, NOT here): -```ts -function catalogMissingVars(vars: ReadonlyArray): string[] { - // Catalog-default incompleteness = required vars the CATALOG itself must satisfy: shared (non - // per-project) required vars with no value. Per-project required vars are a per-binding concern. - return vars - .filter((variable) => !variable.perProject && variable.required && variable.value === null) - .map((variable) => variable.name); -} -``` -> `uiVarsToDraft` (G7) sets `origin` is N/A (drafts have no origin; the decider stamps user). The UI -> `McpVar.origin` is read-only display (to grey shipped declarations). When the user edits a template -> and saves, `uiVarsToDraft` sends only the USER vars (the dialog excludes shipped vars from the draft -> — K8c), so the decider's `mergeTemplateVars` re-attaches shipped vars. **Critical:** the template -> dialog's save must send `vars` = only `origin:"user"` rows (shipped rows are display-only). - -### K8c. `McpServerDialog` — template mode (locked command, extraArgs, shipped vs user vars) -When `server?.locked` is true, the dialog renders **template mode**: -- Transport + command/args from `ServerConfigFields` → **read-only** (disabled inputs, greyed). -- A new **`extraArgs`** text field (space-separated, like args) — editable. -- `VarsEditor` shows shipped vars with **read-only declarations** (name/secret/perProject/required - disabled) but **editable values**; user vars fully editable; "+ переменная" adds user vars. -- Save sends `{ config: , vars: , extraArgs, timeoutMs }` via - `updateServer`. The locked `config` is **not** sent (decider rejects it anyway; omit to be safe). -- No JSON tab in template mode (command is locked). - -Manual mode (server not locked, or adding) is unchanged from the existing dialog + the new `extraArgs` -field shown for custom servers too (optional). -> This is the largest single web edit. Exact JSX: gate the existing `ServerConfigFields` with -> `disabled={server?.locked}` (add a `disabled` prop to `ServerConfigFields` that disables its -> inputs + hides the JSON tab), render `` (new tiny component: one `Input`, space- -> split to array), and pass `lockedDeclarations={server?.locked}` to `VarsEditor` (greys the flag -> checkboxes + name for `origin:"shipped"` rows, keeps the value input + SecretAwareInput live). -> `useMcp.addServer/updateServer` inputs gain `extraArgs: readonly string[]`; the mutations send it. -> **Sub-edits enumerated:** `ServerConfigFields` `+disabled` prop; new `ExtraArgsField.tsx`; -> `VarsEditor` `+lockedDeclarations` prop (disable name + flag checkboxes when a row's `origin === -> "shipped"`); `McpServerDialog` branches on `server?.locked`; `AddServerInput` (useMcp) `+extraArgs`; -> `addServer`/`updateServer` send `extraArgs`. Each is mechanical given the parts above. - -### K8d. Needs-attention surfacing -Already covered: a built-in that ships/gains a required var renders «требует настройки» on the -catalog tile (catalog incomplete, K8b + J3) and on bindings (per-project requireds). No extra code — -the widened `required` + existing incomplete markers do it. The catalog detail header shows the -builtin badge from `source === "builtin"` (existing `RegistryDetail` badge) — unchanged. - ---- - -# AMENDMENTS — supersede earlier parts (REVISION 2 D-restart, D-warn) - -## AMEND-1 (REPLACES PART E entirely + the reactor restart pieces in PART D) -**Delete from the plan-as-built:** PART E (E1 `McpOverlayState` file, E2 layer wiring, E3 reactor -`syncOverlaysAndRestart` rewrite, E4 spawn seeding). **Also delete** the reactor's -`syncOverlaysAndRestart`, `restartThread`, `liveThreadsByProject`, `overlayFingerprintsRef`, and the -`yield* syncOverlaysAndRestart` call in `processSignal` — the reactor no longer writes overlays or -restarts sessions at all. The overlay is written ONLY at turn-start (it always was, in -`ProviderCommandReactor.startProviderSession`). Net reactor `processSignal` AFTER: -```ts - const processSignal = (signal: ReactorSignal): Effect.Effect => - Effect.gen(function* () { - const eager = signal.kind === "project-created" ? true : signal.eager; - if (signal.kind === "project-created") { - yield* autobindBuiltinsForProject(signal.projectId); - } - yield* pruneOrphanedVarValues; // item 11 - const added = yield* reconcileNow; // includes gcOrphanedSecrets (item 10) - if (eager) { - yield* supervisor.probeHashes(added); // item 3 - } - }); -``` -> The reactor keeps: `reconcileNow` (+ probe-cache GC + secret GC), `pruneOrphanedVarValues`, -> `reconcileBuiltins`, the worker, autobind. It DROPS all overlay/restart code. `McpOverlay` is still -> used by `ProviderCommandReactor` (turn-start). `McpReactor` no longer imports `McpOverlay`/ -> `ProviderService`/`ProjectionSnapshotQuery` if those were only used by the dropped restart code — -> **STOP-check at impl:** grep each import's remaining uses; drop the now-unused ones (`McpOverlay`, -> `providerService`, `snapshotQuery`, `Option`) — pre-approved removals if unused. - -**Turn-start gate** (`ProviderCommandReactor.ts`): add per-thread overlay-fingerprint tracking + the -`overlayChanged` trigger. Add near the reactor's other in-memory state (top of `make`): -```ts - // Per-thread overlay fingerprint the live session spawned with. A live qwen process doesn't - // survive a server restart, so this in-memory map's lifetime matches the sessions' (no schema). - const sessionOverlayFingerprintRef = yield* Ref.make>(new Map()); -``` -Hoist the overlay write to the top of the turn handler and compute `overlayChanged`. The existing -`startProviderSession` writes the overlay; instead, write it ONCE before the reuse decision and pass -the result in. BEFORE (the reuse gate, lines 581-594): -```ts - const cwdChanged = effectiveCwd !== activeSession?.cwd; - const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)).sessionModelSwitch; - const modelChanged = requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; - const instanceChanged = requestedModelSelection !== undefined && activeSession?.providerInstanceId !== requestedModelSelection.instanceId; - const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; - - if (!cwdChanged && !instanceChanged && !shouldRestartForModelChange) { - return existingSessionThreadId; - } -``` -AFTER (add `overlayChanged`): -```ts - const cwdChanged = effectiveCwd !== activeSession?.cwd; - const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)).sessionModelSwitch; - const modelChanged = requestedModelSelection !== undefined && requestedModelSelection.model !== activeSession?.model; - const instanceChanged = requestedModelSelection !== undefined && activeSession?.providerInstanceId !== requestedModelSelection.instanceId; - const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; - // ru-fork: the MCP overlay this thread's live session spawned with vs. the current one. A - // changed overlay (server edited / bound / unbound / extraArgs / tool policy) re-spawns on the - // next turn with resumeCursor — qwen only reads the overlay at spawn. Fingerprint subsumes the - // allow-list (removing an MCP changes it). Items 8 & 9 dissolve (a stale first spawn self-heals). - const spawnFingerprint = (yield* Ref.get(sessionOverlayFingerprintRef)).get(threadId); - const overlayChanged = - MCP_ENGINE_USE_OVERLAY && currentOverlayFingerprint !== undefined && - spawnFingerprint !== undefined && spawnFingerprint !== currentOverlayFingerprint; - - if (!cwdChanged && !instanceChanged && !shouldRestartForModelChange && !overlayChanged) { - return existingSessionThreadId; - } -``` -And record the fingerprint when a session is bound (in `bindSessionToThread`, after the session is -set): `yield* Ref.update(sessionOverlayFingerprintRef, (m) => new Map(m).set(threadId, fingerprint))`. -> **Plumbing detail (exact at impl):** `currentOverlayFingerprint` must be available at the gate. The -> cleanest seam: write the overlay ONCE at the top of the turn handler (before the gate) — capture -> `mcpOverlayResult` there (overlayPath + allowedServerNames + fingerprint), use its `.fingerprint` -> for `currentOverlayFingerprint`, and pass the SAME `mcpOverlayResult` into `startProviderSession` -> (so it doesn't re-write). `startProviderSession`/`bindSessionToThread` record the fingerprint into -> `sessionOverlayFingerprintRef` keyed by `threadId`. Since this restructures `startProviderSession`'s -> internal `writeOverlay` call into a hoisted one, the **exact** before/after is finalized against the -> file at impl (the gate + `startProviderSession` are in the same `make`). This is the one section -> whose exact edit depends on local control flow; the SEAM (write once at top, compare, pass result -> down, record on bind) is fully specified — any line that differs is logged as a deviation. - -## AMEND-2 (REPLACES G11's two-click banner with a modal — D-warn) -`McpServerDialog` warn-on-impact uses a centered `AlertDialog` listing affected projects by NAME. -`describeEditImpact` (serverConfigForm.ts, from G11) returns **structured** data instead of a string: -```ts -export interface EditImpact { - readonly removedVars: ReadonlyArray<{ readonly name: string; readonly projects: ReadonlyArray }>; - readonly newRequiredProjects: ReadonlyArray; -} -/** Structured impact of a catalog edit; null when non-disruptive. Needs project id→name. */ -export function describeEditImpact( - server: { readonly id: string; readonly vars: readonly McpVar[] }, - nextVars: readonly McpVar[], - bindings: readonly McpProjectBinding[], - projectName: (projectId: string) => string, -): EditImpact | null { - const nextNames = new Set(nextVars.map((v) => v.name)); - const serverBindings = bindings.filter((b) => b.serverId === server.id); - const removedVars = server.vars - .filter((v) => v.perProject && !nextNames.has(v.name)) - .map((v) => ({ - name: v.name, - projects: serverBindings.filter((b) => v.name in b.varValues).map((b) => projectName(b.projectId)), - })) - .filter((r) => r.projects.length > 0); - const newRequired = nextVars.filter( - (v) => v.required && v.value.trim().length === 0 && !server.vars.some((e) => e.name === v.name), - ); - const newRequiredProjects = - newRequired.length > 0 && serverBindings.length > 0 - ? serverBindings.map((b) => projectName(b.projectId)) - : []; - return removedVars.length > 0 || newRequiredProjects.length > 0 - ? { removedVars, newRequiredProjects } - : null; -} -``` -`McpServerDialog`: replace the G11 banner with controlled `AlertDialog` state. On Save, if -`describeEditImpact(...) !== null`, open the AlertDialog (don't dispatch). The AlertDialog body lists -removed vars + affected project names and new-required project names; **Применить** dispatches the -edit + closes both; **Отмена** closes the AlertDialog only. Imports added to `McpServerDialog.tsx`: -`AlertDialog, AlertDialogPopup, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, -AlertDialogFooter, AlertDialogClose` from `~/components/ui/alert-dialog`; `useMcpProjects` from -`../useMcp` (for `projectName`). The `impactWarning` string state from G11 becomes -`impact: EditImpact | null` + `confirmOpen: boolean`. -> base-ui nests dialogs first-class (`--nested-dialogs`), so the AlertDialog over the editor Dialog -> needs no special handling. `reset()` clears `impact`/`confirmOpen`. - ---- - -## PART K + AMENDMENTS — coverage of the new requirements - -| Requirement (from review) | Edits | -|---|---| -| Built-ins shipped as managed templates | K4 (McpBuiltins) + K7 (migrator) | -| Stable hidden id; remove/update/add across releases | K1c (builtinId/builtinHash) + K7 | -| Preserve user token/values on update | K5b (buildSyncedBuiltin 3-way merge) | -| New required field → needs attention | K2b (required widened) + K8b/K8d + binding incomplete | -| Removed field → cleaned | item 11 (varValues prune) + item 10 (secret GC) | -| Per-platform configs; skip unsupported | K4 (builtinConfigForPlatform) + K7 | -| Locked command + extraOptions, preserved on update | K1c (locked/extraArgs) + K2a + K5b + K8c | -| Catalog-level required secret works + needs-attention | K2b + K8b | -| Never detach; remove-from-prebuild = remove-everywhere | K5a (no fork) + K7 (remove loop) | -| Status resets to «не проверено» on update | free — K2a (configKey includes extraArgs/config) → new key ⇒ unchecked | -| Turn-start restart (no active teardown) | AMEND-1 | -| Warn modal with project list | AMEND-2 | - -## Implementation phases (green + committed at each) — FINAL -1. **Phase A (monitoring, items 1–7):** PART A1–A3 + C1–C7 + D1/D2(no-GC-yet)/D5(eager) + G1–G14 - (statuses/checking/edit-lock) — minus secret-GC. Green + commit. -2. **Phase B (secret GC + varValues prune, items 10–11):** PART B + D3 + D4 + A5 + F4 + the prune - command path. Green + commit. -3. **Phase C (keep-secret, items 13–14):** A4 + F2/F3/F5 + G5/G7/G10/G13 + SecretField. Green + commit. -4. **Phase D (turn-start restart, items 8–9):** AMEND-1. Green + commit. -5. **Phase E (config-model deltas):** K1 (origin/locked/extraArgs/builtinId/hash + draft) + K2 - (resolver/cacheKey/required) + K3 (migration/projection) + all K2c call sites. Green + commit. -6. **Phase F (built-ins migrator):** K4 + K5 + K6 + K7 (delete McpDefaults). Green + commit. -7. **Phase G (web templates + warn modal):** K8 + AMEND-2. Green + commit. -8. **Phase H (tests + final gates):** H2 + H3 + K2c test edit; typecheck 10/10, lint 0/0, test:fast - (only 4 preexisting bin.test.ts). Final commit. - -Each phase ends green or it is logged as a deviation and the phase is not committed until green. - ---- - -# REREAD VALIDATION — corrections (authoritative; override the matching spots above) - -> A validation pass against the real files found 6 errors in PART K. These corrections are -> authoritative and supersede the conflicting text above. Patterns confirmed by reading -> `settings.ts`, `ProjectionMcpBinding.ts`, `McpInvariants.ts`, `orchestration.ts`. - -### C-1. `withDecodingDefault` takes an `Effect`, not a thunk — and mcp.ts needs the Effect import -Confirmed form (`settings.ts:44`): `Schema.withDecodingDefault(Effect.succeed(false))`. So in K1a/K1c: -```ts -// add to packages/contracts/src/ru-fork/mcp.ts imports: -import * as Effect from "effect/Effect"; -// K1a: -origin: McpServerVarOrigin.pipe(Schema.withDecodingDefault(Effect.succeed("user"))), -// K1c: -extraArgs: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed>([]))), -builtinId: Schema.NullOr(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed(null))), -builtinHash: Schema.NullOr(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed(null))), -locked: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), -``` -(`McpServerVarOrigin` exists as both value+type, so `Effect.succeed("user")` is valid.) - -### C-2. K3b projection — mirror the binding's `enabled` pattern (NOT `Schema.transform`) -`ProjectionMcpBinding` stores booleans as `NonNegativeInt` in the row schema + a `rowToBinding` that -converts (`enabled: row.enabled !== 0`). Mirror it for `locked`: -```ts -import { McpCatalogServer, McpServerConfig, McpServerVar, NonNegativeInt } from "@t3tools/contracts"; -import * as Option from "effect/Option"; - -const McpCatalogServerDbRow = McpCatalogServer.mapFields( - Struct.assign({ - config: Schema.fromJsonString(McpServerConfig), - vars: Schema.fromJsonString(Schema.Array(McpServerVar)), - extraArgs: Schema.fromJsonString(Schema.Array(Schema.String)), - locked: NonNegativeInt, // 0/1 column → converted by rowToServer below - }), -); -type McpCatalogServerDbRow = typeof McpCatalogServerDbRow.Type; - -function rowToServer(row: McpCatalogServerDbRow): McpCatalogServer { - return { ...row, locked: row.locked !== 0 }; -} -``` -`builtin_id`/`builtin_hash` are plain `NullOr(TrimmedNonEmptyString)` columns — they decode directly -from the SELECT, **no mapFields entry needed**. Apply `rowToServer` in the repo methods (mirroring -the binding repo): `getById` → `getRow(input).pipe(Effect.map(Option.map(rowToServer)), Effect.mapError(...))`; -`listAll` → `listRows().pipe(Effect.map((rows) => rows.map(rowToServer)), Effect.mapError(...))`. -The K3b `Schema.transform` for `locked` is **discarded** in favor of this. - -### C-3. K5c finder is `findCatalogServerById` (from `McpInvariants.ts`) -The non-throwing finder is `findCatalogServerById(readModel, serverId)` (returns `McpCatalogServer | -undefined`). The decider's `mcp.builtin-sync` branch uses it; add it to the existing -`McpInvariants.ts` import in `decider.ts` (alongside `requireCatalogServer`, `findBinding`). - -### C-4. K1f orchestration.ts — exact `mcp.builtin-sync` command (reuses existing events) -Add after `McpServerRemoveCommand` (orchestration.ts ~line 693), mirroring `McpServerAddCommand`: -```ts -const McpBuiltinSyncCommand = Schema.Struct({ - type: Schema.Literal("mcp.builtin-sync"), - commandId: CommandId, - serverId: McpServerId, - builtinId: TrimmedNonEmptyString, - builtinHash: TrimmedNonEmptyString, - name: TrimmedNonEmptyString, - description: Schema.NullOr(TrimmedNonEmptyString), - config: McpServerConfig, - shippedVars: Schema.Array(McpServerVar), - timeoutMs: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1000))), -}); -``` -- Add `McpBuiltinSyncCommand` to the command Union (find where `McpServerRemoveCommand` is listed in - the `OrchestrationCommand` union and add it). -- Imports in orchestration.ts: add `McpServerConfig`, `McpServerVar`, `TrimmedNonEmptyString` to the - `@t3tools/contracts` / ru-fork import (it already imports `McpServerDraft`/`McpServerDraftPatch`/ - `McpServerId`; `TrimmedNonEmptyString` may already be imported — grep at impl). -- **No new event** — the decider's branch emits the existing `mcp.server-added` / `mcp.server-updated` - (payload `{ server: McpCatalogServer }`), already handled by the projector. Confirmed the event - literals exist (orchestration.ts:863-865, 1178-1188). -- `McpBuiltinSyncInput` (K1e) in `ru-fork/mcp.ts` is then **redundant** — DROP it; orchestration.ts - declares the command inline (matches how the other mcp commands are declared there). The decider - reads the fields off the command directly. - -### C-5. Test literals need `origin` (K1a ripple) -After K1a, every `McpServerVar` literal in tests gains `origin: "user"` (or `"shipped"` where a -shipped var is intended): -- `mcpCore.test.ts`: the `vars` array (the `CONFLUENCE_TOKEN`/`SPACE_ID` literals) + the inline `vars` - in the `substitution rules` / `resolveVarValues` / `missingRequiredVars` blocks → add `origin: "user"`. - Also the `resolve` helper's `resolveConfig({...})` gains `extraArgs: []`, and the three - `configCacheKey(stdioConfig, vars, {...})` calls gain a trailing `[]` (K2c). -- `secretsKeep.test.ts` (H3): the `existing`/`vars` `McpServerVar` literals → add `origin` (`"user"`). -- **`missingRequiredVars` test:** K2b widened it (dropped the `perProject` filter), so the existing - case `missingRequiredVars(vars, {})` where `SPACE_ID` is `perProject:true, required:true` still - returns `["SPACE_ID"]` ✅; ADD a case for a catalog-level required var (`perProject:false, - required:true, value:null`) → returns its name (new behavior). - -### C-6. K7 binding cascade on built-in removal — CONFIRMED, no extra dispatch -`ProjectionMcpBinding` has `removeByServerRow` (`DELETE … WHERE server_id = ?`); the `mcp.server-removed` -projector path uses it (the per-server cascade exists, sibling of the per-project one). So the -migrator's `mcp.server-remove` for a dropped built-in **does** cascade its bindings — the K7 -STOP-check resolves to "no extra `mcp.binding-remove` dispatch needed." Secret GC (D3) then prunes the -orphaned secrets on the next reconcile. **STOP-confirm at impl:** grep the `mcp.server-removed` -projector for `removeByServer`; if absent, the migrator additionally dispatches `mcp.binding-remove` -per binding (pre-approved). - ---- - -## FINAL PLAN STATUS: validated. Proceed to implementation (phases A–H), logging any deviation. -The design (REVISION 2) + exact edits (PART A–K + AMEND-1/2) + these corrections (C-1…C-6) constitute -the production-ready plan. Remaining impl-time confirms are all read-only with pre-approved fallbacks -(every one named). Implementation order = the FINAL phases list above. diff --git a/mcp-specs/legacy/mcp-impl-progress.md b/mcp-specs/legacy/mcp-impl-progress.md deleted file mode 100644 index 05e4e02353e..00000000000 --- a/mcp-specs/legacy/mcp-impl-progress.md +++ /dev/null @@ -1,90 +0,0 @@ -# MCP implementation — COMPLETE ✅ (all 8 phases A–H green & committed) - -**Final state:** typecheck 10/10 · lint 0/0 · test:fast = only the 4 preexisting `bin.test.ts` fail -(744 passed, +16 new). All features (items 1–14 + PART K built-ins/templates + AMEND-1/2) implemented. -Phase F `480a6e56` · Phase G `21be5b6f` · Phase H below. See the per-phase deviation logs lower down. - ---- - -# MCP implementation — HANDOFF (resume anchor) - -**Worktree:** `/mnt/mac/Users/user/WORKSPACE/Projects/experements/ru-code/.claude/worktrees/mcp` (branch `ru-fork/mcp`). -**Authoritative spec:** `mcp-final-plan.md` — PART A–K + AMEND-1/2 + the **REREAD VALIDATION corrections C-1…C-6** (these override the matching text above them). Read it before resuming. -**Mode:** implement straight through, commit green per phase, log deviations, DON'T report until ALL done. No questions — every decision is settled in the plan / REVISION 2 block. - -## Gate per phase (must hold before commit) -`pnpm typecheck` (10/10) · `pnpm lint` (0/0) · `pnpm test:fast` (ONLY the 4 preexisting `tests/bin.test.ts` may fail — everything else passes). - -## DONE — green & committed (5/8) -| Phase | Items | Commit | -|---|---|---| -| A — monitoring | 1–7 (opt-in loop, no probing on load, «не проверено»/«проверка…», edit-lock, change-driven probe) | `111eaa17` | -| B — secret GC | 10 (`pruneByPrefix` + reactor `gcOrphanedSecrets`) | `0c1161e7` | -| C — keep-secret + varValues prune + SecretField | 11,13,14 | `70461cb7` | -| D — turn-start restart | 8,9 (overlayChanged gate in `ProviderCommandReactor`; reactor active-restart removed) | `e66a6ca5` | -| E — config-model deltas | K1–K3 (locked/extraArgs/builtinId/builtinHash, resolver extraArgs+widened required, migration+projection) | `58626b47` | - -## REMAINING — do these next, in order - -### Phase F — built-ins migrator (PART K4–K7, C-3, C-4, C-6) — IN PROGRESS, nothing written yet -1. **Create `apps/server/src/ru-fork/mcp/McpBuiltins.ts`** (plan K4, full body there). Drop the unused `os` import (migrator passes `process.platform`). Exports: `MCP_BUILTINS`, `builtinConfigForPlatform`, `builtinShippedVars` (origin:"shipped"), `builtinHash`, `builtinServerId`, types `McpBuiltinDefinition`/`McpBuiltinVar`. -2. **`orchestration.ts`** (C-4): add `McpBuiltinSyncCommand` after `McpServerRemoveCommand` (fields: type literal `mcp.builtin-sync`, commandId, serverId, builtinId, builtinHash, name, description NullOr, config McpServerConfig, shippedVars Array(McpServerVar), timeoutMs NullOr Int). Add it to the command Union. Imports: `McpServerConfig`,`McpServerVar` (TrimmedNonEmptyString likely already imported — grep). **No new event** — decider emits existing `mcp.server-added`/`mcp.server-updated`. (Drop `McpBuiltinSyncInput` from contracts/mcp.ts — not needed; declare inline in orchestration.ts.) -3. **`McpCatalogBuilders.ts`** (K5b): add `buildSyncedBuiltin(input)` (3-way merge: shipped vars REPLACE but keep user's configured VALUE by name; user-origin vars preserved; extraArgs preserved; source "builtin", locked true) + `mergeTemplateVars(serverId, existing, draftVars)` (locked → existing shipped + split user drafts; else all user). Use `Effect`/`splitServerVars` (already imported). -4. **`decider.ts`** (K5c, C-3): add `case "mcp.builtin-sync"` branch (uses `findCatalogServerById` from McpInvariants — ADD to import; emits server-updated if existing else server-added with `buildSyncedBuiltin`). In `mcp.server-update`: reject a `config` patch when `existing.locked` (throw `OrchestrationCommandInvariantError`, already imported); change `command.patch.vars ? splitServerVars(serverId, patch.vars, existing.vars)` → `mergeTemplateVars(serverId, existing, command.patch.vars)`. -5. **`McpCatalogBuilders.buildAddedServer`** (K5a): manual add is always `source: "custom"`; **remove** `isBuiltinServerId` import/use (McpDefaults is deleted). -6. **`McpReactor.ts`** (K7): replace import `MCP_BUILTIN_SERVERS from "./McpDefaults.ts"` with the `McpBuiltins` helpers; **replace `seedBuiltinsIfEmpty`** with `reconcileBuiltins` (add/update by builtinId+builtinHash via `mcp.builtin-sync`; remove dropped builtins via `mcp.server-remove`; skip platforms with no config variant); `start` calls `reconcileBuiltins` instead of `seedBuiltinsIfEmpty`. (`mcp.server-remove` cascades bindings via `ProjectionMcpBinding.removeByServerRow` — confirmed C-6.) -7. **Delete `apps/server/src/ru-fork/mcp/McpDefaults.ts`** + its test if any (`grep -rl McpDefaults apps/server`). Confirm nothing else imports it. - -### Phase G — web template editor + warn modal (PART K8, AMEND-2) -- `types.ts`: `McpRegistryServer` += `locked`,`extraArgs`,`builtinId`; `McpVar` += `origin`. -- `adapters.ts`: map locked/extraArgs/builtinId in `catalogServerToRegistry`; `catalogVarToUi` set `origin`. (`catalogMissingVars` already correct — non-perProject required.) -- `useMcp.ts`: `AddServerInput` += `extraArgs`; `addServer`/`updateServer` send `extraArgs`. -- `serverConfigForm.ts`: `describeEditImpact` → structured `EditImpact` (AMEND-2) using project names. -- `McpServerDialog.tsx`: template mode when `server?.locked` (read-only command via a `disabled` prop on `ServerConfigFields`; new `ExtraArgsField`; `VarsEditor` `lockedDeclarations` greys shipped-var name+flags; send vars = user-origin only + extraArgs); warn-on-impact **AlertDialog** (centered, lists affected project names, Отмена/Применить) replacing the G11 banner. -- `VarsEditor.tsx`: `lockedDeclarations` prop. `ServerConfigFields.tsx`: `disabled` prop. New `ExtraArgsField.tsx`. -- Gate: typecheck + lint (web has no test target). - -### Phase H — tests + final gates (PART H, C-5) -- New `apps/server/tests/ru-fork/mcp/supervisorDue.test.ts` (H2) — pure due-gate (never-probed not due, 0 interval off). -- New `apps/server/tests/ru-fork/mcp/secretsKeep.test.ts` (H3) — splitServerVars keepSecret + splitBindingVarValues keepNames (fake `ServerSecretStore` layer incl. `pruneByPrefix`). -- `mcpCore.test.ts` (C-5): append `, []` to the 3 `configCacheKey(...)` calls (currently 3-arg — works at runtime but make it correct); add a catalog-level required `missingRequiredVars` case; add `origin:"user"` to the top-level `vars`/literal `McpServerVar`s (server tests aren't typechecked, so optional, but do it). -- Run all three gates; final commit; THEN report to user. - -## Phase F — DONE (built-ins migrator, K4–K7). Green: typecheck 10/10, lint 0/0, test:fast (only 4 baseline bin.test.ts). -Deviations logged in Phase F: -6. **`mcp.builtin-sync` placed in `InternalOrchestrationCommand`**, not the client/dispatchable union the plan's "mirror McpServerRemoveCommand" (C-4) implied. Rationale: it's reactor-dispatched only and must not be client-forgeable (a client could otherwise inject a "managed built-in"). `engine.dispatch` accepts the full `OrchestrationCommand` (Dispatchable ∪ Internal), so the reactor still dispatches it fine. Senior correctness win over the plan's literal placement. -7. **`OrchestrationEngine.ts` `commandAggregate` routing** gained `case "mcp.builtin-sync"` under the mcp-catalog arm — NOT enumerated in the plan but mechanically required: its `default` branch reads `command.threadId`, which the new command lacks (caused the only real typecheck error). Routed to `{aggregateKind:"mcp-catalog", aggregateId: MCP_CATALOG_AGGREGATE_ID}`. -8. **`McpReactor` lost `nowIso` + the `effect/DateTime` import** — the old `seedBuiltinsIfEmpty` stamped `createdAt` itself; the migrator lets the decider stamp `occurredAt` (decider's `nowIso`), so the reactor no longer needs a clock. Removed to satisfy no-unused-vars. -9. **`autobindBuiltinsForProject` rekeyed** from `MCP_BUILTIN_SERVERS` membership to catalog rows with `builtinId !== null` (McpDefaults deleted; built-ins are now identified by the persisted `builtinId`). - -## Phase G — DONE (web template editor + warn modal, K8/AMEND-2). Green: typecheck 10/10, lint 0/0, test:fast (4 baseline). -Deviations logged in Phase G: -10. **`mergeTemplateVars` corrected (server, McpCatalogBuilders)** — the plan's version (`[...existingShipped, ...splitUserDrafts]` + dialog "sends user vars only") silently DROPS edits to a shipped var's VALUE, so a future template shipping a required/secret var the user must fill at the catalog level could never be saved. Rewrote it so the shipped DECLARATION SET (names + secret/perProject/required flags) stays immutable (re-stamped from `existing`) while shipped VALUES are settable from the draft, and the template dialog now sends ALL var rows (shipped + user). With today's built-ins (filesystem/context7, zero vars) behavior is identical; this only matters for future templates with shipped vars. The "user vars only" instruction in K8b/K8c is superseded by "send all rows; the decider re-locks shipped declarations." -11. **`describeEditImpact` / G11 banner did not pre-exist** (Phases A–E built statuses/checking/edit-lock, not a G11 impact banner). So AMEND-2 was implemented as a NET-NEW `describeEditImpact`+`EditImpact` in serverConfigForm.ts and a NET-NEW `AlertDialog` in McpServerDialog.tsx (nothing to "replace"). -12. **`AddServerInput` gained required `extraArgs` + optional `locked`** (useMcp.ts). `addServer` always sends `extraArgs`; `updateServer` omits `config` when `locked` (decider would reject it) and sends `extraArgs`. Only caller is McpServerDialog (grepped). -13. **`ExtraArgsField.tsx`** new component; shown for stdio only (http has no args). `ServerConfigFields` + `VarsEditor` gained `disabled`/`lockedDeclarations` props; template mode hides the JSON tab and renders the form directly. -14. **Web validated via typecheck+lint only** (no web test target, per project rule). - -## Phase H — DONE (tests + final gates). Green: typecheck 10/10, lint 0/0, test:fast (4 baseline). -Deviations logged in Phase H: -15. **No new `supervisorDue.test.ts`** — the plan's H2 (never-probed not due; 0-interval off) is ALREADY fully covered by the existing `supervisorDecisions.test.ts` (the `isProbeDue`/`isSweepDue` blocks). Creating a second file would duplicate. Skipped to stay DRY. -16. **Added `builtins.test.ts` (NOT in the plan)** — Phase F shipped the whole built-ins migrator with zero tests; added coverage for `builtinConfigForPlatform`/`builtinHash`/`builtinShippedVars`/`buildSyncedBuiltin` (3-way merge) + `mergeTemplateVars` (locked declaration-immutability + value-settable + the deviation-10 correction). 9 tests. -17. **`secretsKeep.test.ts` (H3)** uses a Map-backed fake `ServerSecretStore` `Layer.succeed`; the "fresh secret" assertion checks the ref is `mcp-var-`-prefixed + the plaintext lands under it (the ref name is base64-encoded, not literal `srv-x-TOKEN`). 5 tests. -18. **`mcpCore.test.ts` (C-5)** — added the 4th `extraArgs` arg to the 3 `configCacheKey` calls + a new "extraArgs differ" case; `origin` on every `McpServerVar` literal; a catalog-level (`perProject:false`) required `missingRequiredVars` case (K2b widening). - -## Deviations already logged (keep logging new ones) -1. varValues-prune (D4) moved B→C (needs keepVarValues). -2. `McpServerVar.origin` (K1a) pulled into C (splitServerVars needs it). -3. `Effect.zipRight` absent in beta.59 → gen block. -4. Test-literal `origin`/4-arg configCacheKey deferred to H (server tests not typechecked; runtime-safe). -5. Phase E kept McpDefaults seed + isBuiltinServerId transitionally; the no-fork `applyServerUpdate` (source unchanged) already landed in E; the rest of no-fork (buildAddedServer custom, delete McpDefaults) is Phase F. - -## Gotchas (verified against the codebase) -- `Schema.withDecodingDefault(Effect.succeed(x))` — takes an **Effect**, not a thunk; `Effect` is imported in `contracts/mcp.ts`. -- `Effect.catch(handler)` must RETURN an Effect (`Effect.fail(...)`, not a raw error). No `Effect.zipRight` — use a gen block. -- SQLite booleans: store `? 1 : 0`, decode via `NonNegativeInt` in the row schema + a `rowToX` converter (see `ProjectionMcpBinding.rowToBinding` / `ProjectionMcpCatalog.rowToServer`). -- Non-throwing read-model finder = `findCatalogServerById` (in `apps/server/src/ru-fork/mcp/McpInvariants.ts`). -- Server `tests/` are NOT in `tsconfig` include → not typechecked by `pnpm typecheck` (only run by vitest, which strips types). So a missing field that no code reads won't fail. -- `pnpm lint` native binding can be flaky in sandbox — retry once. -- Single migration `031` — edit in place, never add `032`. -- Only `Effect.logError`/`Effect.logDebug`. No `as`/`any` casts (`as const` on a literal is fine). Mark ru-fork deltas with `ru-fork:`. diff --git a/mcp-specs/legacy/mcp-progress.md b/mcp-specs/legacy/mcp-progress.md deleted file mode 100644 index 98e30fc90f7..00000000000 --- a/mcp-specs/legacy/mcp-progress.md +++ /dev/null @@ -1,170 +0,0 @@ -> ⏭️ **NEXT WORK = config-model v2 (vars/template redesign).** Full plan + every decision + -> build order + STATUS table live in **`mcp-vars-redesign.md`** (worktree root). Start there. -> Everything below is the v1 work that is DONE/green and must not regress. - -# MCP Implementation — resume anchor - -## ✅ SESSION: unified neutral-cwd probe + manual recheck + panel hoist + cleanup (DONE, all gates green) -The probe model is now **project-independent**. `${PROJECT_CWD}` resolves to a fixed neutral dir -(`config.mcpProbeCwd` = `/mcp/probe-cwd`, created at startup) for ALL probes → one probe + one -cache row per AUTHORED config (`configCacheKey`), shared across projects; two projects on the catalog -default collapse to ONE instance. qwen still gets the REAL project cwd at overlay-write time (`McpOverlay` -unchanged). Probe online ⇒ qwen can connect + discover tools (catalog-level); cwd only matters at tool-call. -- **Reactor** (`McpReactor.computeDesired`): resolves probe configs with neutral cwd; desired set = every - catalog server's DEFAULT (ref `catalog:`, so the Каталог tab shows status/tools even unbound) ∪ every - enabled binding's EFFECTIVE config (ref `:`; no-override merges into the catalog instance). - Cache **GC** after reconcile: `probeCache.deleteKeysNotIn(liveConfigKeys)` (only runs on a fully-successful - computeDesired → empty set = genuinely empty authored set). -- **Supervisor**: `recheck(filter)` force-probes matching instances bypassing the due-gate (returns void); - in-flight coalescing via `inFlightRef` (sweep + manual never double-connect); pure exported decision helpers - `isSweepDue`/`isProbeDue`/`instanceInWatched`/`instanceMatchesRecheck` (mandatory-FIRST probe bypasses watch - scope; recurring = watched project only). `monitoring` settings flag REMOVED (replaced by the two interval - fields; 0/0 = no recurring probing). -- **Catalog status/tools**: `toolsCache`/`toolsCacheAt` deleted end-to-end (contract + migration 031 cols + - ProjectionMcpCatalog + service + builders). Replaced by `McpCatalogRuntimeSnapshot` (per-serverId, matched - to its instance by configKey) on a **single full-snapshot** runtime stream (`runtime-updated` delta removed). -- **Manual recheck UI**: RPC `mcp.recheck` (`WsMcpRecheckRpc`, success Void) → ws handler → `wsRpcClient.mcp.recheck` - → `useMcpMutations.recheck`. Shared `RecheckButton` in catalog `RegistryDetail` (`{serverId}`), each - `ProjectBindingRow` (`{projectId,serverId}`), and the panel header refresh icon (`{projectId}` of the active - project; tooltip shows its name). Catalog now shows a `StatusBadge`. -- **Universal active-project hook**: `apps/web/src/hooks/useActiveProject.ts` — `useActiveProjectRef(): ProjectId|null` - + `useActiveProject(): Project|null` (with cwd), unifies draft + thread. `useActiveRouteProjectId` deleted, - callers repointed. -- **Panel hoist**: single `McpPanelMount` (desktop inline sidebar + mobile sheet, global store + own media query) - mounted once in `_chat.tsx` beside ``. Removed the per-route mounts from BOTH the thread route AND - the draft route (the draft route had its own mount → would have double-mounted). Fixes draft→thread jump. -- **Tests added** (server-only): `supervisorDecisions.test.ts` (16 — sweep/due/watched/recheck-filter), cache - `deleteKeysNotIn` GC (2), mcpCore neutral-cwd collapse (1). Gates: typecheck 10/10, lint 0, test:fast 722 pass - (only the 4 preexisting `bin.test.ts`). Reviewed by a sub-agent; fixed the draft double-mount + dead recheck - return + GC doc. Everything left uncommitted. - ---- - -# (earlier) MCP Implementation — IN-PROGRESS notes - -> Written mid-build before a context compression. This is the source of truth for resuming. -> Plan docs: `ru-fork-instrumental/changes/mcp/implementation-plan.md` + `final-flow.md` (in the -> MAIN checkout, not this worktree). Probe: `mcp-probe/` (18/18 GO — qwen contract proven). - -## ACTIVE BUILD: monitoring redesign — per-config cache + event-driven prober (LOCKED with user) -M1–M7 + M5 are DONE/green and MCP works end-to-end (qwen used context7 live). The constant 15s -sweep (re-spawning `npx` forever) is being replaced. **Locked spec:** -- **Cache keyed by CONFIG, not server.** `configCacheKey(authoredConfig)` (NEW in mcp-core resolver.ts, - DONE) = fnv1a of the authored McpServerConfig (command/args/env-refs/url/headers/timeout), IGNORES - `${PROJECT_CWD}`/cwd → two projects on the catalog default share one entry; a per-project override - splits it. Persist in a SQLite table `mcp_probe_cache(config_key PK, status, tools_json, last_error, - checked_at)` + migration. This cache is the single source for status+tools (fixes "2-in-log/0-in-UI"). -- **Prober** replaces the sweep: `probe(config)→write cache→broadcast`, with DEDUP (one config = one - probe even if N bindings share it) + IN-FLIGHT coalescing. -- **Mandatory triggers (server/reactor):** catalog add→probe · catalog config change→probe · APP START→ - probe only catalog configs whose cache is MISSING · add-to-project→reuse cache, re-probe only if older - than `RECHECK_ON_ADD_IF_STALE = 5 min` · per-project override change (override≠catalog)→probe it. -- **`mcp.recheck` RPC:** probe a project's configs (filter local/remote) or one config → cache+broadcast. -- **Settings (2 fields, minutes, default 30 each, 0=off):** `settings.mcp.recheckLocalMinutes`, - `recheckRemoteMinutes` — REPLACE the `monitoring` boolean. Drive AUTO recheck of the ACTIVE project's - servers (client-driven off app active-project/draft state — NOT panel-open). -- **Client:** periodic recheck timer (reads the 2 settings) targeting the active project → `mcp.recheck`; - re-targets on active-project change. **Refresh icon** (recheck all active project's configs). **"Проверить" - button per server** in catalog (RegistryDetail) AND project (ProjectBindingRow). UI reads cache. -- **Panel jump (draft→dialog) fix:** hoist `McpPanelInlineSidebar` into the shared `_chat.tsx` layout - (beside ``) → single mount point, no remount on draft→thread. Remove per-route mounts. -- **Also:** drop catalog `toolsCache`/`toolsCacheAt` (cache supersedes); stale-while-revalidate UX; - cache GC of orphaned keys. **NO probe-at-spawn** (each dialog spawns its own `qwen --acp` + respawns - on stop/error → would spam; overlay FILE write on spawn stays, it's cheap). Probing is ONLY: the - mandatory triggers + settings-interval auto-recheck of the active project + manual buttons. -- **Build order / status:** - - ✅ configCacheKey (mcp-core resolver.ts). - - ✅ probe_cache table in single 031 migration (tools_cache cols kept until cache supersedes). - - ✅ Cache persistence: `McpProbeRecord` contract + `McpProbeCacheRepository` (Services/McpProbeCache.ts) - + `ProjectionMcpProbeCacheLive` (Layers) + wired into `McpRepositoriesLive`. NOT yet written-to. - - ✅ Settings `recheckLocalMinutes`/`recheckRemoteMinutes` (default 30) in ServerSettings + patch. - - ✅ **SPAM FIXED**: McpSupervisor sweep now DUE-gated (per-transport interval, 0=off; 60s tick; added - `checkedAtMs` to SupervisorInstance + `isProbeDue`). Local probed once→every recheckLocalMinutes, - remote→every recheckRemoteMinutes. No more every-15s npx. - - ✅ Tools-display fixed: `discoveredTools` on UI `McpProjectBinding` (adapters bindingToUi) + - ProjectBindingRow renders it (server.tools fallback). - - ✅ Log levels → debug/error only ([[log-levels-error-or-debug]]). - - ✅ Settings UI: 2 minute fields in `GeneralSettingsPanel` ("MCP-серверы" section), read via - `useServerSettings().mcp`, write `updateSettings({ mcp: { ...mcpSettings, [field]: minutes } })`. - - ✅ **#4 cache write-through + hydrate — DONE (compiles):** migration `031` mcp_probe_cache gained - `checked_at_ms INTEGER`; `McpProbeRecord` gained `checkedAtMs: Schema.Int`; ProjectionMcpProbeCache - upsert/select include checked_at_ms. Supervisor: `SupervisorInstance`+`DesiredInstance` gained - `configKey`; injects `McpProbeCacheRepository`; `reconcile` rewritten to Effect.gen that HYDRATES new - instances from cache (status/tools/checkedAt/checkedAtMs via `cachedSeed`); `probeInstance` WRITES - THROUGH (`probeCache.upsert({configKey, transport, status, tools, lastError, checkedAt:IsoDateTime.make, - checkedAtMs})`). Reactor computeDesired sets `configKey: configCacheKey(config)` (imported configCacheKey). - - ✅ **#3 active-project scoping — DONE (build green).** Supervisor `watchedProjectsRef: Ref|null>` - (null=probe all), `setWatchedProjects`, `instanceInWatched`, sweep filter `(watched===null || - instanceInWatched) && isProbeDue`. rpc.ts: `WS_METHODS.mcpSetActiveProject` + `WsMcpSetActiveProjectRpc` - (payload `{projectId: NullOr(ProjectId)}`, Void/McpError) in WsRpcGroup. ws.ts: `import { McpSupervisor }` - + `const mcpSupervisor = yield* McpSupervisor;` (after mcpRuntime) + `mcpSetActiveProject` handler. - wsRpcClient.ts: `mcp.setActiveProject` on interface + factory. `_chat.tsx`: NEW `McpActiveProjectSync` - component (mounted beside `ChatRouteGlobalShortcuts`) — `useActiveRouteProjectId()` → `client.mcp. - setActiveProject({projectId: ProjectId.make(...)|null})` on change, guarded by `getPrimaryKnownEnvironment()`. - `useActiveRouteProjectId` re-exported from `ru-fork/mcp-manage/index.ts`. NO casts (ProjectId.make). - - ✅ **TESTS WRITTEN (server-side, all green):** - - `apps/server/tests/ru-fork/mcp/mcpCore.test.ts` (11 tests) — `configCacheKey` stability/key-order- - independence/override-differs/cwd-independence; `resolveConfig`+`dedupHash` (${PROJECT_CWD}→cwd, secret - materialization, dedupHash differs by cwd while cacheKey matches); `effectiveAllowedTools` allow/deny; - `overlayFingerprint` order-independence + policy-flip. - - `apps/server/tests/persistence/Layers/McpProbeCache.test.ts` (3 tests) — upsert→read decoded round-trip, - upsert-overwrites-same-key (1 row), getByKey→None for unknown. Via `SqlitePersistenceMemory`. - - ✅ **GATES GREEN:** typecheck 10/10, lint 0/0, test:fast 703 pass (only the 4 preexisting bin.test.ts fail). - - ⬜ STILL TODO (not started): `mcp.recheck` manual RPC + per-server "Проверить" buttons + refresh icon; - panel hoist to `_chat.tsx` (RISKY: responsive 2-branch + mobile sheet); drop catalog tools_cache; - settings ✅ DONE (UI in GeneralSettingsPanel + contract fields). -- **Unrelated heads-up logged for user:** qwen `setModel failed "qwen3-coder-flash"` — a thread pins a - model qwen rejects; not MCP. -- **In-progress half-edit to reconcile:** added `discoveredTools` to UI `McpProjectBinding` + adapters - `bindingToUi` (ProjectBindingRow not yet using it). Under the cache model, tools come from cache → - reconcile (either keep discoveredTools-from-runtime OR cache-derived). - -## Where I am -- **Worktree:** `/mnt/mac/Users/user/WORKSPACE/Projects/experements/ru-code/.claude/worktrees/mcp` -- **Branch:** `ru-fork/mcp` (based on commit `d177ec50`). **Everything uncommitted** (user wants it left uncommitted for review). -- **qwen is NOT installed here** — test with fakes: `mcp-probe/servers/*` (real stdio+http MCP servers) and `apps/server/tests/provider/fakeAcpCore.ts` + `fakeAcpSpawner.ts` (fake ACP agent the real CliAdapter runs against). - -## Current GREEN checkpoint (verified) -- `pnpm typecheck` → **10/10 pass, 0 errors** -- `pnpm test:fast` → only **4 preexisting failures** in `tests/bin.test.ts` (the "1 preexisting" to IGNORE — they fail because `resolveCli` can't find qwen/cli.js in this env; pass in real env). 689 pass. -- `pnpm lint` → **0 warnings, 0 errors** -- Run gates from worktree root. Lint = `pnpm lint`; tests for one file: `cd apps/server && NODE_OPTIONS='--experimental-strip-types --experimental-sqlite' pnpm vitest run --no-color `. - -## Locked decisions (DO NOT relitigate) -1. **Both feature flags default ON** (`settings.mcp.monitoring`, `MCP_ENGINE_USE_OVERLAY`). User chose "fully live". -2. **Leave everything uncommitted.** -3. **Minimize common-file edits.** All MCP LOGIC lives in `ru-fork/` helpers; common files hold only thin seams (registration/delegation). The user pushed back hard on this — keep it. -4. **No `as const` / casts / `any` / `as unknown`.** Only ONE documented cast exists: `probe.ts` `as Transport` (SDK exactOptionalPropertyTypes friction — justified inline). Don't add more. -5. **Secret model:** `env`/`header` values are ALWAYS secret refs in authored config; the decider splits plaintext→`ServerSecretStore`, supervisor materializes. (Option A — decider splits.) -6. **Structure:** one new package `@ru-fork/mcp-core` (pure logic + probe); everything else in `ru-fork/mcp.ts` (contracts subdir) + `apps/server/src/ru-fork/mcp/` + `apps/web/src/ru-fork/`. - -## Effect 4 (beta.59) gotchas learned -- `Effect.catchAll` does NOT exist → use **`Effect.catch`**. -- No `effect/Either` / `Effect.either` → avoid; `Stream.filterMap` has a version-skewed Option type (don't use it). Let streams carry the error channel instead. -- `Array#sort` lint-flagged → use **`.toSorted()`**. -- `exactOptionalPropertyTypes: true` is on → `T | undefined` is NOT assignable to `T?` (SDK transports trip this). -- Layers are **memoized by reference** → self-providing the same `Live` const in multiple layers shares ONE instance. This is how I fixed test regressions: each Live layer self-provides its repos (matching the existing pipeline pattern), `runtimeLayer.ts` left untouched. -- Commands/events discriminate on **`type`** (string), not `_tag`. Decider is `switch(command.type)` with `command satisfies never` exhaustiveness. - -## DONE (compiles, tests green) -**M1 contracts** — `packages/contracts/src/ru-fork/mcp.ts` (all schemas; `description`/`toolsCache`/`toolsCacheAt` are `NullOr` for DB; `enabled` Boolean). Seams in `orchestration.ts` (5 commands, 5 event-type literals, `mcp-catalog` aggregate kind, widened `aggregateId` union, 5 event envelopes, both command unions, `OrchestrationReadModel` gains `mcpCatalog`/`mcpBindings` with `withDecodingDefault([])`), `rpc.ts` (3 methods + 3 Rpc.make + WsRpcGroup), `settings.ts` (`mcp:{monitoring,autobindDefaults}`), `index.ts` export. Event-store `streamId`/`aggregateId` widened. Receipt repo + `OrchestrationEngine.commandToAggregateRef` widened for the new aggregate. - -**M2 `@ru-fork/mcp-core`** — `packages/mcp-core/` (package.json catalog SDK dep, tsconfig). `resolver.ts` (effectiveConfig, `${VAR}`/`${PROJECT_CWD}` expand, `dedupHash`), `toolPolicy.ts` (`effectiveAllowedTools`, `pruneInertExceptions`), `fingerprint.ts` (`overlayFingerprint` — policy-aware, NOT discovered-tools), `probe.ts` (`probeOnce` SDK client). Added `@ru-fork/mcp-core` to `apps/server/package.json` deps; ran `pnpm install`. - -**M3 persistence + decider + projector** — migration `031_Mcp.ts` (+ registered in `Migrations.ts`). Services `McpCatalog.ts`/`McpBinding.ts` + Layers `ProjectionMcpCatalog.ts`/`ProjectionMcpBinding.ts` (binding `enabled` int↔bool mapped explicitly). Decider branches DELEGATE to `ru-fork/mcp/`: `McpInvariants.ts`, `McpCatalogBuilders.ts`, `McpSecrets.ts`, `McpSecretNames.ts`. Projector folds delegate to `McpReadModel.ts`. Pipeline projectors delegate to `McpProjectors.ts`. `commandInvariants.ts` was REVERTED to original (untouched). `ProjectionSnapshotQuery.getCommandReadModel` loads mcp rows (restart correctness). `config.ts` gains `mcpOverlayDir`. - -**M4 read RPC** — `ru-fork/mcp/McpProjectionQuery.ts` (getSnapshot + `subscriptionStream`). `ws.ts` THIN seam: 2 imports + 2 inject lines + 3 one-line handlers (stream logic lives in the service). - -**M6 services + reactor + startup (DONE, driven & green)** — `ru-fork/mcp/McpSupervisor.ts` (Ref>, single transient-probe sweep loop, `nextStatus` state machine, `reconcile`, `changes` PubSub, `start()`), `McpRuntime.ts` (`subscriptionStream` joining instances×bindings). **NEW `McpReactor.ts`** (drainable worker; `computeDesired` = catalog×bindings, per-project cwd via `ProjectionSnapshotQuery.getProjectShellById().workspaceRoot` memoized, `materializeSecretValues` → `resolveConfig` → `dedupHash` → desired Map with `${projectId}:${serverId}` refs → `supervisor.reconcile`; `isReconcileRelevant` = `mcp.*`/`project.deleted`/`project.meta-updated`; `project.created` → optional autobind gated by `settings.mcp.autobindDefaults`; `seedBuiltinsIfEmpty` dispatches `mcp.server-add` for builtins only when catalog empty; subscribes to `streamDomainEvents` BEFORE seeding so seed/autobind events reconcile; initial `{kind:"reconcile"}` tick covers DB-restored bindings on restart; `{start, drain}` shape). **NEW `McpDefaults.ts`** (`MCP_BUILTIN_SERVERS` = filesystem stdio `${PROJECT_CWD}` + context7 http, both secret-free; `isBuiltinServerId`). `McpCatalogBuilders.buildAddedServer` now sets `source: isBuiltinServerId(serverId) ? "builtin" : "custom"` (decider UNTOUCHED — seed flows through normal `mcp.server-add`). Wired: `McpLayers.ts` `McpRuntimeServicesLive` mergeAll now includes `McpReactorLive` + `provideMerge(ServerSecretStoreLive)` (memoized → shares engine's instance). `serverRuntimeStartup.ts`: resolve `McpSupervisor`+`McpReactor` (~line 239), start both in `reactorScope` after `providerSessionReaper.start()` (~line 287). Self-contained Live layers: pipeline + snapshotQuery + McpRuntimeServices each `provideMerge(McpRepositoriesLive)`; engine `provideMerge(ServerSecretStoreLive)` (added `ServerConfig`+`NodeServices` to 3 minimal engine test setups in `tests/orchestration/Layers/OrchestrationEngine.test.ts`). - -## TODO (remaining — in order) -1. ~~**M6 reactor + startup**~~ ✅ DONE. green. -2. ~~**M7 overlay + ACP coupling**~~ ✅ **DONE** (green: typecheck 10/10, lint 0, test:fast only 4 bin.test.ts). Built: `McpOverlay.ts` (ru-fork — `writeOverlay(projectId)` reads enabled bindings+catalog+project cwd, materializes secrets, resolves configs, builds qwen `system.json` = `{security.folderTrust.enabled:false, mcpServers:{:{stdio command/args/env | http httpUrl/headers, +policy-direct includeTools(default-deny)/excludeTools(default-allow)}}}`, atomic write via `writeFileStringAtomically` provided FileSystem+Path, returns `{overlayPath, allowedServerNames, fingerprint=overlayFingerprint(entries)}`; ONE `// @effect-diagnostics-next-line preferSchemaOverJson:off` on the external-format JSON.stringify). `MCP_ENGINE_USE_OVERLAY=true` flag in config.ts. Contract: `ProviderSessionStartInput` gained TWO provider-neutral optionals — `settingsOverlayPath` (TrimmedNonEmptyString) + `allowedMcpServers` (Array(String)). `CliAcpSupport`: `CliAcpSettingsOverlay` type + `buildCliAcpSpawnInput`/`makeCliAcpRuntime` map settingsOverlayPath→`QWEN_CODE_SYSTEM_SETTINGS_PATH` env, allowedMcpServers→`--allowed-mcp-server-names` arg. `CliAdapter.startSession`: pure passthrough (builds `settingsOverlay` from the 2 input fields, forwards; ZERO MCP import). `AcpSessionRuntime`: 3 `mcpServers:[]` comments. `ProviderCommandReactor.startProviderSession`: now Effect.gen, resolves `mcpOverlay.writeOverlay(thread.projectId)` (gated, best-effort catch→null) → sets the 2 input fields; added `McpOverlay` dep + import. `McpReactor` (M7.5): `overlayFingerprintsRef: Ref>`, `syncOverlaysAndRestart` after reconcile — writeOverlay per project (bindings ∪ previous keys), diff fingerprint, first-sight=record-only, changed→`thread.session.stop` dispatch for that project's LIVE threads (from `providerService.listSessions()` × `getThreadShellById`→projectId); added `McpOverlay`+`ProviderService` deps. `McpLayers`: `McpOverlayLive` provideMerge'd into `McpRuntimeServicesLive` (before repos/secretstore so they reach it). Tests: added `McpOverlay` stub (`Layer.succeed`) to BOTH `ProviderCommandReactor.test.ts` + `RuForkProviderCommandReactor.test.ts`. No cycle: McpReactor→ProviderService→CliAdapter(no MCP); ProviderCommandReactor→McpOverlay→repos. - - **DECISION (locked): Option B** — the reactor resolves the overlay; CliAdapter is a pure forwarder. Contract fields named provider-neutral (`settingsOverlayPath` + `allowedMcpServers`, two INDEPENDENT optionals per user's request — not a masked struct); overlay's include/excludeTools derived DIRECTLY from policy (matches `overlayFingerprint`), NOT from `effectiveAllowedTools`. -3. ~~**M5 client**~~ ✅ **DONE** (green: typecheck 10/10, lint 0; web has NO test target — see [[no-web-tests]]). Built: `rpc/mcpState.ts` (atoms `mcpCatalogAtom`/`mcpBindingsAtom`/`mcpRuntimeAtom` + `applyMcpProjectionEvent`/`applyMcpRuntimeEvent` + `startMcpStateSync` + `useMcpCatalog`/`useMcpBindings`/`useMcpRuntimeMap`, mirrors `serverState.ts`). `rpc/wsRpcClient.ts` +`mcp` domain (`getSnapshot`/`subscribeProjection`/`subscribeRuntime`); mutations reuse `orchestration.dispatchCommand` (NO localApi/EnvironmentApi widening). `ru-fork/mcp-manage/adapters.ts` (pure contract→UI mappers: `catalogServerToRegistry`, `bindingToUi` w/ runtime join, `contractConfigToUi` MASKS secret refs→"", `uiConfigToDraft`, `runtimeStatusToUi`, `policyToToolOverrides`, `toggleToolPolicy`). `ru-fork/mcp-manage/useMcp.ts` (`useMcpRegistry`/`useMcpProjects`[from main `useStore` `selectProjectsAcrossEnvironments`]/`useMcpProjectBindings` + `useMcpMutations` dispatching 5 commands, re-brands ids via `.make()`). `store.ts` stripped to UI state only (kept pure selectors). `routes/__root.tsx` mounts ``. Swapped all 7 data/action components (RegistryTab/RegistryDetail/ProjectsTab/ProjectBindingRow/ProjectConfigDialog/McpServerDialog/AddToProjectControl) store→hooks; McpPanel unchanged (UI-only). ProjectsTab defaults to first project when none selected. **DELETED `fakeData.ts`.** Secret values are write-only client-side (masked on read; editing a secret server requires re-entering it). -4. **Tests** (server/package only — NO web) — mcp-core unit tests (port `mcp-probe` P1–P3 + resolver/policy/fingerprint); decider/projector mcp tests; overlay JSON + spawn-arg test via fakeAcp. **PENDING — confirm scope w/ user.** -5. **progress-log.md** — final doc in `ru-fork-instrumental/changes/mcp/` when 100% green. - -## Stop condition -User runs the app with real qwen and it works immediately (flags ON). My job = the 3 gates green (ignoring the 4 bin.test.ts) + the feature wired end-to-end. The "model actually calls tools" check is the user's real-env step (probe already proved the contract). diff --git a/mcp-specs/legacy/mcp-vars-redesign.md b/mcp-specs/legacy/mcp-vars-redesign.md deleted file mode 100644 index b99366e7341..00000000000 --- a/mcp-specs/legacy/mcp-vars-redesign.md +++ /dev/null @@ -1,381 +0,0 @@ -# MCP config model — vars/template (PLAN + progress) - -> ✅ DONE: the MCP UI has been reworked to the vars/template contracts. Server + web both green -> (typecheck 10/10, lint 0/0, test:fast only the 4 preexisting bin.test.ts). The ONLY remaining -> items are the two deferred server niceties in §C (secret-store GC of orphaned var secrets; -> catalog-edit warn-on-impact) — non-blocking, no compile/UX dependency. - - - -> Separate addition to `mcp-progress.md`. This is the resume anchor for the MCP -> **config-model v2** work. Nothing here is built yet — it's the agreed design + -> build plan. The earlier session's work (below, "Carried forward") is DONE and -> must not be regressed. -> -> Worktree: `/mnt/mac/Users/user/WORKSPACE/Projects/experements/ru-code/.claude/worktrees/mcp` -> Branch `ru-code`. Everything uncommitted. qwen-code source (read-only reference): -> `/mnt/mac/Users/user/WORKSPACE/Projects/experements/qwen-code`. - -## BUILD STATUS (update as you go — this is how we know where it broke) - -| Step | What | Status | -|---|---|---| -| 1 | Contracts (vars/template) + resolver + cache-key | ✅ server-green | -| 2 | Decider/secrets (splitServerVars/splitBindingVarValues/materialize) | ✅ server-green | -| 3 | Persistence `031` (vars_json, var_values_json, timeout_ms) + repos | ✅ server-green | -| 4 | Overlay (per-server literal env, incomplete exclude, 0600/0700 via atomicWrite) + reactor (incomplete skip, cache GC) | ✅ server-green | -| — | Secret-store GC on orphaned vars (reactor) | ⬜ TODO | -| — | Catalog-edit warn-on-impact | ⬜ TODO | -| 5 | Catalog UI (vars block + validation) | ✅ done | -| 6 | Project UI (holes + timeout + incomplete marker/dot) | ✅ done | -| 7 | mcp-core tests rewrite (resolver API) | ✅ done (18 tests pass) | -| D | McpDefaults builtins re-expressed in vars model (empty vars) | ✅ done | - -**ALL GATES GREEN:** typecheck 10/10, lint 0/0, test:fast (only 4 preexisting bin.test.ts). -Note: finishing the UI surfaced two server typecheck gaps the prior "server-green" claim -missed — `McpDefaults` still had v1 `env:{}`/no `vars`, and `McpRuntime` called the old -1-arg `configCacheKey`. Both fixed (catalog default keys on `(config, vars, {})`). - -### EXACT remaining work to finish v2 (resume here) - -**A. Web UI (the whole vars model in the client).** Today only `adapters.ts` (7) + `useMcp.ts` (2) ERROR, -but that's only because `types.ts` still has the v1 UI shape (`env`, `configOverride`, `timeoutMs` on config). -Doing it RIGHT means: -- `apps/web/src/ru-fork/mcp-manage/types.ts` — `McpServerConfig`: drop `env`/`timeoutMs` (stdio = command+args; - http = httpUrl+headers template strings). Add a UI `McpVar` type `{name,value,secret,perProject,required}` and - `vars` on `McpRegistryServer`. `McpProjectBinding`: drop `configOverride`; add `varValues: Record` - (masked for secrets), `incomplete: boolean` + `missingVars: string[]`, `timeoutMs?`. -- `adapters.ts` — `contractConfigToUi` (no env/timeout), `catalogServerToRegistry` maps `server.vars` (mask - secret values → "" ; per-project holes shown empty), `bindingToUi` maps `varValues` + computes `incomplete`/ - `missingVars` from the catalog server's vars. Drop the env/header secret-ref masking on config. -- `useMcp.ts` — `addServer`/`updateServer` send `{config, vars: McpServerVarDraft[], timeoutMs}`; binding mutation - sends `{varValues: Record, toolPolicy, enabled, timeoutMs}` (NO configOverride). Add a `setVarValues` - mutation. `useMcpRegistry` already joins catalog runtime — keep. -- `components/serverConfigForm.ts` — drop env/headers-as-secrets + timeout from the config draft; **add a vars - draft** (rows: name/value/secret/perProject/required). Keep headers as plain template lines for http. -- `components/ServerConfigFields.tsx` — template fields (command/args/url/headers) + the **vars block** (rows with - `[секрет]` / `[для проекта]`→`[обязательно]`, `+`). **Validate**: var name `[A-Z0-9_]+`/no dups/not PROJECT_CWD; - warn on `${X}` referencing an undeclared var; warn on `$NAME`/`${…}` inside a value (§D12). -- `components/McpServerDialog.tsx` — catalog add/edit using the template + vars block. -- `components/ProjectConfigDialog.tsx` — **holes-only**: list the catalog's `[для проекта]` vars (name read-only, - value editable, secret masked, required `*`) + allowed tools + timeout + enabled. No template fields. -- `components/ProjectBindingRow.tsx` — `⚠ требует настройки` marker (from `binding.incomplete`) + tooltip of - `missingVars`; gate the row's status. `components/ProjectsTab.tsx` — a dot on projects with ≥1 incomplete binding. -- `components/ConfigSummary.tsx` — render the template (no `config.env`); show vars summary instead. -- `store.ts` — drop `effectiveBindingConfig`/configOverride helpers; keep pure selectors. - -**B. mcp-core tests** — ✅ DONE. `mcpCore.test.ts` rewritten for the new API (18 tests). Server `test:fast` -green (only the 4 preexisting `bin.test.ts`). The server side is now genuinely ready (typecheck + tests). - -**C. Still-TODO server bits (deferred, non-blocking for compile):** secret-store GC of orphaned var secrets on -catalog edit/binding change (reactor — sibling of cache GC, use `collectVarSecretRefs`); catalog-edit warn-on-impact. -These are the ONLY remaining items; everything else (server + web) is done and green. - -**D. Defaults** — ✅ DONE. `McpDefaults.ts` builtins now carry `vars: []` (filesystem: `${PROJECT_CWD}` in args, -no vars; context7: http, no vars). Both compile + seed cleanly. - -### Web UI rework — DONE (the cascade, for the record) -`types.ts` (config=template, `McpVar`, binding gains `varValues`/`incomplete`/`missingVars`/`timeoutMs`) → -`adapters.ts` (`uiConfigToContract`, `uiVarsToDraft`, mask secrets, local `computeMissingVars`) → -`useMcp.ts` (add/update send `{config,vars,timeoutMs}`; `setProjectBinding` replaces `setBindingConfig`; bindings -join catalog) → `serverConfigForm.ts` (template-only draft + timeout/vars validation + `$…` warnings) → -`ServerConfigFields.tsx` (template fields) + new `VarsEditor.tsx` (vars block) + new `TimeoutField.tsx` → -`McpServerDialog.tsx` (template+vars+timeout, validation, warnings; JSON tab maps `env`→vars) → -`ProjectConfigDialog.tsx` (holes-only + timeout; empty ⇒ inherit) → `ProjectBindingRow.tsx` (`⚠ требует настройки` -marker + `server.config`) → `ProjectsTab.tsx` (amber dot on incomplete projects) → `ConfigSummary.tsx` -(template + vars summary, no `config.env`) → `store.ts` (dropped `effectiveBindingConfig`). -Write-only secrets: editing a secret-bearing server / per-project secret means re-entering it (empty ⇒ cleared/ -inherit) — honest, matches the server's full-replace var-values semantics. - -### Gotchas already hit (avoid re-hitting) -- v2 resolver `ResolveContext` dropped `processEnv` (was unused); context = `{projectCwd, secretValues}`. -- decider `mcp.binding-set` needs the catalog server's `vars` to split secret values — it now fetches the server - (note: `requireCatalogServer` is currently called twice in that branch; consolidate to one assignment). -- `materializeSecretValues(vars, varValues)` (was `(config)`); `splitServerVars`/`splitBindingVarValues` replace - `splitConfigSecrets`; `mcpVarSecretName` replaces `mcpSecretName`. -- overlay perms: `writeFileStringAtomically` now takes optional `{mode, dirMode}` — overlay passes `0o600`/`0o700`. - -Gates each step: `pnpm typecheck` (10/10), `pnpm lint` (0; flaky native-binding in this sandbox — retry), `pnpm test:fast` (only the 4 preexisting `bin.test.ts` may fail). Web has NO tests — typecheck+lint only. - ---- - -## THE PROBLEM (why we're doing this) - -Today a per-project binding can hold a full `configOverride` (any command/args/url/env). -So a project's "filesystem" binding can be edited into a *completely different* MCP -(postgres, whatever) while still being grouped under the catalog server's identity. -That's the inconsistency. **Field-level locking doesn't fix it** because `command` -is generic (`npx`/`uvx`/`bash`), so the identity lives *inside* `args` (the package -name) tangled with per-project params (a path) — you can't cleanly lock "args". - -## THE SOLUTION — catalog owns the template, binding owns only values - -- **Catalog server = a config *template* + a `vars` block.** Template = `transport, - command, args[], url, headers` with `${NAME}` holes. Vars = named values. -- **Binding = per-project only:** `{ varValues, toolPolicy, timeoutMs?, enabled }`. - **No `configOverride`.** A binding has *no field that can hold a different server* — - only values for the holes the catalog opened. **Identity is locked by construction.** -- **Runtime config is always derived** = catalog template + binding's var values + - `${PROJECT_CWD}`. Real divergence = an explicit new catalog entry (not a silent override). - -This is the same pattern as the official MCP registry / Claude Desktop config (declared -inputs you fill in) — not invented-here. - ---- - -## DECISIONS (every one, with why) - -### D1. Catalog = template + vars; binding = var values (no override) -**Why:** identity un-overridable by construction (binding can't represent another server). - -### D2. Var shape: `{ name, secret, perProject, required, value }` -- `[секрет]` → value stored in `ServerSecretStore` (masked, write-only in UI). -- `[для проекта]` → a per-project hole; **reveals** `[обязательно]` (required). -- `[обязательно]` (only when perProject) → must resolve to a value; drives incomplete-gating. -- catalog `value` = the fixed value / the per-project default / empty. - -Semantics table (the four perProject combos): - -| perProject | required | catalog value | meaning | -|---|---|---|---| -| off | — | set | **fixed/shared** — projects can't touch it | -| on | off | empty | optional hole — empty is fine | -| on | off | default | inherit default, overridable | -| on | on | empty | **must set per project** (e.g. `SPACE_ID`) → incomplete until filled | -| on | on | default | default satisfies it; overridable → never incomplete | - -`[секрет]` is orthogonal and applies to any row. **"Use a different token per project" -is NOT a separate feature** — it's just a per-project secret with a catalog default -(inherit by default, override per project). **Why:** redundant; collapses into the var model. - -### D3. Vars are dual-channel: env var (stdio) + `${NAME}` reference -Every var is exported as a process **env var** (name = var name) for stdio servers, -**and** usable as `${NAME}` in command/args/url/headers. -**Why:** env-based servers (the common case) work by *just declaring the var* — no -`${...}` needed, no double-declaration. Arg-based servers reference `${NAME}` in args. -For the common env-based server you literally just declare vars and stop. - -### D4. Substitution syntax: braced-only `${NAME}`, `$$` escape, single-pass, no shell -- Only `${NAME}` is a placeholder (**drop bare `$NAME`**). **Why:** bare `$NAME` is what - corrupts values like `P@$$W0RD` / `…$W0RD…`. -- `$$` → literal `$`. Single-pass (a substituted value is not re-scanned). -- `args` is an **array**, spawned without a shell → no quoting/injection; a value with - spaces is one argv element (substitution is per-token, on the template, before re-split). -- Resolution order: `PROJECT_CWD` (builtin) → declared var (**binding value ?? catalog - default**) → empty. - -### D5. Secrets are opaque + isolated -- Opaque: a secret value is inserted verbatim where referenced and **never re-expanded** - (fixes the `P@$$W0RD` corruption). **Why:** secrets can contain any characters. -- Stored in `ServerSecretStore`: catalog-level secret vars keyed per-server; per-project - secret values keyed per-binding. Masked/write-only in the client (only `{secretRef}` on - the wire). **Why:** keep secrets out of the event log / projections / client. - -### D6. `timeoutMs` moves from config → binding (per-project) -Effective timeout = `binding.timeoutMs ?? catalog.timeoutMs ?? 30s`. **Why:** it's a -per-project knob, not identity. Catalog keeps a default. - -### D7. Project surface = holes + tools + timeout + enabled -The project dialog shows: per-project var values (name **read-only**, value editable; -secret → masked input; required marked `*`) + allowed tools (`toolPolicy`) + timeout + -enabled. **No** transport/command/args/url/var-declarations. **Why:** all four are -per-project and non-identity; the rest is locked. - -### D8. Incomplete binding = computed + gated + marked (never silently broken) -"Incomplete" = (template's required holes) − (binding's filled values). **Derived, no -stored field.** Threaded through 3 places: -- **Reactor** `computeDesired` **skips** incomplete bindings → not probed. -- **Overlay** **excludes** incomplete bindings → qwen never spawns with an empty required value. -- **UI**: amber `⚠ требует настройки` on `ProjectBindingRow` (distinct from connection - status) + tooltip listing the missing var names; a **dot** bubbles to the project in the - Projects tab. -**Asymmetry (intentional):** missing-required → loud (marker + gated); orphaned value -(var removed from catalog) → **silent auto-prune**, no marker. **Why:** the user must act -on the former; nothing to do for the latter. - -### D9. Catalog-edit lifecycle: not locked, warn + auto-reconcile -- **Never lock** the catalog (you must be able to fix it). -- **Warn on impact**: removing a per-project var ("значения «X» в N проектах будут - удалены"); adding a required hole ("N проектов нужно будет настроить"). -- **Auto-prune** orphaned `varValues` + orphaned secrets (secret-store GC — sibling of the - cache GC). Newly-required holes → bindings flagged incomplete. Identity/shared changes → - propagate to all + restart on fingerprint change (existing mechanism). -**Why:** consistency without rigidity; can't leave a project silently broken. - -### D10. qwen re-substitutes our overlay — SOURCE-VERIFIED -qwen-code runs `resolveEnvVarsInObject` over the **whole** loaded settings, including our -`mcpServers` entries. Facts (verified in qwen-code): -- `envVarResolver.ts`: regex `/\$(?:(\w+)|{([^}]+)})/g`; substitutes from qwen's - **process.env**; **single-pass**; **undefined var → placeholder preserved**; **NO escape - character**; only values, not keys. -- `settings.ts:138/654/684`: `QWEN_CODE_SYSTEM_SETTINGS_PATH` (our overlay) → `systemResult` - → `resolveEnvVarsInObject(systemResult.settings)`. `mcpServers` is a settings field - (`settingsSchema.ts:181`). -- `mcp-client.ts:1404`: stdio child env = `{ ...process.env, ...config.env }`. -- `AcpSessionRuntime.ts:234` (ru-code): spawn env = `{ ...process.env, ...options.spawn.env }` - → qwen's process.env = ru-code base env + what we inject. - -### D11. Delivery DECISION: per-server **literal** `config.env` — NOT ref-ification -We considered "ref-ification" (write `${RU_MCP_n}` refs, put real values in qwen's -process.env, let qwen substitute). **REJECTED.** **Why:** qwen spawns *every* MCP child -with `{ ...process.env, ... }`, so any value in qwen's process.env leaks to **all** MCP -children — a Confluence server could read your GitHub token; a malicious `npx` MCP could -exfiltrate every secret. Instead, write each server's resolved values into its **own** -`mcpServers[].env` block (per-server) — qwen applies that block only to that server's -child, so **secrets are isolated per MCP**. We do all substitution ourselves and write -literals, so qwen's resolver is a **no-op** on clean values. - -### D12. The only residual risk: `$ENVNAME` corruption — rare, warned -Because there's no escape char in qwen and we write literals, a value containing `$` + -an **exact env-var name** (`$HOME`, `${PATH}`, …) is still expanded/corrupted by qwen. -- Tokens/keys are **zero-risk** (no `$` in their charset). `wor$ld`/`P@$$w0rd` survive - (name after `$` isn't a real env var → preserved). Only `my$HOME`-style collides. -- Unfixable without re-introducing the cross-MCP leak. So **warn at authoring** if a value - contains `$NAME`/`${…}`. Note qwen's `$VAR` expansion is *also useful* for intentional - machine-env refs (`$HOME/cache`, `$AWS_REGION`) — only literal-`$ENVNAME` is the hazard. - -### D13. Overlay file perms MUST be `0600` / dir `0700` (current GAP) -`ServerSecretStore` is plaintext `.bin` @`0o600` in a `0o700` dir — **not encrypted**; its -job is "out of events/client" + owner-only perms. The overlay (`McpOverlay` via -`atomicWrite.ts` / `mcpOverlayDir`) is currently written with **default perms (~0644, -world-readable)** — so writing resolved secrets there is a **real downgrade**. Fix: write -`system.json` at `0o600` inside a `0o700` `mcpOverlayDir`. Then "secret in overlay" = "secret -in store" (same owner-only-plaintext boundary). **Why:** plaintext-at-spawn is unavoidable -(qwen needs it); minimize footprint + match the store's protection. - -### D14. Coverage (audited) -Covers: stdio (npx/uvx/node/python/docker), HTTP/Streamable, **SSE** (qwen auto-fallback on -the http url), env-based, arg-based, URL/path params, Bearer/API-key/Basic auth, per-project -params, shared+per-project secrets w/ defaults, required holes, repeated/bool/`--k=v` flags, -`${PROJECT_CWD}`, per-server timeout, tool allow/deny, any-chars secrets. -Gaps (verdicts): **OAuth** servers → qwen's domain (`authProviderType`/`createTransportWithOAuth`), -not ours; **generated config files** → out of scope (we pass paths, not contents); **conditional -/omit-if-empty args** → minor, future flag; **custom process cwd** → minor, future field. All -non-breaking future additions. - -### D15. This REPLACES the env-secret-refs + configOverride model (v1) — unreleased -Edit migration `031` **in place** (no stacking, no data migration). Re-express builtins -(`McpDefaults`) in the vars model. - ---- - -## CHANGE SURFACE (by layer, with files) - -**1. Contracts** (`packages/contracts/src/ru-fork/mcp.ts`, `rpc.ts`, `settings.ts`) -- `McpStdioConfig = {transport, command, args[]}`; `McpHttpConfig = {transport, httpUrl, - headers: Record}` — **remove env/headers secret-refs + `timeoutMs` from config**. -- New `McpServerVar {name, secret, perProject, required, value}`; `McpCatalogServer` gains - `vars: McpServerVar[]` + a default `timeoutMs`. -- `McpBinding`: drop `configOverride`; add `varValues: Record` + - optional `timeoutMs`. -- Drafts/patches carry plaintext var values inbound (`McpServerDraft`, `McpBindingPatch`). -- (rpc.ts `mcp.recheck` etc. from v1 stay.) - -**2. Resolver** (`packages/mcp-core/src/resolver.ts`, `fingerprint.ts`) -- Braced-only `${NAME}`; order PROJECT_CWD → var(bindingValue ?? catalogDefault) → empty; - `$$`→`$`; single-pass; opaque secrets (verbatim insert). -- Build env = all user vars; substitute into command/args/url/headers. -- Replace `effectiveConfig` (override-or-default) with "resolve template + binding var values". -- `configCacheKey`/`dedupHash` now hash the **resolved** template+vars (so per-project var - diffs split probes; identical collapse — keeps the neutral-cwd collapse from v1). - -**3. Decider/secrets** (`apps/server/src/ru-fork/mcp/McpSecrets.ts`, `McpSecretNames.ts`, -`McpCatalogBuilders.ts`, decider branches) -- Split **secret vars** → store (catalog vars per-server; per-project secret values per-binding). -- **Secret-store GC**: prune orphaned secrets when a secret var/value is removed. -- `materializeSecretValues` resolves the refs at probe/overlay time. - -**4. Persistence** (single `031`, edited in place; `ProjectionMcpCatalog.ts`, -`ProjectionMcpBinding.ts`, `Services/McpCatalog.ts`, `McpBinding.ts`) -- `mcp_catalog_server`: add `vars_json`; `config_json` loses env/secret refs. -- `mcp_project_binding`: replace `config_override_json` with `var_values_json` + `timeout_ms`. - -**5. Overlay + reactor** (`McpOverlay.ts`, `McpReactor.ts`, `McpSupervisor.ts`, `McpRuntime.ts`, -`atomicWrite.ts`/`config.ts` for perms) -- Resolve template+vars+varValues → **per-server literal `config.env`** (+ command/args/headers/url), - effective timeout. **No ref-ification, no `RU_MCP_*`, nothing extra in qwen's process env.** -- **Overlay perms**: write `system.json` `0o600` in a `0o700` `mcpOverlayDir` (atomicWrite gains a - mode; ensure dir mode). -- **Incomplete gating**: reactor `computeDesired` skips incomplete bindings; overlay excludes them. -- **Catalog-edit**: warn-on-impact + auto-prune orphaned varValues + secret GC (reactor). -- (Spawn `CliAcpSupport`/`CliAdapter` need **no change for values** — literal config.env lives in - the overlay file, not the spawn env. The `settingsEnv` idea was only for ref-ification → DROPPED.) - -**6. Catalog UI** (`apps/web/src/ru-fork/mcp-manage/`: `serverConfigForm.ts`, `ServerConfigFields.tsx`, -`McpServerDialog.tsx`) -- Template fields + **vars block** (rows: `name`, `value`, `[секрет]`, `[для проекта]`→`[обязательно]`, - `+`). Remove old env/header secret editors. -- **Validate**: var names `[A-Z0-9_]+`, no dups, not `PROJECT_CWD`; warn on `${X}` referencing an - undeclared var; **warn on `$NAME`/`${…}` inside a value** (D12). - -**7. Project UI** (`ProjectConfigDialog.tsx`, `ProjectBindingRow.tsx`, `ProjectsTab.tsx`, -`adapters.ts`, `useMcp.ts`, `types.ts`) -- Project dialog → holes (name read-only, value editable, secret masked, required `*`) + allowed - tools + timeout + enabled. No template fields. -- `ProjectBindingRow`: `⚠ требует настройки` marker + tooltip of missing vars; `ProjectsTab`: dot. -- adapters/useMcp: map `vars`/`varValues`, mask secrets, compute `incomplete` + missing list. - -**8. Defaults + tests** (`McpDefaults.ts`, server-side tests) -- Re-express builtins (filesystem: `${PROJECT_CWD}` in args, no vars; context7: http, no/opt var). -- Tests: resolver (braced-only, `$$`, opaque secrets, per-project override, env-export, - PROJECT_CWD, undeclared→empty); decider secret-split + GC; cache-key w/ vars; incomplete-gating - decision; overlay perms. - ---- - -## BUILD ORDER (green at each step; update STATUS table above) - -1. **Contracts + resolver + cache-key + unit tests** (model only, nothing wired). -2. **Decider/secrets + secret GC** (commands carry vars/varValues; split + prune). -3. **Persistence `031`** (columns). -4. **Overlay + reactor** (per-server literal env, incomplete gating, **overlay perms 0600/0700**, - edit-warn/prune). -5. **Catalog UI** (vars block + validation). -6. **Project UI** (holes + tools + timeout + enabled + incomplete marker/dot). -7. **Defaults + full gates** green. - ---- - -## CARRIED FORWARD — v1 work that is DONE and must NOT regress - -(From the prior session; see `mcp-progress.md`.) All green: typecheck 10/10, lint 0, -test:fast 722 pass (only 4 preexisting `bin.test.ts`). -- **Neutral-cwd probe + per-configKey collapse** — `${PROJECT_CWD}` resolves to `config.mcpProbeCwd` - for probes; one probe/cache row per authored config; catalog defaults probed (unbound shown). -- **Catalog runtime** (`McpCatalogRuntimeSnapshot`, per-serverId by configKey) + single full-snapshot - runtime stream. `toolsCache` deleted. -- **Manual recheck** (`mcp.recheck` RPC, supervisor `recheck` w/ in-flight coalescing, `RecheckButton` - in catalog/row/header). -- **Universal `useActiveProject`** hook (`useActiveProjectRef`/`useActiveProject`) — **note the bug we - fixed:** the draft-store selector must return PRIMITIVES, never a fresh object (React #185 loop). -- **Panel hoist** (`McpPanelMount` in `_chat.tsx`, single mount). -- **`monitoring` settings flag removed** (replaced by the two interval fields). -- **Cache GC** (`deleteKeysNotIn`) in reactor. -- Tests: `mcpCore.test.ts`, `McpProbeCache.test.ts`, `supervisorDecisions.test.ts`. - -⚠️ **v2 interaction:** `configCacheKey` must now incorporate resolved vars (D11/step 1). The catalog -runtime + recheck still key off configKey — keep them working after the binding/config shape changes. - ---- - -## CONSTRAINTS (from memory — hold these) -- Logs: only `Effect.logDebug` / `Effect.logError` (never info/warn). -- No `as`/`any`/`as const`/`as unknown` casts (one justified `as Transport` in `probe.ts`). -- Mark ru-fork deltas with `ru-fork:` comments; leave t3-cloned code unmarked. -- Effect 4 beta.59: `Effect.catch` (not catchAll), `.toSorted()`, exactOptionalPropertyTypes - (conditional-spread optionals, never `k: undefined`), `Ref.modify` tuple via explicit return type - (no `as const`). -- **Single `031` migration** — edit in place, never stack `032`. -- Secrets: env/header/var values are ALWAYS secret refs in authored config; decider splits - plaintext→`ServerSecretStore`; overlay/probe materialize. -- MCP logic in `ru-fork/` helpers; minimize common-file edits (thin seams only). -- Web has NO test target — validate web with typecheck+lint only. -- Leave everything uncommitted. -- qwen is on another machine — NEVER run qwen or the project; hand the user a runnable artifact. - ---- - -## OPEN QUESTIONS -None. Design is fully settled (catalog template + vars; identity locked; per-project = values -only; secrets opaque + per-server isolated; overlay 0600/0700; `$ENVNAME` warned). Coverage gaps -(OAuth/config-files/conditional-args/custom-cwd) are out-of-scope future additions, non-breaking. diff --git a/pixso-move/STATUS.md b/pixso-move/STATUS.md deleted file mode 100644 index cec33d65370..00000000000 --- a/pixso-move/STATUS.md +++ /dev/null @@ -1,129 +0,0 @@ -# pixso-move — build status - -Snapshot of what's implemented in this worktree (`ru-fork/pisxo-move`). The design specs live in -[`specs/`](./specs/); this file records the **actual state** + decisions that differ from the specs. - -**Updated:** 2026-06-14 - -## Where we are - -| Package | Lint | Typecheck | Tests | Coverage | Build | Spec | -|---|---|---|---|---|---|---| -| `@pixso-move/contracts` | ✅ 0 | ✅ | 34 | **100%** | — | [02](./specs/02-contracts.md) ✅ | -| `@pixso-move/processor` | ✅ 0 | ✅ | 36 | **100%** | — | [04](./specs/04-processor.md) ✅ | -| `@pixso-move/server` | ✅ 0 | ✅ | 27 | **100%** | ✅ `dist/bin.mjs` | [03](./specs/03-server.md) · [05](./specs/05-embed.md) ✅ | -| `@pixso-move/plugin` | ✅ 0 | ✅ | 32 | (pure helpers) | ✅ `code.js`+`ui.html` | [06–08](./specs/06-plugin-build.md) ✅ | -| web `ru-fork/pixso-move` | ✅ 0 | ✅ | — (no web tests) | — | (typecheck+lint) | reader panel — below | - -**Done:** Tasks 1 (scaffold), 2 (contracts), 3 (server), 4 (processor engine), 5 (embed), -6–8 (plugin), **plus the web "Макеты Pixso" reader panel** (new — see below). -**Deferred:** Task 9 — manual smoke against real qwen on the user's machine. - -## How to run - -Server (from the worktree root): -```bash -# dev (watch) -NODE_OPTIONS='--experimental-strip-types --experimental-sqlite' pnpm --filter @pixso-move/server dev -# built -pnpm --filter @pixso-move/server build -node --experimental-sqlite pixso-move/server/dist/bin.mjs start --port 7787 -# verify -curl -H "x-designer-id: dz_test" http://localhost:7787/nodes # → [] 200 -``` -Plugin: -```bash -pnpm --filter @pixso-move/plugin dev # browser dev server — inspect UI without Pixso -pnpm --filter @pixso-move/plugin build # → dist/code.js + dist/ui.html, import manifest.json into Pixso -``` -Gates: `pnpm -w lint` · `turbo run typecheck --filter='@pixso-move/*'` · `turbo run test --filter='@pixso-move/*'`. - -> **Env gotcha:** in some shells `pnpm install` purges modules and reinstalls **macOS** native -> bindings, breaking oxlint/vite/vitest on linux. Always use -> `pnpm install --config.confirmModulesPurge=false`. (`tsc` is binding-free.) - -## Architecture (actual) - -``` -pixso-move/ - contracts/ effect Schema: ids, ingest, node, processing, tagged errors (DesignerId/NodeId/ResultTag …) - server/ Effect + effect-platform HTTP + node:sqlite (vendored NodeSqliteClient). Embeds the processor seam. - processor/ ONLY the Processor service tag + NoopProcessorLive (real engine = Task 4) - plugin/ Pixso plugin: code/ (sandbox) + ui/ (iframe React, vendored ru-code kit + themes) -``` - -Data model (sqlite, 2 tables): `nodes` (immutable) + `processing_results` (status-tracked ledger). -HTTP: `POST /ingest`, `GET /nodes`, `GET /node?id=`, `GET /processing-data?nodeId=` — all gated by -the `x-designer-id` header. - -## Decisions / deviations from the specs (read before continuing) - -**Server** -- `GET /node?id=` (query param), **not** `/nodes/:id` — a path param is always present when matched, - leaving an uncoverable branch; query-param is fully testable. -- Oversize preview → **400** (invalid payload), not 413 (the `Base64Png` max-length rejects it). -- Atomic claim uses SQL `RETURNING`. DB errors `orDie` → HTTP 500 via the shared `route()`/`respond()`. -- The processor is a **no-op seam** (`NoopProcessorLive`); ingest calls `Processor.notify` (currently - a void). `/processing-data` returns `[]` until Tasks 4–5. - -**Plugin (lots of ru-code-fidelity iteration — keep faithful)** -- **Storage:** settings AND theme persist via `pixso.clientStorage` (sandbox), **never localStorage** — - the Pixso iframe is sandboxed/opaque-origin and `localStorage` throws `SecurityError` (this caused a - black-screen crash). `themeName`+`themeMode` are folded into the `StoredSettings` blob. -- **Theme:** all 7 ru-code themes + `index.css` vendored. The web's `useTheme` hook is **not** reused - (it's localStorage-bound); `ui/theme.ts` is a minimal `applyTheme` + constants instead. Theme picker - uses the vendored `Select`. -- **Settings page = ru-code `GeneralSettingsPanel`:** vendored `settingsLayout.tsx` - (`SettingsSection`/`SettingsRow`/`SettingResetButton`/`SettingsPageContainer`) + `tooltip.tsx`. - Real-time (no Save button); per-field reset icons + a header **"Сбросить настройки"** matching - `routes/settings.tsx`'s `RestoreDefaultsButton`. Back button matches mcp `RegistryTab` (ghost `sm`, - `ChevronLeftIcon size-4`). Settings gear = `variant="outline" size="icon-xs"` (matches mcp/diff - header buttons). UI is Russian, no tech jargon. **Keep base+sm responsive classes as ru-code wrote - them — do NOT change the breakpoint.** -- **Key generation:** `crypto.randomUUID()` throws in the sandbox iframe → `key.ts` builds a v4 UUID - from `getRandomValues` with a `Math.random` fallback. -- **Preview perf:** display preview exports at **capped width 640** (cheap); the **full 1× export - happens only on send** (`handleCollect`) so the server keeps a pixel-perfect copy. Full-res on every - select previously froze the plugin 30–50s. -- **Preview correctness:** state tracks `selectedNodeId`; preview resets when the node changes; - `preview-ready` carries `nodeId` and is ignored if stale (frame switched mid-export). - -## Processor (Tasks 4 + 5 — done) - -- `@pixso-move/processor`: pure helpers (`prompt`/`extract`/`reconcile`/`acp/collect`/`acp/handshake`), - the contained `drain.runOneJob` (any failure/defect → `error` row + `logError`, never escapes), and - `engine.makeProcessor` (recover → reconcile → claim → run loop, `notify` serialized via a `Ref` - state machine, `Schedule.fixed` poll timer, `catchCause` so a tick can't kill the loop). The real - ACP runner is `acp/acpRunnerLive.integration.ts` (the only coverage-excluded file — real qwen - spawn glue), exposed as `makeAcpRunnerLayer(opts)`. -- **Config is the only hardcoded part** (`processor/src/config.ts`): designer - `dz_c07a93f7-2505-4e60-94af-17a2cc068b79`, tag `html-css`, the simple prompt. **CLI path, home, - and auth method are NOT hardcoded** — they come from `ServerConfig` (`--cli-js` / `--cli-home` / - `--cwd` / `--no-ssl` flags; auth defaults to `"openai"`). `cliJs === ""` ⇒ no qwen configured ⇒ - jobs still run but fail gracefully into `error` rows (server never crashes). -- **Embed:** `server/src/services/processorLive.ts` builds `ProcessorDeps` from the stores + the - `AcpRunner` service and starts/stops the processor with the layer (scoped). `server.ts` wires - `ProcessorLive`; `bin.ts` provides the real `makeAcpRunnerLayer` (+ `NodeServices` spawner). - `POST /ingest` calls `Processor.notify`, contained so a notify failure never breaks the request. - Tests provide a `FakeAcpRunner` → end-to-end in-memory (ingest → `done`) with no qwen. - -## Web reader panel (`apps/web/src/ru-fork/pixso-move/`) - -A developer-facing reader, mirroring the MCP right-panel pattern, isolated in one ru-fork folder: -- **Left nav:** `PixsoNavGroup` adds a block under search / above projects — **«Макеты Pixso»** - (opens the panel) and **«MCP Серверы»** (inert placeholder). Hosted **app-wide** from - `AppSidebarLayout` (inline sidebar on desktop, `RightPanelSheet` on narrow screens), so it opens - from anywhere. -- **Panel:** header (refresh / settings gear / close) → gallery of preview thumbnails → - node detail (preview + node JSON via `ChatMarkdown` ```json + collapsible per-result code blocks - from `/processing-data`) → settings (server URL + designer id + sync interval `NumberField`, - min 5, real-time, persisted to `localStorage`). **Manual refresh only** (the sync interval is - stored for later; no polling yet — per the product decision). -- Reads the pixso-move server (`/nodes`, `/node?id=`, `/processing-data?nodeId=`) with React Query, - gated by the `x-designer-id` header. Validated by **typecheck + lint** (apps/web has no test target). - -## Next - -- **Task 9** — manual smoke against real qwen: run the server with `--cli-js ` - (+ `--cli-home` if needed), ingest from the plugin for the configured designer, watch the - `html-css` result transition to `done`, then view it in the web panel. diff --git a/pixso-move/specs/00-overview.md b/pixso-move/specs/00-overview.md deleted file mode 100644 index 26d70a01849..00000000000 --- a/pixso-move/specs/00-overview.md +++ /dev/null @@ -1,164 +0,0 @@ -# 00 — Overview: data model, contracts, endpoints - -The single source of truth for the **shapes** every package agrees on. Tasks 2–5 implement this; -tasks 6–8 produce/consume it from the plugin. - ---- - -## Glossary - -| Term | Meaning | -|---|---| -| **designerId** | The designer's key. An opaque non-empty string the designer generates once in the plugin and saves. Used everywhere as both identity and access token. Called `authorKey` in the original sketch — we use **`designerId`** everywhere. | -| **node record** | One immutable submission: the serialized Pixso node subtree + a preview image + the designerId + when it arrived. Identified by a server-generated **`nodeId`**. | -| **processing result** | The outcome of running one configured prompt against one node record. Status-tracked. Identified by `(nodeId, resultTag)`. | -| **resultTag** | A short label naming what a prompt produces (e.g. `react`, `summary`, `a11y`). Lets multiple prompts run against the same node, each producing a distinct result. | - ---- - -## Data model (sqlite, 2 tables) - -### `nodes` — immutable ingestion records -| column | type | notes | -|---|---|---| -| `id` | TEXT PRIMARY KEY | the **nodeId**, server-generated UUID | -| `designer_id` | TEXT NOT NULL | indexed | -| `root_name` | TEXT NOT NULL | the selected node's name (for the developer list) | -| `nodes_json` | TEXT NOT NULL | serialized node subtree (JSON string) | -| `preview` | TEXT NOT NULL | base64 PNG (1×), no `data:` prefix | -| `added_at` | TEXT NOT NULL | ISO-8601 (`DateTime.formatIso`) | - -Index: `idx_nodes_designer ON nodes(designer_id, added_at)`. -This table is **append-only** — never mutated after insert. - -### `processing_results` — the job ledger AND the output -One row per `(node × configured prompt)`. Carries both lifecycle status and the produced result. -| column | type | notes | -|---|---|---| -| `id` | TEXT PRIMARY KEY | server-generated UUID | -| `designer_id` | TEXT NOT NULL | denormalized from the node (for key-gated reads), indexed | -| `node_id` | TEXT NOT NULL | FK → `nodes(id)` | -| `result_tag` | TEXT NOT NULL | from processor config | -| `status` | TEXT NOT NULL | `pending` \| `processing` \| `done` \| `error` | -| `attempts` | INTEGER NOT NULL DEFAULT 0 | incremented each run attempt | -| `result` | TEXT | the LLM output text; NULL until `done` | -| `error` | TEXT | failure message; NULL unless `error` (or last failed attempt) | -| `created_at` | TEXT NOT NULL | when the job row was created | -| `started_at` | TEXT | when last claimed (→ `processing`) | -| `finished_at` | TEXT | when it reached `done`/`error` | - -Constraints / indexes: -- `UNIQUE(node_id, result_tag)` → reconciliation is idempotent (`INSERT … ON CONFLICT DO NOTHING`). -- `idx_results_status ON processing_results(status)` → fast claim scan. -- `idx_results_designer_node ON processing_results(designer_id, node_id)` → fast reads. - -> **Why this design (status off the `nodes` table):** the product owner requires full -> observability and control — monitor status, mark errors, retry later, finish unfinished work -> after a crash. We get all of that from the ledger while keeping `nodes` immutable. Adding a -> **new** prompt to config later auto-creates `pending` rows for existing nodes (reconciliation -> via the UNIQUE key), so new prompts backfill historical nodes. - ---- - -## Processing lifecycle (state machine) - -``` - reconcile (INSERT new (node,tag)) - │ - ▼ - ┌────────────┐ claim ┌─────────────┐ ACP ok ┌────────┐ - │ pending │──────▶│ processing │───────▶│ done │ - └────────────┘ └─────────────┘ └────────┘ - ▲ ▲ │ ACP fail - │ │ ▼ - │ │ ┌──────────┐ - │ └────────────│ error │ (terminal; manual/future retry resets to pending) - │ crash recovery└──────────┘ - │ (processing→pending on startup, unbounded; attempts NOT incremented by recovery) -``` - -- **claim** is atomic: `UPDATE … SET status='processing', started_at=…, attempts=attempts+1 - WHERE id=? AND status='pending'` — the row is only ours if `changes === 1`. -- **crash recovery** on startup: `UPDATE … SET status='pending' WHERE status='processing'` (work - interrupted mid-flight resumes). Recovery does **not** bump `attempts`. -- **retry** (future): a config flag may auto-reset `error` rows with `attempts < maxAttempts` back - to `pending`. The columns already support it; the loop hook is specified in - [04-processor.md](./04-processor.md) but defaults OFF. - ---- - -## HTTP API (plain HTTP + effect Schema; `effect/unstable/http`) - -Auth: every endpoint requires the header **`x-designer-id: `**. A request whose body -or query `designerId` disagrees with the header is rejected. Reads are scoped to that designerId — -you can only read what your key owns. CORS is open (`access-control-allow-origin: *`) because the -plugin posts cross-origin; `x-designer-id` is added to the allowed headers. - -| Method | Path | Auth | Request | Response (200) | Errors | -|---|---|---|---|---|---| -| `POST` | `/ingest` | header | `IngestRequest` (body) | `IngestResponse` `{ nodeId }` | 400 invalid, 401 missing key | -| `GET` | `/nodes` | header | — | `NodeSummary[]` (this designer, newest first) | 401 | -| `GET` | `/nodes/:id` | header | — | `NodeRecord` (full, key-scoped) | 401, 404 | -| `GET` | `/processing-data` | header | `?nodeId=…` | `ProcessingResult[]` for that node | 400, 401, 404 | -| `OPTIONS` | `*` | — | CORS preflight | 204 | — | - -> `GET /nodes` returns **summaries** (no `nodes_json`, to keep the list light) — id, rootName, -> addedAt, preview. `GET /nodes/:id` returns the full record including `nodes_json`. -> `GET /processing-data` returns all result rows (every `resultTag`, every status) for one node so -> the developer sees progress and errors, not just finished output. - -### Contract shapes (effect Schema — defined in `@pixso-move/contracts`, see [02](./02-contracts.md)) - -```ts -DesignerId = TrimmedNonEmptyString.pipe(Schema.brand("DesignerId")) // ≤ 200 chars -NodeId = TrimmedNonEmptyString.pipe(Schema.brand("NodeId")) -ResultTag = TrimmedNonEmptyString.pipe(Schema.brand("ResultTag")) // ≤ 64 chars -Base64Png = TrimmedNonEmptyString // ≤ ~8 MB guard - -IngestRequest = { designerId, rootName, nodesJson: string, preview: Base64Png } -IngestResponse = { nodeId } -NodeSummary = { nodeId, rootName, addedAt, preview } -NodeRecord = { nodeId, designerId, rootName, nodesJson, preview, addedAt } -ProcessingStatus = "pending" | "processing" | "done" | "error" -ProcessingResult = { nodeId, resultTag, status, attempts, result: string|null, - error: string|null, createdAt, startedAt: string|null, - finishedAt: string|null } - -// tagged errors: -IngestError ("IngestError", { message, status }) // 400/413 -AuthError ("AuthError", { message, status }) // 401 -NodeNotFoundError ("NodeNotFound", { message, status }) // 404 -``` - -`nodesJson` is carried as an opaque validated **string** (already-serialized JSON from the plugin). -The server stores it verbatim; it does not re-parse the node tree. (Keeps the contract simple and -the server agnostic to Pixso's node schema, which can change.) - ---- - -## Processor config (`@pixso-move/processor`) - -A plain TS file — `src/config.ts` — the operator edits: -```ts -export const processorConfig: ProcessorConfig = { - entries: [ - { designerId: "dz_alice", prompt: "Generate a React component for this design.", resultTag: "react" }, - { designerId: "dz_alice", prompt: "Summarize this screen for a PM.", resultTag: "summary" }, - { designerId: "dz_bob", prompt: "List accessibility issues.", resultTag: "a11y" }, - ], -}; -``` -- The processor only acts on nodes whose `designerId` matches a config entry. Nodes from unknown - designers are stored by the server but **never processed**. -- Multiple entries for one designer → multiple result rows per node (distinct `resultTag`). -- Adding/removing/editing entries is picked up on next process start (and reconciliation backfills - new `(node, resultTag)` pairs for existing nodes). - ---- - -## Deployment (decided) - -- The **processor runs embedded in the server process** (one Effect runtime). Ingest calls the - processor's `notify()` for low-latency pickup; a poll timer is the backstop. Single sqlite - writer → no cross-process `SQLITE_BUSY`. See [05-embed.md](./05-embed.md). -- The DB file path comes from server config (default `./.data/pixso.sqlite`; `:memory:` in tests). diff --git a/pixso-move/specs/01-scaffold.md b/pixso-move/specs/01-scaffold.md deleted file mode 100644 index f29fc1f6673..00000000000 --- a/pixso-move/specs/01-scaffold.md +++ /dev/null @@ -1,209 +0,0 @@ -# Task 1 — Scaffold - -Stand up the four packages, wire them into the monorepo, and confirm the `effect-acp` reuse path. -**No business logic** — just buildable, typecheckable, lintable empty packages. - -## Deliverables - -### Shared config (DRY — created once at `pixso-move/`) -``` -pixso-move/ - tsconfig.base.json extends ../tsconfig.base.json; common compilerOptions for all packages - vitest.base.ts export makeVitestConfig(dir) (see conventions §5) -``` -`pixso-move/tsconfig.base.json`: -```jsonc -{ "extends": "../tsconfig.base.json", - "compilerOptions": { "composite": true, "types": ["node"], "lib": ["ESNext"] } } -``` -Every package's `tsconfig.json` then reduces to: -```jsonc -{ "extends": "../tsconfig.base.json", "include": ["src", "tests"] } -``` -(Server adds `"lib": ["ESNext","esnext.disposable"]`; plugin overrides `lib`/`jsx`/`types`/`paths` -— see [06](./06-plugin-build.md).) Every package's `vitest.config.ts` is the 3-line call from -conventions §5. **No coverage/compiler options are duplicated per package.** - -### Packages -``` -pixso-move/ - contracts/ - package.json @pixso-move/contracts - tsconfig.json - vitest.config.ts - src/index.ts (re-exports; empty for now) - tests/.gitkeep - server/ - package.json @pixso-move/server - tsconfig.json - vitest.config.ts - tsdown.config.ts - src/bin.ts (entrypoint stub) - tests/.gitkeep - processor/ - package.json @pixso-move/processor - tsconfig.json - vitest.config.ts - src/index.ts - tests/.gitkeep - plugin/ - package.json @pixso-move/plugin - tsconfig.json - manifest.json (filled in task 6) - src/.gitkeep -``` - -## package.json templates - -Use the monorepo's `catalog:`/`workspace:*` conventions (verified against -`apps/server/package.json`, `packages/contracts/package.json`). - -**contracts** -```jsonc -{ - "name": "@pixso-move/contracts", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" } }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "vitest run --coverage", - "test:fast": "vitest run" - }, - "dependencies": { "effect": "catalog:" }, - "devDependencies": { - "@effect/vitest": "catalog:", "vitest": "catalog:", "@vitest/coverage-v8": "catalog:" - } -} -``` - -**server** (depends on contracts + processor + effect-acp; node:sqlite is built-in) -```jsonc -{ - "name": "@pixso-move/server", - "version": "0.0.0", - "private": true, - "type": "module", - "bin": { "pixso-move-server": "./dist/bin.mjs" }, - "scripts": { - "dev": "node --watch src/bin.ts start", - "build": "tsdown", - "start": "node dist/bin.mjs start", - "typecheck": "tsc --noEmit", - "test": "vitest run --coverage", - "test:fast": "vitest run" - }, - "dependencies": { - "@pixso-move/contracts": "workspace:*", - "@pixso-move/processor": "workspace:*", - "effect": "catalog:", - "@effect/platform-node": "catalog:" - }, - "devDependencies": { - "@effect/vitest": "catalog:", "vitest": "catalog:", "@vitest/coverage-v8": "catalog:", - "tsdown": "catalog:" - } -} -``` -> If a needed dep is not yet in the root `catalog:` (e.g. `@effect/platform-node`), add it to the -> catalog in `pnpm-workspace.yaml` rather than pinning an ad-hoc version — match `apps/server`. - -**processor** (depends on contracts + effect-acp; effect-acp is unscoped `effect-acp`) -```jsonc -{ - "name": "@pixso-move/processor", - "version": "0.0.0", - "private": true, - "type": "module", - "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" } }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "vitest run --coverage", - "test:fast": "vitest run" - }, - "dependencies": { - "@pixso-move/contracts": "workspace:*", - "effect": "catalog:", - "effect-acp": "workspace:*", - "@effect/platform-node": "catalog:" - }, - "devDependencies": { - "@effect/vitest": "catalog:", "vitest": "catalog:", "@vitest/coverage-v8": "catalog:" - } -} -``` - -**plugin** (versions are EXACT — must match `apps/web` so the vendored UI is identical) -```jsonc -{ - "name": "@pixso-move/plugin", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "pnpm build:code && pnpm build:ui", - "build:code": "vite build --config vite.code.config.ts", - "build:ui": "vite build --config vite.ui.config.ts", - "dev:code": "vite build --config vite.code.config.ts --watch", - "dev:ui": "vite build --config vite.ui.config.ts --watch", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "react": "19.2.5", "react-dom": "19.2.5", "@base-ui/react": "1.4.1", - "class-variance-authority": "0.7.1", "tailwind-merge": "3.4.0", "lucide-react": "0.564.0" - }, - "devDependencies": { - "vite": "8.0.10", "@vitejs/plugin-react": "catalog:", - "tailwindcss": "4.2.4", "@tailwindcss/vite": "4.2.4", - "vite-plugin-singlefile": "catalog-or-pin", "typescript": "catalog:", - "@figma/plugin-typings": "pin-latest" - } -} -``` -> The plugin is **not** part of the 100%-coverage/test gate (needs Pixso runtime). It must still -> `typecheck` and `oxlint` clean. `@figma/plugin-typings` gives the Pixso/Figma `figma`/`pixso` -> global types (Pixso mirrors the Figma plugin API). - -## tsconfig.json (each package) — extends the shared base above -```jsonc -{ "extends": "../tsconfig.base.json", "include": ["src", "tests"] } -``` -- `pixso-move/tsconfig.base.json` is **one** level up from each package (`pixso-move//` → - `../tsconfig.base.json`); it in turn extends the worktree-root `../tsconfig.base.json`. ✓ -- Server's `tsconfig.json` adds `"compilerOptions": { "lib": ["ESNext","esnext.disposable"] }`. -- Plugin's overrides `lib`/`jsx`/`types`/`paths` (see [06](./06-plugin-build.md)). - -## Workspace wiring -- Edit `pnpm-workspace.yaml`: add `"pixso-move/*"` to the `packages:` list (alongside `apps/*`, - `packages/*`). -- Add any missing catalog entries used above (`@effect/platform-node`, `@vitejs/plugin-react`, - `vite-plugin-singlefile`, `tsdown` if not present) under `catalog:`. -- Run `pnpm install` so workspace links resolve. -- Optional: a `turbo.json` already globs all workspaces; confirm `typecheck`/`test` tasks pick up - the new packages (no change expected). - -## Confirm effect-acp reuse (blocking gate for task 4) -Verified facts to encode now (so task 4 has no surprises): -- Package name: **`effect-acp`** (unscoped, `private`), `package.json` exports `./client`, - `./schema`, `./errors`, `./rpc`, `./protocol`, `./agent`, `./terminal`. -- Import as `import * as AcpClient from "effect-acp/client"` → `AcpClient.AcpClient` (Context - service), `AcpClient.layerChildProcess(handle, options?)`. -- Depend via `"effect-acp": "workspace:*"`. -- **Action:** add `effect-acp` to processor deps; write a throwaway `tests/imports.test.ts` that - imports `AcpClient`, `AcpSchema`, `AcpErrors` and asserts the symbols exist — proves resolution - before any real wiring. (Delete or fold into real tests in task 4.) - -## TDD / tests -Scaffold has no logic, but the test harness must run: -- Each server-side package gets a trivial `tests/smoke.test.ts` (`it.effect("boots", () => - Effect.succeed(…))`) so `vitest run --coverage` exits 0 with the config in place. -- `vitest.config.ts` per package per [conventions §5](./conventions.md). The 100% thresholds are - active from day one (trivially met while `src` is near-empty; keeps us honest as code lands). - -## Acceptance -- [ ] `pnpm install` resolves; all four packages linked. -- [ ] `turbo run typecheck` — 0 errors across new packages. -- [ ] `pnpm -w lint` — 0 errors. -- [ ] `turbo run test` — green; server-side packages report 100% (trivially). -- [ ] processor `imports.test.ts` proves `effect-acp` resolves. diff --git a/pixso-move/specs/02-contracts.md b/pixso-move/specs/02-contracts.md deleted file mode 100644 index a08f8b1bc97..00000000000 --- a/pixso-move/specs/02-contracts.md +++ /dev/null @@ -1,104 +0,0 @@ -# Task 2 — Contracts (`@pixso-move/contracts`) - -All cross-package shapes as **effect Schema**. Server decodes/validates with them; processor reuses -row types; plugin imports request types. TDD: decode/encode tests first. Uses the **confirmed** -Schema API (conventions §4) — no hedging. - -## File budget (all ≤ 150 LOC, single-responsibility) -| Path | Responsibility | est. LOC | -|---|---|---| -| `src/base.ts` | shared primitives copied from ru-fork `baseSchemas.ts` (`TrimmedString`, `TrimmedNonEmptyString`, `NonNegativeInt`) | ~16 | -| `src/ids.ts` | `DesignerId`, `NodeId`, `ResultTag` (branded) | ~18 | -| `src/ingest.ts` | `Base64Png`, `IngestRequest`, `IngestResponse` | ~22 | -| `src/node.ts` | `NodeSummary`, `NodeRecord` | ~18 | -| `src/processing.ts` | `ProcessingStatus`, `ProcessingResult` | ~20 | -| `src/errors.ts` | `AuthError`, `IngestError`, `NodeNotFoundError` | ~18 | -| `src/index.ts` | re-export all | ~8 | -| `tests/*.test.ts` | one per src module | — | - -## `src/base.ts` (copied utility — DRY primitive source) -```ts -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import * as SchemaTransformation from "effect/SchemaTransformation"; - -export const TrimmedString = Schema.String.pipe( - Schema.decodeTo(Schema.String, SchemaTransformation.transformOrFail({ - decode: (v) => Effect.succeed(v.trim()), encode: (v) => Effect.succeed(v.trim()), - })), -); -export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); -export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); -``` -> Verbatim from `packages/contracts/src/baseSchemas.ts:5-15`. Header-mark it `// pixso-move: copied -> from ru-fork baseSchemas.ts`. (Small enough to own; keeps `@pixso-move/contracts` standalone.) - -## `src/ids.ts` -```ts -import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./base.ts"; -const makeId = (b: B) => TrimmedNonEmptyString.pipe(Schema.brand(b)); - -export const DesignerId = makeId("DesignerId").check(Schema.isMaxLength(200)); -export const NodeId = makeId("NodeId"); -export const ResultTag = makeId("ResultTag").check(Schema.isMaxLength(64)); -export type DesignerId = typeof DesignerId.Type; -export type NodeId = typeof NodeId.Type; -export type ResultTag = typeof ResultTag.Type; -``` - -## `src/ingest.ts` -```ts -export const Base64Png = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(8 * 1024 * 1024)); -export const IngestRequest = Schema.Struct({ - designerId: DesignerId, - rootName: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(512)), - nodesJson: Schema.String.check(Schema.isMinLength(2)), // opaque JSON string, stored verbatim - preview: Base64Png, -}); -export const IngestResponse = Schema.Struct({ nodeId: NodeId }); -``` - -## `src/node.ts` -```ts -export const NodeSummary = Schema.Struct({ - nodeId: NodeId, rootName: Schema.String, addedAt: Schema.String, preview: Base64Png }); -export const NodeRecord = Schema.Struct({ - nodeId: NodeId, designerId: DesignerId, rootName: Schema.String, - nodesJson: Schema.String, preview: Base64Png, addedAt: Schema.String }); -``` - -## `src/processing.ts` -```ts -export const ProcessingStatus = Schema.Literals(["pending", "processing", "done", "error"]); -export const ProcessingResult = Schema.Struct({ - nodeId: NodeId, resultTag: ResultTag, status: ProcessingStatus, attempts: NonNegativeInt, - result: Schema.NullOr(Schema.String), error: Schema.NullOr(Schema.String), - createdAt: Schema.String, startedAt: Schema.NullOr(Schema.String), - finishedAt: Schema.NullOr(Schema.String) }); -``` - -## `src/errors.ts` -```ts -export class AuthError extends Schema.TaggedErrorClass()("AuthError", - { message: Schema.String, status: Schema.Int }) {} // 401 -export class IngestError extends Schema.TaggedErrorClass()("IngestError", - { message: Schema.String, status: Schema.Int }) {} // 400 | 413 -export class NodeNotFoundError extends Schema.TaggedErrorClass()( - "NodeNotFoundError", { message: Schema.String, status: Schema.Int }) {} // 404 -``` - -## TDD — tests first (100%) -Use `Schema.decodeUnknownExit(schema)(input)` and assert `Exit.isSuccess`/`Exit.isFailure` -(idiom from `packages/shared/src/schemaJson.ts`). - -- **happy decode** per schema → branded/typed value; `TrimmedNonEmptyString` trims. -- **reject** per refinement: empty string, over-max (`DesignerId>200`, `ResultTag>64`, - `rootName>512`, `preview>8MB`), `nodesJson` length <2, wrong `status` literal, missing field. - Each `.check` branch is exercised (→ 100% branches). -- **errors**: each `TaggedError` carries `_tag`/`message`/`status`. - -## Acceptance -- [ ] Confirmed Schema API only; all valid inputs decode, all invalid rejected (tested). -- [ ] Each file ≤150 LOC, single-purpose; primitives sourced once from `base.ts`. -- [ ] 100% coverage; `tsc`/oxlint clean; shapes exactly match [00-overview.md](./00-overview.md). diff --git a/pixso-move/specs/03-server.md b/pixso-move/specs/03-server.md deleted file mode 100644 index d83ca068836..00000000000 --- a/pixso-move/specs/03-server.md +++ /dev/null @@ -1,214 +0,0 @@ -# Task 3 — Server (`@pixso-move/server`) - -Effect + effect-platform HTTP server, `node:sqlite` persistence, 2-table schema, four endpoints, -key-gating, structured logging, launchable bootstrap. **Never crashes.** TDD, 100% (minus -`vendor/**`, `Migrations`, `bin.ts`). Every authored file ≤150 LOC. - -## File budget -| Path | Responsibility | LOC | -|---|---|---| -| `src/vendor/NodeSqliteClient.ts` | **vendored** node:sqlite client (verbatim from ru-fork) | 277\* | -| `src/persistence/sqlite.ts` | `SqlitePersistenceMemory` + `makeSqlitePersistenceLive(dbPath)` + PRAGMA setup | ~45 | -| `src/persistence/migrate.ts` | `runMigrations` = run ordered migration list | ~20 | -| `src/persistence/migrations/001_nodes.ts` | `nodes` DDL | ~22\* | -| `src/persistence/migrations/002_results.ts` | `processing_results` DDL | ~28\* | -| `src/services/nodeStore.ts` | `NodeStore` tag + shape + `NodeRow` schema + decoders | ~45 | -| `src/services/nodeStoreLive.ts` | `NodeStoreLive` layer (insert/list/get/reads) | ~95 | -| `src/services/resultStore.ts` | `ResultStore` tag + shape + `ResultRow` schema + decoders | ~45 | -| `src/services/resultStoreLive.ts` | `ResultStoreLive` layer (reconcile/claim/complete/fail/recover/reads) | ~120 | -| `src/http/cors.ts` | cors headers + `corsLayer` (adds `x-designer-id`) | ~16 | -| `src/http/respond.ts` | `respondJson` + `respondError` (single error→response mapper) | ~30 | -| `src/http/route.ts` | `route(method, path, handler)` wrapper (catch + cors + never-throw) | ~30 | -| `src/http/auth.ts` | `requireDesignerId` | ~25 | -| `src/http/ingest.ts` | `POST /ingest` | ~35 | -| `src/http/nodes.ts` | `GET /nodes`, `GET /nodes/:id` | ~40 | -| `src/http/processing.ts` | `GET /processing-data` | ~30 | -| `src/http/routes.ts` | merge route layers + `corsLayer` | ~15 | -| `src/config.ts` | `ServerConfig` service + `layerTest` + defaults | ~50 | -| `src/serverLogger.ts` | logger layer (vendored-tiny, ported) | ~16 | -| `src/time.ts` | `nowIso` (shared) | ~5 | -| `src/httpServer.ts` | `HttpServerLive` (NodeHttpServer.layer) | ~20 | -| `src/server.ts` | `makeServerLayer` + `runServer` compose | ~70 | -| `src/bin.ts` | flag parse → provide config + AcpRunnerLive → runServer | ~30\* | - -`*` = coverage-excluded (`vendor/**`, `migrations/**`, `bin.ts`). `tests/` mirrors `src/`. - -> **Vendoring (conventions §2.3):** `vendor/NodeSqliteClient.ts` is copied verbatim from -> `apps/server/src/persistence/NodeSqliteClient.ts` (277 LOC, uses `node:sqlite`). Header-marked, -> not rewritten, not split, excluded from coverage. It exports the SqlClient layer constructors our -> `persistence/sqlite.ts` consumes. - -## Persistence - -### `persistence/sqlite.ts` -Mirror `apps/server/src/persistence/Layers/Sqlite.ts:18-76` but trimmed: -```ts -const setup = Layer.effectDiscard(Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* runMigrations; -})); -export const SqlitePersistenceMemory = Layer.provideMerge(setup, NodeSqliteClient.layerMemory()); -export const makeSqlitePersistenceLive = (dbPath: string) => /* mkdir parent, then */ - Layer.provideMerge(setup, NodeSqliteClient.layer({ filename: dbPath })); -export const persistenceLive = Layer.unwrap( - Effect.map(Effect.service(ServerConfig), (c) => makeSqlitePersistenceLive(c.dbPath))); -// idiom from apps/server/src/persistence/Layers/Sqlite.ts:74 -``` - -### `persistence/migrate.ts` — no Migrator dependency -Migrations are idempotent DDL (`CREATE TABLE IF NOT EXISTS …`), so just run them in order each -startup: -```ts -import m001 from "./migrations/001_nodes.ts"; -import m002 from "./migrations/002_results.ts"; -export const runMigrations = Effect.gen(function* () { - for (const step of [m001, m002]) yield* step; // ordered, idempotent - yield* Effect.logDebug("migrations applied", { count: 2 }); -}); -``` -> Simpler than the ru-fork `Migrator.fromRecord` registry (we have 2 tables) — fewer moving parts, -> fully covered by the migration test. DDL exactly per [00-overview.md](./00-overview.md): all -> columns, `UNIQUE(node_id, result_tag)`, the 3 indexes. - -## Stores (all SQL lives here; interface ⟂ impl) - -### `services/nodeStore.ts` — interface + row codec -```ts -export interface NodeStoreShape { - readonly insert: (i: { designerId: DesignerId; rootName: string; nodesJson: string; preview: string }) - => Effect.Effect<{ nodeId: NodeId }>; - readonly listSummaries: (d: DesignerId) => Effect.Effect>; - readonly getById: (d: DesignerId, n: NodeId) => Effect.Effect; - readonly listNodeIds: (d: DesignerId) => Effect.Effect>; // processor - readonly getForProcessing: (n: NodeId) - => Effect.Effect<{ nodeId: NodeId; rootName: string; nodesJson: string } | undefined>; -} -export class NodeStore extends Context.Service()("pixso-move/NodeStore") {} -// NodeRow = Schema.Struct({ id, designer_id, root_name, nodes_json, preview, added_at }); -// rowToSummary / rowToRecord via Schema.decode. -``` - -### `services/nodeStoreLive.ts` — `NodeStoreLive` layer -- `insert`: `nodeId = NodeId.make(crypto.randomUUID())`, `addedAt = yield* nowIso`, one `INSERT`. -- `listSummaries`: `SELECT id, root_name, preview, added_at … WHERE designer_id=? ORDER BY added_at - DESC` → decode `NodeSummary[]` (no `nodes_json`). -- `getById`: `WHERE id=? AND designer_id=?` (key isolation in SQL) → `NodeRecord | undefined`. -- `listNodeIds`/`getForProcessing`: minimal projections for the processor. - -### `services/resultStore.ts` — interface + row codec -```ts -export interface ResultStoreShape { - readonly reconcile: (rows: ReadonlyArray<{ designerId; nodeId; resultTag }>) => Effect.Effect; - readonly claimNextPending: Effect.Effect; - readonly complete: (id: string, result: string) => Effect.Effect; - readonly fail: (id: string, error: string) => Effect.Effect; - readonly recoverInFlight: Effect.Effect; - readonly listByNode: (d: DesignerId, n: NodeId) => Effect.Effect>; - readonly countPending: Effect.Effect; -} -export type ClaimedJob = { id: string; designerId: DesignerId; nodeId: NodeId; resultTag: ResultTag }; -export class ResultStore extends Context.Service()("pixso-move/ResultStore") {} -``` - -### `services/resultStoreLive.ts` — `ResultStoreLive` layer -- `reconcile`: `INSERT INTO processing_results (…) VALUES … ON CONFLICT(node_id, result_tag) DO - NOTHING`; id = `crypto.randomUUID()`, `created_at = yield* nowIso`, `status='pending'`. Returns - inserted count. -- `claimNextPending`: `SELECT … WHERE status='pending' ORDER BY created_at LIMIT 1`, then - `UPDATE … SET status='processing', started_at=?, attempts=attempts+1 WHERE id=? AND - status='pending'`; return the job **only if `changes === 1`** (else `undefined` — lost race). -- `complete`/`fail`: set terminal `status` + `finished_at` + `result`/`error`. -- `recoverInFlight`: `UPDATE … SET status='pending' WHERE status='processing'` (no attempts bump). -- `listByNode`: `WHERE designer_id=? AND node_id=?` → `ProcessingResult[]`. `countPending` for tests. - -## HTTP (effect/unstable/http, `HttpRouter.add` — no base-path helper) - -### `http/cors.ts` -```ts -export const corsAllowedMethods = ["GET", "POST", "OPTIONS"] as const; -export const corsAllowedHeaders = ["content-type", "x-designer-id"] as const; -export const corsHeaders = { "access-control-allow-origin": "*", - "access-control-allow-methods": corsAllowedMethods.join(", "), - "access-control-allow-headers": corsAllowedHeaders.join(", ") } as const; -export const corsLayer = HttpRouter.cors({ allowedMethods: [...corsAllowedMethods], - allowedHeaders: [...corsAllowedHeaders], maxAge: 600 }); -``` - -### `http/respond.ts` — the single mapper (DRY) -```ts -export const respondJson = (body: unknown, status: number) => - HttpServerResponse.jsonUnsafe(body, { status, headers: corsHeaders }); -export const respondError = (e: AuthError | IngestError | NodeNotFoundError) => - respondJson({ error: e.message }, e.status); -``` - -### `http/route.ts` — the single wrapper (DRY, never-throws) -```ts -export const route = (method, path, handler: Effect.Effect) => - HttpRouter.add(method, path, handler.pipe( - Effect.catchTags({ AuthError: respondError, IngestError: respondError, NodeNotFoundError: respondError }), - Effect.catchAllCause((cause) => // defects: log + 500, process survives - Effect.zipRight(Effect.logError("route defect", { path, cause: Cause.pretty(cause) }), - respondJson({ error: "Internal error." }, 500))), - )); -``` -Every route is defined via `route(...)`; **no route re-implements error handling or cors.** - -### `http/auth.ts` -`requireDesignerId: Effect` — read `x-designer-id`, decode -through `DesignerId`; missing/blank/invalid → `new AuthError({ message:"Missing or invalid designer -key.", status:401 })`. - -### Route handlers (each tiny, identical shape) -| File | Route | Body | -|---|---|---| -| `http/ingest.ts` | `POST /ingest` | auth → `schemaBodyJson(IngestRequest)` (fail→`IngestError(400)`) → assert `body.designerId === key` (else `AuthError(401)`) → preview-size guard (`IngestError(413)`) → `NodeStore.insert` → `Processor.notify` (task 5) → `respondJson({ nodeId }, 200)`; `logDebug("ingest stored", { nodeId })` | -| `http/nodes.ts` | `GET /nodes` | auth → `NodeStore.listSummaries` → 200 | -| `http/nodes.ts` | `GET /nodes/:id` | auth → `NodeStore.getById` → 200 or `NodeNotFoundError(404)` | -| `http/processing.ts` | `GET /processing-data` | auth → decode `?nodeId` (`IngestError(400)`) → ensure node owned (`NodeNotFoundError(404)`) → `ResultStore.listByNode` → 200 | - -### `http/routes.ts` -Merge the four `route(...)` layers; provide `corsLayer`. (`OPTIONS` preflight handled by `corsLayer`.) - -## Config / logger / bootstrap -- `config.ts`: `ServerConfig = Context.Service<…>("pixso-move/ServerConfig")` with `{ host, port, - dbPath, logLevel, cliJs, cliHome? }`; `ServerConfig.layerTest(overrides)`; defaults `host - 127.0.0.1`, `port 7787`, `dbPath ./.data/pixso.sqlite`, `logLevel Debug`. -- `serverLogger.ts`: port `apps/server/src/serverLogger.ts:8-16` (consolePretty + MinimumLogLevel). -- `httpServer.ts`: `HttpServerLive = NodeHttpServer.layer(NodeHttp.createServer, { host, port })` - (dynamic import, mirror `server.ts:100-112`). -- `server.ts`: `makeServerLayer = Layer.mergeAll(HttpRouter.serve(routes), httpListening?)` provided - with `persistenceLive`, `ServerLoggerLive`, `NodeStoreLive`, `ResultStoreLive`, `ProcessorLive` - (task 5), over `PlatformServicesLive`/`NodeServices`. `runServer = Layer.launch(makeServerLayer)`. -- `bin.ts`: parse `--port/--host/--db/--cli-js` → provide `ServerConfig` + `AcpRunnerLive` → - `runServer` (coverage-excluded). - -## TDD — tests first (100%) -**migrations** (`tests/persistence`): run `SqlitePersistenceMemory`; assert both tables + 3 indexes + -the unique constraint exist (query `sqlite_master`); idempotent (build layer twice). - -**nodeStore**: insert returns fresh id + persists all columns, `added_at` ISO (pin clock); -`listSummaries` excludes `nodes_json`, newest-first, **isolated by designerId**; `getById` undefined -for wrong key and missing id; `listNodeIds`/`getForProcessing` projections correct. - -**resultStore**: `reconcile` inserts only missing pairs, idempotent (2nd call → 0); `claimNextPending` -flips one row to `processing` (attempts=1) and returns it; pre-claimed row → loser gets `undefined`; -`complete`/`fail` set terminal status + `finished_at` + result/error; `recoverInFlight` flips -`processing→pending` without bumping attempts; `listByNode` scoped + all tags; `countPending`. - -**http** (`tests/http`): exercise each route Effect with a constructed `HttpServerRequest` over a -layer = routes + `SqlitePersistenceMemory` + stores + test logger + a **no-op `Processor`** + -`ServerConfig.layerTest`. Cover: missing/blank/invalid key→401; ingest body/header mismatch→401; -invalid body→400; oversize preview→413; happy→200 + body + `corsHeaders`; `/nodes/:id` unknown→404, -wrong-key→404; `processing-data` missing/invalid `nodeId`→400, unknown node→404, happy→array. -**Catch-all**: inject a store layer whose method dies → handler returns 500 `{error}` and `logError` -fired (proves never-crash). `respond.ts`/`route.ts`/`auth.ts`/`cors.ts`/`config.ts`/`time.ts` each -fully covered. - -## Acceptance -- [ ] Both tables + indexes + unique constraint; key-gating enforced in SQL and at the edge. -- [ ] Every route via `route()`; one `respondError`; no duplicated handling; defects → 500+log. -- [ ] `logError`/`logDebug` only; ingest steps logged; server cannot crash. -- [ ] Every authored file ≤150 LOC; `vendor/` isolated; `tsc`/oxlint clean; coverage 100% (excl.). diff --git a/pixso-move/specs/04-processor.md b/pixso-move/specs/04-processor.md deleted file mode 100644 index 0f230ec5f1a..00000000000 --- a/pixso-move/specs/04-processor.md +++ /dev/null @@ -1,144 +0,0 @@ -# Task 4 — Processor (`@pixso-move/processor`) - -Watches new node records from **configured** designers, runs each configured prompt through qwen -over ACP (via `effect-acp`), writes status-tracked rows. **Never crashes.** TDD, 100% (minus the one -`*.integration.ts` spawn-glue file). Every authored file ≤150 LOC. - -## File budget -| Path | Responsibility | LOC | -|---|---|---| -| `src/types.ts` | `ProcessorConfig`, `ConfigEntry`, `ProcessorDeps`, `AcpRunner`, `AcpRunError`, `ClaimedJob`, `ProcessorOptions` | ~50 | -| `src/config.ts` | the operator-edited `processorConfig` value | ~20 | -| `src/prompt.ts` | `buildPrompt(input)` — pure | ~25 | -| `src/extract.ts` | `extractText(raw)` — pure (code-fence unwrap) | ~25 | -| `src/reconcile.ts` | `computeReconcileRows(config, nodesByDesigner)` — pure | ~30 | -| `src/drain.ts` | `runOneJob(deps, job)` — the single contained unit | ~45 | -| `src/engine.ts` | `makeProcessor(deps, options)` — loop, notify, recover, timer | ~95 | -| `src/processor.ts` | `Processor` service tag + `Processor` shape | ~20 | -| `src/acp/collect.ts` | `accumulateDelta(buf, notification)` — pure delta reducer | ~25 | -| `src/acp/handshake.ts` | initialize/authenticate/createSession param builders + stopReason/error mapping — pure | ~30 | -| `src/acp/acpRunnerLive.integration.ts` | spawn qwen + `layerChildProcess` + run → `AcpRunner` | ~80\* | -| `src/index.ts` | public exports | ~12 | - -`*` = coverage-excluded (only the real-process spawn glue). `tests/` mirrors the pure modules. - -> **No dependency on `@pixso-move/server`.** The processor owns the `ProcessorDeps` interface; the -> server satisfies it at embed time (task 5). This avoids a cycle and keeps SQL solely in the -> server's stores. - -## Injected dependencies (`types.ts`) -```ts -export interface ProcessorDeps { - readonly listNodeIds: (d: DesignerId) => Effect.Effect>; - readonly getForProcessing: (n: NodeId) - => Effect.Effect<{ nodeId: NodeId; rootName: string; nodesJson: string } | undefined>; - readonly reconcile: (rows: ReadonlyArray<{ designerId; nodeId; resultTag }>) => Effect.Effect; - readonly claimNextPending: Effect.Effect; - readonly complete: (id: string, result: string) => Effect.Effect; - readonly fail: (id: string, error: string) => Effect.Effect; - readonly recoverInFlight: Effect.Effect; - readonly acp: AcpRunner; -} -export interface AcpRunner { - readonly run: (input: { prompt: string }) => - Effect.Effect<{ text: string; stopReason: string }, AcpRunError>; -} -``` -The tiny `AcpRunner` seam is what makes the engine testable and isolates all `effect-acp` detail. - -## Pure helpers (fully tested) - -### `prompt.ts` -`buildPrompt({ prompt, rootName, nodesJson }): string` — configured prompt + output rule ("return -the result only") + payload (`rootName` then ```json fenced `nodesJson`). Deterministic. Borrowed -*shape* from the reference's prompt-builder; reimplemented. - -### `extract.ts` -`extractText(raw): { text: string }` — if the result is a single fenced block, return its body; -else the trimmed whole. Borrowed *idea* (code-fence extraction) from the reference. - -### `reconcile.ts` -`computeReconcileRows(config, nodesByDesigner): Array<{ designerId; nodeId; resultTag }>` — cross -each configured designer's `nodeIds` with that designer's `resultTag`s. Pure; the engine fetches -`nodesByDesigner` via `deps.listNodeIds` and passes `deps.reconcile` the result. - -### `acp/collect.ts` -`accumulateDelta(buf: string, n: SessionNotification): string` — appends `n.update.content.text` -when `n.update.sessionUpdate === "agent_message_chunk"` && `content.type === "text"`; ignores other -update kinds. Pure reducer over the `effect-acp/schema` union. - -### `acp/handshake.ts` -Pure builders: `initializeParams()`, `authenticateParams()` (`{ methodId: "openai" }`), -`newSessionParams(cwd)`, and `mapAcpError(e: AcpError): AcpRunError`, -`mapStopReason(res): string`. (Constants verified against `AcpSessionRuntime.ts` + `config.ts:52`.) - -## `drain.ts` — the contained unit -```ts -export const runOneJob = (deps: ProcessorDeps, job: ClaimedJob) => Effect.gen(function* () { - const node = yield* deps.getForProcessing(job.nodeId); - if (!node) { yield* deps.fail(job.id, "node missing"); return; } - const prompt = buildPrompt({ prompt: /*from config via engine*/, rootName: node.rootName, nodesJson: node.nodesJson }); - const res = yield* deps.acp.run({ prompt }); - yield* deps.complete(job.id, extractText(res.text).text); - yield* Effect.logDebug("job done", { nodeId: job.nodeId, resultTag: job.resultTag, stopReason: res.stopReason }); -}).pipe(Effect.catchAllCause((cause) => Effect.zipRight( - Effect.logError("job failed", { nodeId: job.nodeId, resultTag: job.resultTag, cause: Cause.pretty(cause) }), - deps.fail(job.id, Cause.pretty(cause))))); // ANY failure/defect → error row, never escapes -``` -> The job's prompt text comes from the config entry matching `job.resultTag`+`designerId`; the -> engine resolves it and passes it in (so `drain` stays pure-of-config-lookup). Adjust signature to -> `runOneJob(deps, job, promptText)`. - -## `engine.ts` — the loop -```ts -export const makeProcessor = (deps, options): Effect.Effect -``` -`ProcessorOptions = { config: ProcessorConfig; pollIntervalMs?: number /*2000*/ }`. -- **start**: `recoverInFlight` (`logDebug("recovered", { count })`) → fork a `Schedule.fixed` - timer calling `notify`, interruptible on scope close. -- **runTickOnce**: (1) for each configured designer, `listNodeIds` → `computeReconcileRows` → - `deps.reconcile`; `logDebug("reconciled", { inserted })`. (2) drain: loop `claimNextPending`; - resolve `promptText` from config by `resultTag`; `runOneJob(deps, job, promptText)`; until - `undefined`. -- **notify**: if a tick is in-flight (`Ref`), set a "re-run" flag and return; else run a - tick, then re-run once if flagged. Serializes ticks (reference's poll+notify guard). -- **never-crash**: `runTickOnce` is wrapped `Effect.catchAllCause(logError)` so a tick can't kill - the timer; `runOneJob` already contains per-job failure; `notify` is self-contained. -- **stop**: interrupt timer, await in-flight. - -`processor.ts` exports `Processor = Context.Service<…>("pixso-move/Processor")` with -`{ start; notify; stop; runTickOnce }`. - -## Real ACP impl — `acp/acpRunnerLive.integration.ts` (excluded) -The only un-unit-testable file. Assembles the pure helpers into a real runner: -1. spawn `process.execPath [cliJs, "--acp"]` via `ChildProcessSpawner` (`shell:false`, env per - `CliAcpSupport.ts`: `CLI_HOME`, `NODE_TLS_REJECT_UNAUTHORIZED="0"`). -2. `AcpClient.layerChildProcess(handle)` → `AcpClient.AcpClient`. -3. register `client.handleSessionUpdate(n => Ref.update(buf, b => accumulateDelta(b, n)))`. -4. `initialize` → `authenticate` → `createSession` (using `handshake.ts` builders) → `prompt`. -5. read `buf`; return `{ text, stopReason: mapStopReason(res) }`; map `AcpError` via `mapAcpError`. -Exposed as `AcpRunnerLive` layer. Session-per-job (simple, isolated); child reused across jobs with -idle reap is an allowed later optimization. Because every pure part lives in `collect.ts`/ -`handshake.ts`/`extract.ts` (100% tested), this file is thin glue and justifiably excluded. - -## TDD — tests first (100% of non-excluded) -Inject a **FakeAcpRunner** (scripted `{text,stopReason}` or fail-on-demand) + the **real stores** -over `SqlitePersistenceMemory` (exercises real claim atomicity), or hand fakes implementing -`ProcessorDeps`. -- **prompt/extract/collect/handshake/reconcile**: enumerated pure cases (fence variants; delta kinds - appended vs ignored; reconcile crosses only configured designers × their tags; error/stopReason - mappings). -- **drain**: success→`done`+text; FakeAcpRunner failure→`error` row + `logError`; a thrown defect→ - caught→`error`+loop continues (assert a second queued job still completes); missing node→ - `fail("node missing")`. -- **engine**: reconcile creates rows only for configured designers; multiple tags→multiple rows; - idempotent across ticks; adding a config entry + re-tick backfills existing nodes; crash recovery - (`processing→pending` on start, attempts not bumped); notify serialization (no concurrent ticks, - re-run after); timer fires (Effect **TestClock**, no real sleep); the loop type is `Effect<…, - never>` (never-fails proof). - -## Acceptance -- [ ] Only configured designers processed; multiple tags supported; full lifecycle observable. -- [ ] Crash recovery resumes interrupted work; no job (even a defect) breaks the loop/process. -- [ ] Every step logged (`logDebug` progress / `logError` failures). -- [ ] Each authored file ≤150 LOC; only `*.integration.ts` excluded; `tsc`/oxlint clean; 100%. diff --git a/pixso-move/specs/05-embed.md b/pixso-move/specs/05-embed.md deleted file mode 100644 index 708a1a6b872..00000000000 --- a/pixso-move/specs/05-embed.md +++ /dev/null @@ -1,90 +0,0 @@ -# Task 5 — Embed the processor into the server runtime - -Compose the processor (task 4) into the server's Effect runtime (task 3) so they share one process, -one sqlite connection, and one logger. Wire `POST /ingest` to `notify()` the processor for -low-latency pickup. TDD, 100% coverage for the wiring. - -## What gets wired - -``` -makeServerLayer - ├─ persistence (SqlitePersistenceMemory | layerConfig) ← single sqlite, single writer - ├─ ServerLoggerLive - ├─ NodeStoreLive, ResultStoreLive (task 3 services) - ├─ ProcessorLive (NEW — builds makeProcessor) - │ deps = ProcessorDeps built from NodeStore + ResultStore - │ started on layer acquisition; stopped on release (scoped) - └─ HttpRouter.serve(routes) ← ingest handler resolves Processor and calls notify -``` - -## File budget -| Path | Responsibility | LOC | -|---|---|---| -| `server/src/services/processorLive.ts` | build `ProcessorDeps` from stores + `AcpRunner`; `ProcessorLive` scoped layer | ~45 | - -(`Processor` tag and `AcpRunnerLive` come from `@pixso-move/processor`; `NodeStore.listNodeIds`/ -`getForProcessing` already exist from task 3 — no new store methods needed.) - -## ProcessorLive layer (`server/src/services/processorLive.ts`) -```ts -export const ProcessorLive = Layer.scoped(Processor, Effect.gen(function* () { - const nodeStore = yield* NodeStore; - const resultStore = yield* ResultStore; - const acp = yield* AcpRunner; // AcpRunnerLive in prod; provided fake in tests - const deps: ProcessorDeps = { - listNodeIds: nodeStore.listNodeIds, - getForProcessing: nodeStore.getForProcessing, - reconcile: resultStore.reconcile, - claimNextPending: resultStore.claimNextPending, - complete: resultStore.complete, - fail: resultStore.fail, - recoverInFlight: resultStore.recoverInFlight, - acp, - }; - const processor = yield* makeProcessor(deps, { config: processorConfig }); - yield* processor.start; // recover + arm timer - yield* Effect.addFinalizer(() => processor.stop); - return processor; -})); -``` -- `Processor` is the `Context.Service` tag from `@pixso-move/processor`. The ingest handler resolves - it to call `notify`. SQL stays solely in task-3 stores. - -## Ingest → notify -In `http/ingest.ts` (task 3), after a successful `NodeStore.insert`: -```ts -const processor = yield* Processor; -yield* processor.notify; // fire-and-forget pickup; never fails the request -``` -- `notify` is non-blocking and self-contained (its own catch). If the designer is unconfigured, - the tick reconciles nothing for them — harmless. The HTTP response returns `{ nodeId }` - regardless of processing outcome. - -## AcpRunner provisioning -- **Production**: `AcpRunnerLive` (task 4) provided into `makeServerLayer`, reading `cliJs`/`cwd`/ - env from `ServerConfig` (extend `ServerConfig` with `cliJs`, `cliHome?` fields; default `cliJs` - to a config flag `--cli-js`). -- **Tests**: provide a `FakeAcpRunner` layer instead → end-to-end-in-memory without qwen. - -## TDD — tests first (100%) -Build a full in-memory app layer: `SqlitePersistenceMemory` + stores + `ProcessorLive` (with -**FakeAcpRunner**) + routes + test logger + `ServerConfig.layerTest`. - -- **embed smoke**: layer builds, processor `start` ran (recovery executed), timer armed; layer - release stops the processor (no leaked fiber — assert finalizer ran). -- **ingest triggers processing**: `POST /ingest` for a **configured** designer → 200 `{ nodeId }`; - after allowing the tick to run (drive `runTickOnce` or advance TestClock), `GET /processing-data? - nodeId=…` shows the configured tags transitioning to `done` with FakeAcpRunner's text. -- **unconfigured designer**: `POST /ingest` for an unknown designer → stored, but - `processing-data` stays empty (no rows reconciled). Server fine. -- **notify never breaks ingest**: inject a processor whose `notify` would error → ingest still - returns 200 (notify is contained). Assert `logError` captured, response unaffected. -- **shared writer**: ingest + processing both write the same in-memory DB without error - (single-writer guarantee holds because one runtime). - -## Acceptance -- [ ] One process, one sqlite, one logger; processor starts/stops with the server layer. -- [ ] Ingest notifies the processor; pickup is near-immediate for configured designers. -- [ ] A failing `notify` never affects the HTTP response. -- [ ] End-to-end in-memory test (ingest → processed → readable) passes with a fake ACP runner. -- [ ] `tsc`/oxlint clean; wiring covered 100%. diff --git a/pixso-move/specs/06-plugin-build.md b/pixso-move/specs/06-plugin-build.md deleted file mode 100644 index 8e364e5d7e5..00000000000 --- a/pixso-move/specs/06-plugin-build.md +++ /dev/null @@ -1,110 +0,0 @@ -# Task 6 — Plugin build & vendored UI kit (`@pixso-move/plugin`) - -Set up the two-bundle Pixso plugin build and vendor ru-fork web's UI kit so the iframe UI looks -**identical** to ru-fork. No app logic yet (tasks 7–8) — this task produces a buildable shell with -the design system in place. - -## File budget (authored; vendored UI files exempt per conventions §2.3) -| Path | Responsibility | LOC | -|---|---|---| -| `vite.code.config.ts` | sandbox IIFE build | ~25 | -| `vite.ui.config.ts` | iframe React+Tailwind single-file build | ~20 | -| `manifest.json` | Pixso plugin manifest | ~8 | -| `src/ui/index.html` | iframe host (`data-theme`/`dark`) | ~20 | -| `src/ui/main.tsx` | React mount | ~10 | -| `src/ui/lib/utils.ts` | vendored `cn` (stripped) | ~6 | -| `src/ui/components/ui/*.tsx` | **vendored** (button/input/label/field/card) | exempt | -| `src/ui/index.css`, `src/ui/themes/ru-fork.css` | **vendored** css/theme | exempt | - -All **authored** plugin files obey the 150-LOC cap. App/screen/code decomposition is in -[07](./07-plugin-code.md) / [08](./08-plugin-ui.md). - -## Pixso plugin model (two bundles) -A Pixso plugin (Figma-compatible API) ships two artifacts declared in `manifest.json`: -- **`main`** → `dist/code.js` — runs in the **sandbox** (the `pixso`/`figma` main thread; has the - node API; **no** DOM). Built as a single IIFE. -- **`ui`** → `dist/ui.html` — runs in an **iframe** (full DOM/React; **no** node API). Built as one - self-contained HTML (JS+CSS inlined via `vite-plugin-singlefile`). -They communicate only via `postMessage` (task 7). - -## manifest.json -```json -{ - "name": "PixsoMove", - "id": "REPLACE_WITH_PIXSO_PLUGIN_ID", - "api": "2.0.0", - "editorType": ["pixso"], - "main": "dist/code.js", - "ui": "dist/ui.html" -} -``` -(Format verified against the reference `pixso-plugin/manifest.json`.) - -## Vite configs (two) -**`vite.code.config.ts`** — sandbox bundle (IIFE, no externals, minified): -```ts -export default defineConfig({ - build: { - target: "esnext", minify: true, emptyOutDir: false, outDir: "dist", - lib: { entry: "src/code/code.ts", name: "__pixsoPlugin", formats: ["iife"], fileName: () => "code.js" }, - rollupOptions: { output: { inlineDynamicImports: true } }, - }, -}); -``` -**`vite.ui.config.ts`** — iframe bundle (React + Tailwind v4 + single-file): -```ts -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import { viteSingleFile } from "vite-plugin-singlefile"; -export default defineConfig({ - root: "src/ui", - plugins: [react(), tailwindcss(), viteSingleFile()], - resolve: { alias: { "~": path.resolve(import.meta.dirname, "src/ui") } }, // mirror ru-fork "~/" - build: { target: "esnext", outDir: "../../dist", emptyOutDir: false, - rollupOptions: { input: "src/ui/index.html" } }, -}); -``` -> Two configs because the two bundles have **opposite** constraints (no-DOM IIFE vs React SPA). -> Verified against the reference plugin's `vite.code.config.ts`/`vite.ui.config.ts`. - -## Vendored UI kit (copied from `apps/web`, must stay identical) -Copy these into `src/ui/` and rewrite imports (`~/lib/utils` works via the `~` alias): - -| Source (apps/web) | Dest (plugin) | Notes | -|---|---|---| -| `src/lib/utils.ts` → just `cn()` | `src/ui/lib/utils.ts` | **strip** the `@t3tools/contracts` import + the id helpers; keep only `cn` (cx + twMerge) | -| `src/components/ui/button.tsx` | `src/ui/components/ui/button.tsx` | verbatim | -| `src/components/ui/input.tsx` | `src/ui/components/ui/input.tsx` | verbatim | -| `src/components/ui/label.tsx` | `src/ui/components/ui/label.tsx` | verbatim | -| `src/components/ui/field.tsx` | `src/ui/components/ui/field.tsx` | verbatim | -| `src/components/ui/card.tsx` | `src/ui/components/ui/card.tsx` | verbatim | -| `src/index.css` | `src/ui/index.css` | keep `@import "tailwindcss"`, the `@theme inline` token map, the `@layer base`, and the cross-theme fallbacks. **Drop** imports of themes we don't ship (keep only `./themes/ru-fork.css`). | -| `src/themes/ru-fork.css` | `src/ui/themes/ru-fork.css` | verbatim (light + dark blocks) | - -Exact dep versions (must match `apps/web` so classes/tokens render identically): -`react@19.2.5`, `react-dom@19.2.5`, `@base-ui/react@1.4.1`, `class-variance-authority@0.7.1`, -`tailwind-merge@3.4.0`, `lucide-react@0.564.0`, `tailwindcss@4.2.4`, `@tailwindcss/vite@4.2.4`, -`vite@8.0.10`. - -> **Why copy, not import:** the plugin is a separate vite build that can't pull from the `apps/web` -> app (router/store/effect deps). Per the project rule, we vendor the **presentational** components -> (they only need `@base-ui/react` + `cn`). Mark each copied file with a header comment -> `// pixso-move: vendored from apps/web/src/components/ui/.tsx — keep in sync`. - -## index.html (iframe host) -`src/ui/index.html` sets `data-theme="ru-fork"` + `class="dark"` on `` (so tokens resolve), -imports `index.css`, mounts `#root`, and loads `main.tsx`. Body uses the same font stack as -ru-fork (DM Sans) for parity; bundle the font or fall back to system if offline (corporate). - -## TDD / validation -Plugin is exempt from the 100% rule (no Pixso runtime here), **but**: -- `pnpm --filter @pixso-move/plugin typecheck` → 0 errors (the vendored components must typecheck - against `@base-ui/react@1.4.1`). -- `oxlint` → 0 errors on `src/`. -- `pnpm --filter @pixso-move/plugin build` produces `dist/code.js` + `dist/ui.html`. -- A pure-logic unit test target *is* added in task 7 for the sandbox helpers. - -## Acceptance -- [ ] `build` emits `dist/code.js` (IIFE) + `dist/ui.html` (single file). -- [ ] Vendored components typecheck & lint clean; tokens/classes render (manual eyeball in task 9). -- [ ] Only the `ru-fork` theme is shipped; UI matches ru-fork visually. diff --git a/pixso-move/specs/07-plugin-code.md b/pixso-move/specs/07-plugin-code.md deleted file mode 100644 index dc74baaa600..00000000000 --- a/pixso-move/specs/07-plugin-code.md +++ /dev/null @@ -1,123 +0,0 @@ -# Task 7 — Plugin sandbox (`code.ts`) - -The sandbox side: read the selection, **validate it's one frame**, serialize the node subtree, -export a 1× preview, and bridge to the UI over `postMessage`. Pure helpers are unit-tested. - -## File budget (all authored ≤150 LOC, single-responsibility) -| Path | Responsibility | LOC | tested | -|---|---|---|---| -| `src/code/code.ts` | entry: `showUI`, selection listener, message routing (glue) | ~70 | no (needs Pixso) | -| `src/code/selection.ts` | `validateSelection` — pure | ~25 | yes | -| `src/code/serialize.ts` | `serializeNode` — recursive collect | ~95 | yes | -| `src/code/nodeProps.ts` | per-node property extraction (split out of serialize) | ~60 | yes | -| `src/code/base64.ts` | `bytesToBase64` — pure encoder | ~25 | yes | -| `src/code/settings.ts` | `clientStorage` read/save + `parseSettings` (pure) | ~40 | pure part yes | -| `src/shared/messages.ts` | typed UI↔code message unions (shared with UI) | ~35 | n/a | -| `tests/*` | one per pure module | — | — | - -> If `serialize.ts` would exceed 150 LOC, the per-node property extraction lives in `nodeProps.ts` -> and `serialize.ts` only does the recursion + caps. `messages.ts` lives under `src/shared/` so both -> the sandbox and the iframe import the same unions (DRY). - -## Plugin lifecycle (`code.ts`) -```ts -pixso.showUI(__html__, { width: 380, height: 560 }); -pixso.on("selectionchange", () => postSelectionState()); -pixso.ui.onmessage = (msg: UiToCode) => { /* route: "request-preview" | "collect-and-send-meta" */ }; -``` -- On load and on every `selectionchange`, compute `validateSelection(pixso.currentPage.selection)` - and post the result to the UI (so the UI can enable/disable Send and show the "not allowed" - message live). - -## Selection validation (`selection.ts`) — PURE, the core rule -> Rule: support a **regular selection of exactly one node** (its whole subtree comes with it). -> Reject ctrl/shift multi-select of unrelated items. - -```ts -type SelectionVerdict = - | { ok: true; node: { id: string; name: string } } - | { ok: false; reason: "empty" | "multiple" }; - -export const validateSelection = (selection: ReadonlyArray): SelectionVerdict; -``` -- `selection.length === 0` → `{ ok:false, reason:"empty" }` (UI: "Select a frame to analyze"). -- `selection.length > 1` → `{ ok:false, reason:"multiple" }` (UI: "Select a single frame — multiple - unrelated items aren't supported"). -- `selection.length === 1` → `{ ok:true, node:{ id, name } }`. The one node's children come for - free via serialization; no further parent/page checks needed because a single selected node is by - definition one subtree. - -`SceneNodeLike` is a minimal structural type (`{ id: string; name: string; type: string; … }`) so -the function is testable without the Pixso runtime. - -## Node serialization (`serialize.ts`) -```ts -export const serializeNode = (node: SceneNodeLike): string; // returns JSON string -``` -- Recursively walk `node` + `node.children`, capturing a useful, **stable** subset of properties - (id, name, type, geometry, layout/auto-layout, fills/strokes/effects, text + style, - componentId/variant info). Borrow the property list from the reference's `nodes-full` collector - (`packages/pixso-plugin/.../nodes-full.ts`) as a **starting point** — it's a utility we can adapt. -- Guard depth/size: cap recursion depth and total node count (configurable consts) to avoid - pathological trees; if capped, include a `truncated: true` marker. Log nothing here (sandbox has - no server logger); surface a count to the UI. -- Output is `JSON.stringify` of the collected tree → becomes `IngestRequest.nodesJson`. -- Tested with hand-built fake node trees: nested children serialized; selected subset of props - present; depth/count cap triggers `truncated`; empty children handled. - -## Preview export (1×) — in `code.ts` (not pure; thin) -```ts -const bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 1 } }); -const base64 = bytesToBase64(bytes); // bytesToBase64 is PURE + tested -``` -- `bytesToBase64(Uint8Array): string` is a pure helper (chunked `String.fromCharCode` + `btoa`, or - a manual base64 encoder since the sandbox may lack `btoa` — verify; provide a pure encoder and - test it against known vectors). -- 1× scale per the product decision ("so we can use it later in pixel-perfect"). -- The base64 (no `data:` prefix) → `IngestRequest.preview`. - -## Message bridge (`messages.ts`) — typed both directions -```ts -type CodeToUi = - | { type: "selection-state"; verdict: SelectionVerdict } - | { type: "preview-ready"; preview: string; rootName: string } // base64 - | { type: "collected"; nodesJson: string; rootName: string; preview: string; nodeCount: number } - | { type: "error"; message: string }; -type UiToCode = - | { type: "request-preview" } // user selected → show preview in UI - | { type: "collect-and-send-meta" }; // user pressed Send → gather everything for the UI to POST -``` -- The **UI** owns the `fetch` to the server (it has the saved settings + designerId in - `clientStorage` is read on the code side? — see note). Decision: **settings live UI-side** (the - UI renders/edits them and does the POST); the **code side** only produces nodesJson + preview and - hands them to the UI via `postMessage`. This keeps network out of the sandbox and matches the - reference (UI did the fetch). -- On `collect-and-send-meta`: validate selection again (guard), serialize, export preview, post - `{ type:"collected", nodesJson, rootName, preview, nodeCount }`. On any thrown error, post - `{ type:"error", message }` (UI shows it) — the sandbox never throws unhandled. - -> **clientStorage note:** Pixso `figma.clientStorage` is available in the **sandbox**, not the -> iframe. Two options: (a) UI sends settings to code to persist, code echoes them back on load; or -> (b) UI persists via a `clientStorage` round-trip through messages. Spec choice: the **code side** -> owns `clientStorage` (it's the only side with access); on load it reads settings and posts -> `{ type:"settings-loaded", settings }` to the UI; the UI sends `{ type:"save-settings", settings }` -> which code persists. Add these two message variants. (This is the one place the sandbox touches -> persistence; keep it tiny and tested via a fake clientStorage.) - -## TDD — tests first -Pure helpers are fully tested (this is the plugin's tested surface): -- `validateSelection`: empty / single / multiple → correct verdicts. -- `serializeNode`: structure, prop subset, depth/count cap + `truncated`, empty children. -- `bytesToBase64`: known byte→base64 vectors; empty input. -- (if code-side settings) a `parseSettings`/`applyDefaults` pure helper for stored settings → - tested for missing/partial/garbage stored values. - -The thin glue in `code.ts` (showUI, exportAsync, message routing) is not unit-tested (needs the -Pixso runtime) — kept minimal; validated manually in task 9. - -## Acceptance -- [ ] Selecting one frame → `{ ok:true }`; zero or multiple → the right rejection reason. -- [ ] Serialization produces stable JSON of the subtree with caps. -- [ ] 1× preview exported as base64. -- [ ] Sandbox never throws unhandled; errors are posted to the UI. -- [ ] Pure helpers tested; `typecheck`/oxlint clean. diff --git a/pixso-move/specs/08-plugin-ui.md b/pixso-move/specs/08-plugin-ui.md deleted file mode 100644 index d821dffb3bf..00000000000 --- a/pixso-move/specs/08-plugin-ui.md +++ /dev/null @@ -1,103 +0,0 @@ -# Task 8 — Plugin UI (iframe React app) - -The designer-facing UI, built from the vendored ru-fork kit (task 6) and bridged to the sandbox -(task 7). Three screens: **Settings**, **Select/Preview**, **Send**. The UI owns settings state and -the `fetch` to the server. - -## File budget (all authored ≤150 LOC, single-responsibility) -| Path | Responsibility | LOC | tested | -|---|---|---|---| -| `src/ui/App.tsx` | wire `reducer` + `useBridge` + switch screen (thin) | ~70 | manual | -| `src/ui/state/reducer.ts` | `reduce(state, msg)` — **pure** state machine | ~70 | yes | -| `src/ui/state/types.ts` | `Settings`, `UiState`, `Screen` types | ~25 | n/a | -| `src/ui/bridge.ts` | `postToCode` + `useBridge(dispatch)` hook (Pixso envelope) | ~40 | hook glue | -| `src/ui/api.ts` | `buildIngestRequest` (pure) + `sendToServer(_, _, fetchImpl)` | ~45 | yes | -| `src/ui/key.ts` | `generateDesignerId()` (`dz_${uuid}`) — pure | ~10 | yes | -| `src/ui/screens/SettingsScreen.tsx` | settings form (vendored Card/Field/Input/Button) | ~95 | manual | -| `src/ui/screens/SelectScreen.tsx` | placeholder + preview + Send | ~95 | manual | -| `src/ui/components/ui/*`, `lib/utils.ts` | **vendored** (task 6) | exempt | - -> `App.tsx` stays thin: it holds `useReducer(reduce, initial)`, calls `useBridge(dispatch)`, and -> renders a screen by `state.screen`. **All UI logic is in the pure `reduce` + `api` + `key` -> modules** (100% unit-tested), so no screen file needs logic beyond rendering + dispatch. - -## State model (local React state; no store needed) -```ts -type Settings = { serverUrl: string; designerId: string }; -type Screen = "select" | "settings"; -// plus: selectionVerdict, preview (base64|null), rootName, isSending, sendResult, errorMessage -``` -- Settings come from the sandbox on load (`settings-loaded` message) and are saved back via - `save-settings`. The UI never persists directly (sandbox owns `clientStorage`). - -## Screen 1 — Settings (`SettingsScreen`) -Built with vendored `Card`, `Field`/`FieldLabel`/`FieldDescription`, `Input`, `Button`, `Label`. -- **Server URL** field — `Input`, default `http://localhost:7787`. Basic validation (non-empty, - parses as URL) shown via `FieldError`. -- **Designer key (`designerId`)** field — `Input` showing the current key + two buttons: - - **Generate** → `dz_${uuid}` (a `generateDesignerId()` pure helper; uuid via `crypto.randomUUID` - in the iframe — DOM context, available). Fills the field. - - **Save** → posts `save-settings` to the sandbox; shows a saved confirmation. - - Copy-to-clipboard affordance (designer "writes it on paper"/shares it). -- Reachable via a gear button in the header from the Select screen, and on first run (no saved key) - the UI opens Settings by default. - -## Screen 2 — Select / Preview / Send (`SelectScreen`) -Driven by `selection-state` messages from the sandbox: -- **No/var selection** → placeholder card: "Select a frame to analyze" (verdict `empty`) or - "Select a single frame — multiple unrelated items aren't supported" (verdict `multiple`). - Send disabled. -- **Valid single selection** → request a preview (`request-preview` → `preview-ready`), show the - **preview image** (the base64 PNG) in a `Card`, the frame name, and an enabled **Send to server** - `Button`. -- **Send** flow: - 1. Guard: settings present (else route to Settings with a hint). - 2. Ask the sandbox to collect (`collect-and-send-meta` → `collected { nodesJson, rootName, - preview, nodeCount }`). - 3. `api.sendToServer(settings, { designerId, rootName, nodesJson, preview })` → `POST {serverUrl}/ingest` - with header `x-designer-id: ` and `IngestRequest` body. - 4. On 200 → success state (show returned `nodeId`); on non-2xx or network error → `errorMessage` - from the response/`catch` (the UI surfaces server errors verbatim). - -## api.ts (testable) -```ts -export const buildIngestRequest = (s: Settings, p: Collected): { url; headers; body }; // PURE -export const sendToServer = (s: Settings, p: Collected, fetchImpl = fetch): Promise; -``` -- `buildIngestRequest` is pure (URL join, headers incl. `x-designer-id`, JSON body matching - `IngestRequest`) → unit-tested. -- `sendToServer` takes an injectable `fetchImpl` → unit-tested with a fake fetch for 200 / 400 / - 413 / network-throw, asserting it maps to `SendResult`. - -## Bridge (`bridge.ts`) -Thin `postToCode(msg: UiToCode)` and a `useBridge(dispatch)` hook wrapping `window.onmessage` → -`event.data.pluginMessage` (Pixso/Figma envelope), typed by the shared `src/shared/messages.ts` -unions (same file the sandbox imports). The hook only forwards incoming messages to `dispatch`; the -pure `reduce(state, msg)` (in `state/reducer.ts`) turns them into state — and is unit-tested. - -## Look & feel -- Identical to ru-fork: same tokens (`bg-card`, `text-foreground`, `border-border`, `rounded-2xl`, - button variants). Use `variant="default"` for primary Send, `variant="outline"`/`"ghost"` for - secondary, `Alert` (if vendored) or `FieldError` for errors. -- Compact, plugin-sized (≈380×560). Header with title + gear (settings) button. - -## TDD / validation -Plugin UI is exempt from the 100% gate, but the **pure** units are tested (vitest + jsdom or pure): -- `buildIngestRequest` → correct url/headers/body for given settings+payload. -- `sendToServer` → 200/4xx/throw mapped to `SendResult` via fake fetch. -- `generateDesignerId` → `dz_` prefix + uuid shape. -- `reduce(state, msg)` → each `CodeToUi` message produces the right state transition - (selection-state enables/disables Send; preview-ready sets the image; error sets errorMessage; - collected triggers send; settings-loaded fills settings). -- React rendering is validated manually in task 9 (Pixso). Optionally add React Testing Library - smoke tests for the two screens if jsdom is wired — not required for the gate. - -`typecheck` + `oxlint` must be clean. - -## Acceptance -- [ ] Settings screen sets/generates/saves server URL + designerId; persisted via sandbox. -- [ ] Placeholder + the exact "not allowed" copy for multi-select; Send disabled when invalid. -- [ ] Valid selection shows the 1× preview + enabled Send. -- [ ] Send POSTs the correct `IngestRequest` with `x-designer-id`; success shows `nodeId`, errors - surfaced verbatim. -- [ ] Looks identical to ru-fork; pure logic tested; `typecheck`/oxlint clean. diff --git a/pixso-move/specs/09-end-to-end.md b/pixso-move/specs/09-end-to-end.md deleted file mode 100644 index 373eb7f7013..00000000000 --- a/pixso-move/specs/09-end-to-end.md +++ /dev/null @@ -1,59 +0,0 @@ -# Task 9 — End-to-end: gates + manual smoke - -Final wiring, the green-gate verification, and the manual smoke test (which **you** run, since this -box has no Pixso and no qwen). - -## Automated gates (must all pass) -Run from the worktree root: -```bash -pnpm install -pnpm -w lint # oxlint — 0 errors across pixso-move/* -turbo run typecheck --filter='@pixso-move/*' # tsc --noEmit — 0 errors -turbo run test --filter='@pixso-move/*' # vitest --coverage -``` -- **Server-side coverage = 100%** for `@pixso-move/contracts`, `@pixso-move/server`, - `@pixso-move/processor` (thresholds enforced in each `vitest.config.ts`; only `Migrations/**`, - `bin.ts`, and the justified `acpRunnerLive.ts` spawn glue are excluded). -- **Plugin** typechecks, lints, and `build`s (`dist/code.js` + `dist/ui.html`). - -> Note on this environment: per project memory, native-binding-dependent test runs can fail in this -> box. `node:sqlite` is a built-in (Node 22, `--experimental-sqlite`) and the ACP path is faked in -> tests, so the server-side suites are expected to run headless. If any suite truly can't execute -> here, that is flagged explicitly (not silently skipped) and you run it on a real machine. - -## Manual smoke (you run it) -Because Pixso and qwen live on your machine: - -1. **Server**: `pnpm --filter @pixso-move/server start --port 7787 --db ./.data/pixso.sqlite - --cli-js `. Confirm it logs startup (debug) and listens. -2. **Processor config**: edit `pixso-move/processor/src/config.ts` to add an entry for the - `designerId` you'll use in the plugin, with a real prompt + `resultTag`. -3. **Plugin**: `pnpm --filter @pixso-move/plugin build`; import `pixso-move/plugin/manifest.json` - into Pixso (Plugins → Development → Import). Open the plugin. -4. In the plugin: open **Settings**, set Server URL `http://localhost:7787`, **Generate** a key, - **Save** (use that same `designerId` in the processor config). -5. Select **one frame** → see the preview + enabled Send. (Try selecting two unrelated items → the - "not allowed" message; Send disabled.) -6. **Send** → expect 200 + a `nodeId`. -7. Verify storage + enrichment: - ```bash - curl -H "x-designer-id: " http://localhost:7787/nodes - curl -H "x-designer-id: " http://localhost:7787/nodes/ - curl -H "x-designer-id: " "http://localhost:7787/processing-data?nodeId=" - ``` - `processing-data` should show rows transitioning `pending → processing → done` (or `error` with - a message), one per configured `resultTag`, with the LLM `result` text on `done`. -8. **Robustness checks**: stop the server mid-processing and restart → `processing` rows recover to - `pending` and finish. Point a config entry's prompt at something that makes qwen fail → the row - goes `error` with a message; the server stays up; other jobs still process. - -## What to capture -- Server logs for an ingest + a full processing cycle (`logDebug` steps + any `logError`). -- The three `curl` outputs. -- Confirmation that multi-select is rejected and single-select works. - -## Acceptance (project-level) -- [ ] All automated gates green; server-side 100%. -- [ ] Manual smoke: ingest → stored → processed → readable, end to end, against real qwen. -- [ ] Crash recovery and error-marking observed working. -- [ ] Server never crashed throughout. diff --git a/pixso-move/specs/README.md b/pixso-move/specs/README.md deleted file mode 100644 index 03f9fc5cf9a..00000000000 --- a/pixso-move/specs/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# pixso-move — specs - -> **Implementation status:** see [`../STATUS.md`](../STATUS.md). Tasks 1, 2, 3, 6–8 are **done and -> green** (server-side 100% coverage); Tasks 4–5 (real processor + embed) are **deferred**. STATUS.md -> also records where the implementation deviates from these specs (e.g. `GET /node?id=`, theme via -> `pixso.clientStorage`, capped display preview). - - -Design-handoff pipeline for **Pixso** (a Figma-style design tool) where the corporate -environment **blocks all Pixso APIs**. The only way to extract design data is a **plugin** -running inside Pixso. Raw node JSON is useful on its own, but we **enrich it with an LLM** -(qwen, driven over ACP) to produce more usable, developer-facing output. - -Three actors, one data spine: - -``` - ┌──────────┐ POST /ingest ┌──────────┐ reconcile+claim ┌────────────┐ - │ PLUGIN │ ──{designerId,preview, │ SERVER │ ───────────────────▶ │ PROCESSOR │ - │ (Pixso) │ nodes}────────────▶ │ (HTTP + │ (embedded in │ (effect-acp│ - │ │ │ sqlite) │ server runtime) │ → qwen) │ - └──────────┘ ◀── GET /nodes … └──────────┘ ◀── writes results ──└────────────┘ - designer developer reads sqlite (nodes, processing_results) -``` - -- **Plugin** (designer): selects one frame, sends `{designerId, preview, nodes}` to the server. -- **Server** (storage + read API): validates, persists to sqlite, exposes key-gated GET endpoints. -- **Processor** (enrichment): for *configured* designers, runs configured prompts through qwen and - writes status-tracked results that developers query. - -## Why this design - -- Pixso APIs are blocked → a **plugin is the only extraction path**. -- Raw nodes are useful, but **LLM enrichment** (component specs, code, summaries — whatever a - prompt asks) makes them more usable. -- A designer carries a **key (`designerId`)** — generates/saves it once, shares it with - developers so they can read that designer's nodes and enriched results. - -## This is built FRESH on ru-fork architecture - -The reference product at `…/experements/pixso-move` is **someone else's**. We borrow *ideas and -utility helpers only* (node serialization, poll+notify loop, atomic claim, code-fence extraction). -Everything else — server, plugin UI, processor — is built from scratch on **ru-fork best -practices**: Effect + effect-platform server, ru-fork web components/styles for the plugin UI, -and ru-fork's own `effect-acp` package for the CLI/ACP integration. - -## Layout - -``` -pixso-move/ - contracts/ @pixso-move/contracts — effect Schema: requests/responses/rows + tagged errors - server/ @pixso-move/server — Effect HTTP + node:sqlite; embeds the processor - processor/ @pixso-move/processor — drives effect-acp; reconcile→claim→ACP→store loop - plugin/ @pixso-move/plugin — Pixso plugin: code.ts (sandbox) + iframe UI (vendored ru-fork kit) - specs/ these documents -``` - -## Spec index - -| # | Spec | Covers | -|---|------|--------| -| — | [conventions.md](./conventions.md) | Lint, typecheck, test, TDD/coverage policy, import style, Effect idioms — **read first, applies to every task** | -| — | [00-overview.md](./00-overview.md) | Vision, full data model, contracts, endpoint table, glossary | -| 1 | [01-scaffold.md](./01-scaffold.md) | 4 packages, package.json, tsconfigs, workspace wiring, effect-acp confirmation | -| 2 | [02-contracts.md](./02-contracts.md) | effect Schema for all requests/responses/rows + tagged errors | -| 3 | [03-server.md](./03-server.md) | sqlite layer + migrations (2 tables), HTTP routes, logger, config, bootstrap | -| 4 | [04-processor.md](./04-processor.md) | effect-acp client wrapper, reconcile+claim repo, run loop, crash recovery, config | -| 5 | [05-embed.md](./05-embed.md) | compose processor into server runtime, `notify()` on ingest | -| 6 | [06-plugin-build.md](./06-plugin-build.md) | two vite configs, manifest, vendor UI kit + theme + `cn` | -| 7 | [07-plugin-code.md](./07-plugin-code.md) | selection validation, node serialize, `exportAsync` 1× preview, postMessage bridge | -| 8 | [08-plugin-ui.md](./08-plugin-ui.md) | settings / preview / send screens, clientStorage, fetch to server | -| 9 | [09-end-to-end.md](./09-end-to-end.md) | typecheck/lint/coverage gates; manual smoke (user runs Pixso + qwen) | - -## Hard rules (non-negotiable, from the product owner) - -1. **All server-side code is covered by tests — 100% coverage — written TDD (tests first).** -2. **Lint + typecheck identical to ru-fork web/server. Zero lint errors, zero typecheck errors.** -3. **Server logs everything — `logError` / `logDebug` only** (no info/warn). Especially the - processing steps, so we can see what failed and what worked. **The server must never crash.** -4. Processing has **full status tracking** (pending/processing/done/error, attempts, error text, - timestamps) for observability, retry, and crash recovery. -5. **Production-grade, senior-level, DRY.** Every authored source file is **≤150 LOC**, single - responsibility, well-decomposed. Cross-cutting logic is defined **once** and reused (shared - tsconfig/vitest base, one error→response mapper, one route wrapper, one timestamp source). - Proven upstream infra (e.g. the `node:sqlite` client) is **vendored verbatim** under `vendor/`, - isolated and exempt — never rewritten. See [conventions.md](./conventions.md) §2. diff --git a/pixso-move/specs/conventions.md b/pixso-move/specs/conventions.md deleted file mode 100644 index 2aa33d0b534..00000000000 --- a/pixso-move/specs/conventions.md +++ /dev/null @@ -1,200 +0,0 @@ -# Conventions — applies to every task - -Derived from the ru-fork monorepo (verified by reading `apps/server`, `apps/web`, `packages/*`, -root configs in this worktree). Every `pixso-move/` package obeys these. **This is the backbone — -read it before any task.** - ---- - -## 1. Toolchain (identical to ru-fork) - -| Concern | Tool / version | Source | -|---|---|---| -| Language | TypeScript 5.9.3, ESM, `module`/`moduleResolution`: `NodeNext` | `tsconfig.base.json` | -| Effect | `effect` (catalog) — **namespace** imports | throughout | -| Lint | **oxlint** 1.63.0 (root `.oxlintrc.json`) | root `package.json:19` | -| Format | **oxfmt** | root `package.json` | -| Typecheck | `tsc --noEmit` per package | `apps/server/package.json:23` | -| Tests | **vitest 3.2.4** + `@effect/vitest` 4.0.0-beta.59 | `pnpm-workspace.yaml` | -| Coverage | `@vitest/coverage-v8` 3.2.4, provider `v8` | `apps/server/vitest.config.ts` | -| Server bundler | tsdown 0.20.3 | `apps/server/tsdown.config.ts` | -| Plugin bundler | vite 8 (two configs) | [06](./06-plugin-build.md) | - -Root scripts run with `NODE_OPTIONS='--experimental-strip-types --experimental-sqlite'`. -`node:sqlite` is **built-in** (Node 22+); there is **no `better-sqlite3`**. - -### Gate commands (every package must pass, zero issues) -```bash -pnpm -w lint # oxlint, 0 errors -turbo run typecheck --filter='@pixso-move/*' # tsc --noEmit, 0 errors -turbo run test --filter='@pixso-move/*' # vitest --coverage; server-side = 100% -``` - ---- - -## 2. Senior-engineering rules (non-negotiable) - -### 2.1 File & module budget — **hard cap 150 LOC per file** -- **No source file exceeds 150 lines of code** (blank lines and comment-only lines don't count; - everything else does). If a module approaches the cap, **decompose by responsibility** before - adding more. -- **One responsibility per file.** A file is either: a service *interface* (tag + shape), a service - *implementation*, a *pure helper module*, a *route*, a *migration*, a *schema group*, or a - *layer-composition* module — never a mix. -- Every task spec carries a **file-budget table** (path · responsibility · est. LOC). Implementation - must match it; deviations require splitting, not inflating. -- **Functions:** keep them small and single-purpose; prefer pure functions extracted from effectful - shells so logic is unit-testable without I/O. - -### 2.2 DRY — shared infrastructure, defined once -Cross-cutting concerns live in exactly one place and are imported, never re-pasted: -- `pixso-move/tsconfig.base.json` — shared TS config (extends the repo `../tsconfig.base.json`); - every package `extends` it. (Avoids per-package compiler-option drift.) -- `pixso-move/vitest.base.ts` — shared vitest/coverage config factory; each package's - `vitest.config.ts` is a 3-line call into it (see §5). -- `@pixso-move/contracts` `src/base.ts` — the shared schema primitives (`TrimmedNonEmptyString` - etc.), copied once from ru-fork `baseSchemas.ts`. -- `@pixso-move/server` `src/http/respond.ts` + `src/http/route.ts` — the single error→response - mapper and the single route wrapper (auth + catch + cors). Every route reuses them; no route - re-implements error handling. -- `@pixso-move/server` `src/time.ts` — `const nowIso = Effect.map(DateTime.now, DateTime.formatIso)` - (the one timestamp source; matches `ws.ts:124`). - -### 2.3 Vendoring policy (ported upstream code) -Some ru-fork infrastructure is reused verbatim and is **too large to rewrite or re-test** (e.g. -`NodeSqliteClient.ts` is 277 LOC). Such files: -- live under a `vendor/` directory in the consuming package (e.g. `server/src/vendor/`); -- are copied **verbatim** with a header `// pixso-move: vendored from @ — - keep in sync; do not edit`; -- are **exempt** from the 150-LOC cap, from 100% coverage, and from our authored-code lint - expectations (they are upstream code); -- are excluded in `vitest.config.ts` coverage and may be `oxlint`-ignored if upstream style - diverges. -This keeps *our* authored surface small, DRY, and fully owned, while reusing proven infra. - ---- - -## 3. TypeScript rules (from `tsconfig.base.json`) - -All ON; zero typecheck errors: -- `strict`, `noUncheckedIndexedAccess` (array access is `T | undefined` — guard it), - `exactOptionalPropertyTypes` (spread conditional optionals: `{ ...(env ? { env } : {}) }`), - `verbatimModuleSyntax` (`import type` for types), `noImplicitOverride`, `useDefineForClassFields`. -- `allowImportingTsExtensions` + `rewriteRelativeImportExtensions` → **relative imports carry `.ts`**: - `import { x } from "./x.ts"`. Bare package specifiers don't. - -The `@effect/language-service` "error" diagnostics in `tsconfig.base.json` are **editor-only** — they -do not affect `tsc --noEmit`/oxlint, so `crypto.randomUUID()` and friends compile. We still prefer -the testable idioms in §4 where they help. - -### Import style -- Effect: `import * as Effect from "effect/Effect"`, `* as Layer`, `* as Schema`, `* as DateTime - from "effect/DateTime"`, `* as SqlClient from "effect/unstable/sql/SqlClient"`. **Never** barrel - `"effect"`. -- HTTP: `import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"`. -- ACP: `import * as AcpClient from "effect-acp/client"`, `* as AcpErrors from "effect-acp/errors"`, - `* as AcpSchema from "effect-acp/schema"`. - ---- - -## 4. Effect idioms (server + processor) - -### Confirmed Schema API (from `packages/contracts/src/baseSchemas.ts` + usage) -```ts -Schema.String, Schema.Int, Schema.Number, Schema.Struct({...}), Schema.NullOr(x), -Schema.Literal("a"), Schema.Literals(["a","b","c"]), Schema.Array(x), Schema.brand("Brand") -// refinements via .check(...): -.check(Schema.isNonEmpty()) // non-empty string/array -.check(Schema.isMaxLength(n)) / .check(Schema.isMinLength(n)) -.check(Schema.isBetween({ minimum, maximum })) -.check(Schema.isGreaterThanOrEqualTo(n)) / .check(Schema.isLessThanOrEqualTo(n)) -// reuse, don't redefine: -TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()) -makeEntityId(brand) = TrimmedNonEmptyString.pipe(Schema.brand(brand)) -// decode at the edge: -Schema.decodeUnknownExit(schema)(input) → Exit ; HttpServerRequest.schemaBodyJson(schema) -``` - -### Timestamps — never `new Date()` -`import * as DateTime from "effect/DateTime"`; `nowIso = Effect.map(DateTime.now, DateTime.formatIso)` -(one shared module, §2.2). Tests pin the clock. - -### IDs -`NodeId.make(crypto.randomUUID())` (ru-fork pattern, `serverRuntimeStartup.ts:160`). Designer keys -are generated client-side in the plugin (`dz_${uuid}`). - -### Logging — `logError` / `logDebug` ONLY -`Effect.logError("msg", { …annotations })` for failures/defects we must see; `Effect.logDebug` for -traces, expected failures, and processing progress. **No** `logInfo`/`logWarning`. Annotate with -structured objects. - -### Never crash -Every fallible unit (HTTP handler, processor job, ingest→notify) is wrapped so a failure is -**logged and contained**: handlers → typed-error JSON or a 500+`logError`; processor jobs → -`error` row + `logError` + loop continues. Mirror the three-way cause split in `ws.ts:57-81` -(defect→error, interrupt→debug, typed→debug). - -### Errors as data -`Schema.TaggedErrorClass()("Name", { …fields })` (`git.ts:320`). Handlers map them via the -shared `respond.ts` (§2.2), never inline. - ---- - -## 5. Testing & coverage (server-side: TDD, 100%) - -**Server-side** = `@pixso-move/contracts`, `@pixso-move/server`, `@pixso-move/processor` → **100%** -(lines/branches/functions/statements), minus the documented exclusions (`Migrations/**`, `bin.ts`, -`vendor/**`, justified spawn glue). **Plugin** is exempt from 100% (needs Pixso runtime); its *pure* -helpers are still unit-tested, and it must typecheck + lint clean. - -### TDD per unit: red → green → refactor. Tests live in `tests/**/*.test.ts`, mirroring `src/`. - -### Shared vitest base (`pixso-move/vitest.base.ts`) — DRY -```ts -import * as path from "node:path"; -import { defineConfig } from "vitest/config"; -export const makeVitestConfig = (dir: string) => defineConfig({ - resolve: { alias: [{ find: /^@pixso-move\/contracts$/, - replacement: path.resolve(dir, "../contracts/src/index.ts") }] }, - test: { - include: ["tests/**/*.test.ts"], testTimeout: 60_000, hookTimeout: 60_000, - coverage: { - provider: "v8", reporter: ["text", "json-summary", "lcov"], reportsDirectory: "./coverage", - all: true, include: ["src/**/*.ts"], - exclude: ["src/**/*.test.ts", "src/**/testUtils/**", "src/persistence/migrations/**", - "src/vendor/**", "src/bin.ts", - "src/**/*.integration.ts"], // *.integration.ts = real-process spawn glue only, justified per file - thresholds: { lines: 100, branches: 100, functions: 100, statements: 100 }, - }, - }, -}); -``` -Each package `vitest.config.ts`: -```ts -import { makeVitestConfig } from "../vitest.base.ts"; -export default makeVitestConfig(import.meta.dirname); -``` - -### Idioms (from ru-fork) -```ts -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -it.effect("does X", () => Effect.gen(function* () { /* … */ })); -const layer = it.layer(SqlitePersistenceMemory); // migrations run in :memory: -layer("repo", (it) => it.effect("inserts", () => Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; /* … */ -}))); -``` -No real network, no real child processes, no real `Date.now()` in any test. The processor's ACP -dependency is injected behind `AcpRunner` and faked. SQL stores run against `:memory:`. - ---- - -## 6. Definition of done (every task) -- [ ] Tests written first; green; server-side coverage 100%. -- [ ] Every source file ≤ 150 LOC, single-responsibility (vendor/ exempt). -- [ ] No duplicated cross-cutting logic — shared infra (§2.2) reused. -- [ ] `tsc --noEmit` + `oxlint` clean (0 errors). -- [ ] `logError`/`logDebug` only; nothing can crash the process. -- [ ] Matches the data model + contracts in [00-overview.md](./00-overview.md).