refactor(ts): complete legacy host removal — drop fastify/commander/zod, delete MCP SDK server, remove ManagedRuntime facades

Finishes the remaining EFFECT_NATIVE_REWRITE_PLAN stages in one verified slice:
- fastify, @fastify/websocket, commander, zod removed from all package manifests
- legacy @modelcontextprotocol/sdk stdio server deleted; effect/unstable/ai McpServer is canonical
- no ManagedRuntime or Effect.runPromise program facades remain in production source
- gateway server/rpc-contract and client rpc/socket moved onto Effect v4 native http/rpc/socket layers

Gates (force-run, no cache): check:tsgo, build, test (96 tests / 11 tasks) all green.
Native-class inventory: zero blocking production classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-06-11 06:29:29 -05:00
parent a26463afc1
commit cf12defcd8
30 changed files with 1506 additions and 456 deletions

View file

@ -27,14 +27,6 @@
"name": "@trustgraph/base",
"version": "0.1.0",
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"effect": "4.0.0-beta.78",
"nats": "^2.29.0",
},
@ -52,16 +44,10 @@
"tg": "dist/index.js",
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"effect": "4.0.0-beta.78",
"ws": "^8.18.0",
},
"devDependencies": {
@ -97,21 +83,13 @@
"version": "0.1.0",
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78",
"@mistralai/mistralai": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"@qdrant/js-client-rest": "^1.13.0",
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"effect": "4.0.0-beta.78",
"falkordb": "^5.0.0",
"ollama": "^0.6.3",
@ -129,20 +107,11 @@
"name": "@trustgraph/mcp",
"version": "0.1.0",
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78",
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"effect": "4.0.0-beta.78",
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.78",
@ -155,21 +124,11 @@
"name": "@trustgraph/workbench",
"version": "0.1.0",
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78",
"@tanstack/react-query": "^5.75.0",
"@trustgraph/client": "workspace:*",
"clsx": "^2.1.0",
"effect": "4.0.0-beta.78",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -178,7 +137,6 @@
"react-markdown": "^10.1.0",
"react-router": "^7.6.0",
"tailwind-merge": "^3.3.0",
"zustand": "^5.0.0",
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.78",
@ -234,16 +192,8 @@
"@effect/ai-anthropic": ["@effect/ai-anthropic@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-FZzwvKx2k+UIDJyv+FWMtC2CKRxJMWTII+z8EmMcT6tw1uzkkn+ouIyJ32AU+lfveKScNV1SXL4ml3VdcYGy/g=="],
"@effect/ai-openai": ["@effect/ai-openai@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-tx953rRkLqW2BeEkWK12/nRBPO0b1eS6pI+2YyWI0nQvX2JTTijrGlBv/qDVa5kxDkLm63+tA04xnxgZMlA8NA=="],
"@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-9GIRU9stAnDU5EJ5ZghUWrQXaE+rECCWI/eKVfYeC7UqjZmmmJmTcEbid3tvz2NMsnvIn0ymeKsJAohWCys39w=="],
"@effect/atom-react": ["@effect/atom-react@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78", "react": "^19.2.4", "scheduler": "*" } }, "sha512-cgxDXJaD0wlbQXbp6tiEmmY+yajwurB0ynkFG20RVucvH4LsQMB3ogiHe0mt42wGggfbVYMEDxgBpQdqDRY8yA=="],
"@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.78", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.78", "effect": "^4.0.0-beta.78" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-qhKRcZCNQ5b0Klrct+AC/tPQgIDBxVsD0MkQLIzqvLU3qRHaNd5yHo7kxFf/DuhCyyL++xZfbHsPdq3VdLIByg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.78", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.78" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OJGnlNkxfhUmZ/8aLIfQly8ic2tntcnwidAP0BdrTUKa1/sbZjq5xTrhVUjvmehFra2Thsef0k4UPTgsOrBG1A=="],
"@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.78", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-8r9MVuZ8xJRyVyi+C8SKSYLbMsHr7qOiUgLV6lKMECuAWyMhlbK/7Ka9SQGr0ZPqOe5ShLEvV7DevnGkG+owAQ=="],
"@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.78", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.78" }, "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-lmPCL1G7SlkCWCguX3rDPS7kKuvJ/AN4pjS7IXb/5SoauHPd67iUdc1ZbB7o6lwTChJaIfWNNPkUWygiaUeJiA=="],
@ -382,8 +332,6 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
"@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
@ -478,10 +426,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "6.4.1" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.96.2", "", {}, "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.96.2", "", { "dependencies": { "@tanstack/query-core": "5.96.2" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA=="],
"@trustgraph/base": ["@trustgraph/base@workspace:packages/base"],
"@trustgraph/cli": ["@trustgraph/cli@workspace:packages/cli"],
@ -1242,8 +1186,6 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "3.25.76" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zustand": ["zustand@5.0.12", "", { "optionalDependencies": { "@types/react": "19.2.14", "react": "19.2.4" } }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@qdrant/js-client-rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],

View file

@ -20,14 +20,6 @@
"test": "bunx --bun vitest run"
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"effect": "4.0.0-beta.78",
"nats": "^2.29.0"
},

View file

@ -7,7 +7,7 @@
*/
import type { PubSubBackend } from "../backend/types.js";
import { makeNatsBackend } from "../backend/nats.js";
import { makeNatsBackend, makeNatsBackendScoped } from "../backend/nats.js";
import { Cause, Config as EffectConfig, Context, Effect } from "effect";
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
import { loadProcessorRuntimeConfig } from "../runtime/config.js";
@ -198,6 +198,27 @@ export function makeAsyncProcessor<
return processor;
}
export const makeAsyncProcessorScoped = Effect.fn("makeAsyncProcessorScoped")(function* <
RunError = ProcessorLifecycleError,
RunRequirements = never,
>(
config: ProcessorConfig,
options: AsyncProcessorRuntimeOptions<RunError, RunRequirements> = {},
) {
if (config.pubsub !== undefined) {
return makeAsyncProcessor(config, options);
}
const pubsub = yield* makeNatsBackendScoped(config.pubsubUrl ?? "nats://localhost:4222");
return makeAsyncProcessor(
{
...config,
pubsub,
},
options,
);
});
export type AsyncProcessor<
RunError = ProcessorLifecycleError,
RunRequirements = never,

View file

@ -1,6 +1,7 @@
export {
AsyncProcessor,
makeAsyncProcessor,
makeAsyncProcessorScoped,
type ConfigHandler,
type EffectConfigHandler,
type AsyncProcessorRuntime,

View file

@ -11,7 +11,7 @@ import {
type ProcessorLifecycleError,
type PubSubError,
} from "../errors.js";
import { makeNatsBackend } from "../backend/nats.js";
import { makeNatsBackendScoped } from "../backend/nats.js";
import { makePubSubService, PubSub } from "../backend/pubsub.js";
import {
ConsumerFactory,
@ -119,18 +119,9 @@ export function makeProcessorProgram<
manageProcessSignals: false,
} as Config;
const pubsub = makePubSubService(makeNatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222"));
const backend = yield* makeNatsBackendScoped(runtimeConfig.pubsubUrl ?? "nats://localhost:4222");
const pubsub = makePubSubService(backend);
const messagingConfig = yield* loadMessagingRuntimeConfig();
yield* Effect.addFinalizer(() =>
pubsub.close.pipe(
Effect.catch((error) =>
Effect.logError("[PubSub] Failed to close processor backend", {
error: error.message,
operation: error.operation,
}),
),
),
);
const processorEffect = runProcessorScoped<Config, RunError, RunRequirements>(
runtimeConfig,
options.make,
@ -202,18 +193,9 @@ export function makeFlowProcessorProgram<
manageProcessSignals: false,
} as Config;
const pubsub = makePubSubService(makeNatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222"));
const backend = yield* makeNatsBackendScoped(runtimeConfig.pubsubUrl ?? "nats://localhost:4222");
const pubsub = makePubSubService(backend);
const messagingConfig = yield* loadMessagingRuntimeConfig();
yield* Effect.addFinalizer(() =>
pubsub.close.pipe(
Effect.catch((error) =>
Effect.logError("[PubSub] Failed to close processor backend", {
error: error.message,
operation: error.operation,
}),
),
),
);
const configHandlers = options.configHandlers?.(runtimeConfig);
const processorOptions = {

View file

@ -12,16 +12,10 @@
"test": "bunx --bun vitest run --passWithNoTests --exclude=dist/**"
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"effect": "4.0.0-beta.78",
"ws": "^8.18.0"
},
"devDependencies": {

View file

@ -7,37 +7,77 @@
import { Effect } from "effect";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import { cliCommandError, withSocket } from "./util.js";
import { cliCommandError, withGatewayClient, type CliCommandError } from "./util.js";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringProperty(source: unknown, key: string): string | undefined {
const value = asRecord(source)[key];
return typeof value === "string" ? value : undefined;
}
function booleanProperty(source: unknown, key: string): boolean | undefined {
const value = asRecord(source)[key];
return typeof value === "boolean" ? value : undefined;
}
function responseErrorMessage(source: unknown): string | undefined {
const error = asRecord(source).error;
if (typeof error === "string") return error;
return stringProperty(error, "message");
}
export const agentCommand = Command.make("agent", {
question: Argument.string("question").pipe(Argument.withDescription("Question to ask")),
}, ({ question }) =>
withSocket((socket, opts) =>
withGatewayClient((client, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
let streamError: CliCommandError | undefined;
yield* Effect.callback<void, ReturnType<typeof cliCommandError>>((resume) => {
flow.agent(
yield* client.runDispatchStream(
{
scope: "flow",
flow: opts.flow,
service: "agent",
request: {
question,
(chunk) => {
// think — show thought process
if (chunk.length > 0) process.stderr.write(chunk);
},
(chunk) => {
// observe — show observations
if (chunk.length > 0) process.stderr.write(chunk);
},
(chunk, complete) => {
// answer — print to stdout
if (chunk.length > 0) process.stdout.write(chunk);
if (complete) {
process.stdout.write("\n");
resume(Effect.void);
}
},
(err) => resume(Effect.fail(cliCommandError("agent", err))),
);
});
user: opts.user,
collection: "default",
streaming: true,
},
},
(chunk) => {
const resp = asRecord(chunk.response);
const chunkType = stringProperty(resp, "chunk_type");
const error = chunkType === "error" ? responseErrorMessage(resp) ?? "Unknown agent error" : responseErrorMessage(resp);
if (error !== undefined) {
streamError = cliCommandError("agent", error);
return true;
}
const content = stringProperty(resp, "content") ?? "";
const messageComplete = booleanProperty(resp, "end_of_message") === true;
const dialogComplete = chunk.complete === true || booleanProperty(resp, "end_of_dialog") === true;
if (chunkType === "thought" || chunkType === "observation") {
if (content.length > 0) process.stderr.write(content);
} else if (chunkType === "answer" || chunkType === "final-answer") {
if (content.length > 0) process.stdout.write(content);
if (messageComplete || dialogComplete) process.stdout.write("\n");
}
return dialogComplete;
},
{ timeoutMs: 120_000, retries: 2 },
);
if (streamError !== undefined) {
return yield* streamError;
}
}),
),
).pipe(Command.withDescription("Ask the TrustGraph agent a question"));

View file

@ -2,7 +2,12 @@
* Shared CLI utilities.
*/
import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client";
import {
createTrustGraphSocket,
makeTrustGraphGatewayClientScoped,
type BaseApi,
type TrustGraphGatewayClient,
} from "@trustgraph/client";
import { Duration, Effect } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
@ -109,6 +114,23 @@ export function createSocketEffect(opts: CliOpts): Effect.Effect<BaseApi, CliCom
);
}
function gatewayUrlWithToken(opts: CliOpts): string {
if (opts.token === undefined || opts.token.length === 0) return opts.gateway;
const separator = opts.gateway.includes("?") ? "&" : "?";
return `${opts.gateway}${separator}token=${encodeURIComponent(opts.token)}`;
}
export const withGatewayClient = Effect.fn("withGatewayClient")(function* <A, E, R>(
use: (client: TrustGraphGatewayClient, opts: CliOpts) => Effect.Effect<A, E, R>,
) {
const opts = yield* getOpts;
return yield* Effect.scoped(
makeTrustGraphGatewayClientScoped({ url: gatewayUrlWithToken(opts) }).pipe(
Effect.flatMap((client) => use(client, opts)),
),
);
});
export const withSocket = Effect.fn("withSocket")(function* <A, E, R>(
use: (socket: BaseApi, opts: CliOpts) => Effect.Effect<A, E, R>,
) {

View file

@ -5,6 +5,16 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./rpc/contract": {
"types": "./dist/rpc/contract.d.ts",
"import": "./dist/rpc/contract.js"
}
},
"scripts": {
"build": "bunx --bun tsc",
"dev": "tsc --watch",

View file

@ -13,7 +13,7 @@ describe("FlowsApi", () => {
makeRequest: vi.fn(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
flowsApi = new FlowsApi(mockApi as any);
flowsApi = FlowsApi(mockApi as any);
});
describe("startFlow", () => {

View file

@ -23,7 +23,7 @@ describe("workbench API contracts", () => {
values: [{ type: "prompt", key: "welcome", value: "hello" }],
});
const result = await new ConfigApi(base).getValues("prompt");
const result = await ConfigApi(base).getValues("prompt");
expect(makeRequest).toHaveBeenCalledWith(
"config",
@ -45,7 +45,7 @@ describe("workbench API contracts", () => {
],
});
const result = await new ConfigApi(base).getTokenCosts();
const result = await ConfigApi(base).getTokenCosts();
expect(result).toEqual([
{ model: "gpt-test", input_price: 0.1, output_price: 0.2 },
@ -55,7 +55,7 @@ describe("workbench API contracts", () => {
it("writes and deletes config using Python-style key/value arrays", async () => {
const { base, makeRequest } = makeApi();
makeRequest.mockResolvedValue({});
const config = new ConfigApi(base);
const config = ConfigApi(base);
await config.putConfig([{ type: "tool", key: "search", value: "{}" }]);
await config.deleteConfig({ type: "tool", key: "search" });
@ -86,7 +86,7 @@ describe("workbench API contracts", () => {
const { base, makeRequest } = makeApi();
const document = { id: "doc-1", title: "Document" };
const processing = { id: "proc-1", "document-id": "doc-1" };
const librarian = new LibrarianApi(base);
const librarian = LibrarianApi(base);
makeRequest
.mockResolvedValueOnce({ "document-metadatas": [document] })
@ -101,7 +101,7 @@ describe("workbench API contracts", () => {
const document = { id: "doc-1", title: "Document" };
makeRequest.mockResolvedValue({ "document-metadata": document });
const result = await new LibrarianApi(base).getDocumentMetadata("doc-1");
const result = await LibrarianApi(base).getDocumentMetadata("doc-1");
expect(makeRequest).toHaveBeenCalledWith(
"librarian",
@ -120,7 +120,7 @@ describe("workbench API contracts", () => {
const { base, makeRequest } = makeApi();
makeRequest.mockResolvedValue({});
await new LibrarianApi(base).loadDocument(
await LibrarianApi(base).loadDocument(
"SGVsbG8=",
"text/plain",
"Hello",
@ -145,7 +145,7 @@ describe("workbench API contracts", () => {
describe("KnowledgeApi", () => {
it("lists and loads document embedding cores", async () => {
const { base, makeRequest } = makeApi();
const knowledge = new KnowledgeApi(base);
const knowledge = KnowledgeApi(base);
makeRequest
.mockResolvedValueOnce({ ids: ["de-core"] })
@ -178,7 +178,7 @@ describe("workbench API contracts", () => {
const { base, makeRequest } = makeApi();
makeRequest.mockResolvedValue({});
await new KnowledgeApi(base).unloadKgCore("kg-core", "default");
await KnowledgeApi(base).unloadKgCore("kg-core", "default");
expect(makeRequest).toHaveBeenCalledWith(
"knowledge",

View file

@ -8,6 +8,7 @@ export * from "./models/namespaces.js";
// Export socket client
export * from "./socket/trustgraph-socket.js";
export * from "./socket/effect-rpc-client.js";
export * from "./rpc/contract.js";
// Export WebSocket adapter (isomorphic helpers and types)

View file

@ -1,4 +1,5 @@
import { Schema as S } from "effect";
import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
import * as Rpc from "effect/unstable/rpc/Rpc";
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
@ -32,3 +33,14 @@ export class DispatchStream extends Rpc.make("DispatchStream", {
}) {}
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
export class GatewayWorkbenchHttpApi extends HttpApi.make("trustgraph-gateway-workbench")
.add(
HttpApiGroup.make("workbench", { topLevel: true }).add(
HttpApiEndpoint.post("dispatch", "/api/v1/workbench/dispatch", {
payload: DispatchPayload,
success: S.Unknown,
}),
),
)
{}

View file

@ -1,4 +1,4 @@
import { Cause, Context, Effect, Fiber, Layer, ManagedRuntime, Stream, SubscriptionRef } from "effect";
import { Cause, Context, Effect, Exit, Fiber, Layer, Ref, Scope, Stream, SubscriptionRef } from "effect";
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
import * as RpcClient from "effect/unstable/rpc/RpcClient";
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
@ -35,24 +35,44 @@ export interface DispatchOptions {
readonly retries?: number;
}
export interface TrustGraphGatewayClient {
readonly state: Effect.Effect<RpcConnectionState>;
readonly changes: Stream.Stream<RpcConnectionState>;
readonly subscribe: (
listener: (state: RpcConnectionState) => void,
) => Effect.Effect<Effect.Effect<void>>;
readonly dispatch: (
input: DispatchInput,
options?: DispatchOptions,
) => Effect.Effect<unknown, RpcClientError | DispatchError>;
readonly dispatchStream: (
input: DispatchInput,
options?: DispatchOptions,
) => Stream.Stream<DispatchStreamChunk, RpcClientError | DispatchError>;
readonly runDispatchStream: (
input: DispatchInput,
receiver: (chunk: DispatchStreamChunk) => boolean,
options?: DispatchOptions,
) => Effect.Effect<DispatchStreamChunk | undefined, RpcClientError | DispatchError>;
readonly close: Effect.Effect<void>;
}
export class TrustGraphGatewayClientService extends Context.Service<
TrustGraphGatewayClientService,
TrustGraphGatewayClient
>()("@trustgraph/client/socket/effect-rpc-client/TrustGraphGatewayClientService") {}
export interface TrustGraphGatewayClientOptions {
readonly url: string;
readonly onConnect?: () => void;
readonly onDisconnect?: () => void;
readonly stateRef?: SubscriptionRef.SubscriptionRef<RpcConnectionState>;
readonly closedRef?: Ref.Ref<boolean>;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
const DEFAULT_REQUEST_ATTEMPTS = 3;
type NewableFactory<Args extends readonly unknown[], A extends object> = {
new (...args: Args): A;
(...args: Args): A;
readonly prototype: A;
};
function newableFactory<Args extends readonly unknown[], A extends object>(
factory: (...args: Args) => A,
): NewableFactory<Args, A> {
function Constructor(...args: Args): A {
return factory(...args);
}
return Constructor as unknown as NewableFactory<Args, A>;
}
export interface EffectRpcClient {
readonly subscribe: (listener: (state: RpcConnectionState) => void) => () => void;
readonly dispatch: (
@ -67,131 +87,207 @@ export interface EffectRpcClient {
readonly close: () => Promise<void>;
}
const makeClientLayer = (
options: TrustGraphGatewayClientOptions,
stateRef: SubscriptionRef.SubscriptionRef<RpcConnectionState>,
closedRef: Ref.Ref<boolean>,
): Layer.Layer<TrustGraphRpcClientService> => {
const setState = (nextState: RpcConnectionState) =>
SubscriptionRef.set(stateRef, nextState);
const socketLayer = Layer.effect(
Socket.Socket,
Socket.makeWebSocket(options.url, {
closeCodeIsError: (code) => code !== 1000,
openTimeout: "10 seconds",
}),
).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
const hooksLayer = Layer.succeed(
RpcClient.ConnectionHooks,
RpcClient.ConnectionHooks.of({
onConnect: Effect.gen(function* () {
yield* setState({ status: "connected" });
options.onConnect?.();
}),
onDisconnect: Effect.gen(function* () {
const closed = yield* Ref.get(closedRef);
if (!closed) {
yield* setState({
status: "connecting",
lastError: "Disconnected from gateway",
});
}
options.onDisconnect?.();
}),
}),
);
const protocolLayer = RpcClient.layerProtocolSocket({
retryTransientErrors: true,
}).pipe(
Layer.provide(socketLayer),
Layer.provide(RpcSerialization.layerNdjson),
Layer.provide(hooksLayer),
);
return Layer.effect(
TrustGraphRpcClientService,
RpcClient.make(TrustGraphRpcs),
).pipe(Layer.provide(protocolLayer));
};
const makeSubscribeEffect = Effect.fn("makeSubscribeEffect")(function* (
stateRef: SubscriptionRef.SubscriptionRef<RpcConnectionState>,
scope: Scope.Scope,
listener: (state: RpcConnectionState) => void,
) {
let latest = SubscriptionRef.getUnsafe(stateRef);
listener(latest);
let replaySeen = false;
const fiber = yield* Effect.forkIn(SubscriptionRef.changes(stateRef).pipe(
Stream.runForEach((nextState) =>
Effect.sync(() => {
if (!replaySeen) {
replaySeen = true;
if (nextState === latest) return;
}
latest = nextState;
listener(nextState);
})
),
), scope);
return yield* Effect.succeed(Fiber.interrupt(fiber).pipe(Effect.asVoid));
});
export const makeTrustGraphGatewayClientScoped: (
options: TrustGraphGatewayClientOptions,
) => Effect.Effect<TrustGraphGatewayClient, never, Scope.Scope> = Effect.fn("makeTrustGraphGatewayClientScoped")(function* (
options,
) {
const stateRef = options.stateRef ?? (yield* SubscriptionRef.make<RpcConnectionState>({ status: "connecting" }));
const closedRef = options.closedRef ?? (yield* Ref.make(false));
const scope = yield* Scope.Scope;
const context = yield* Layer.buildWithScope(makeClientLayer(options, stateRef, closedRef), scope).pipe(
Effect.tapCause((cause) =>
SubscriptionRef.set(stateRef, {
status: "failed",
lastError: Cause.pretty(cause),
})
),
);
const client = Context.get(context, TrustGraphRpcClientService);
const close = Effect.gen(function* () {
const wasClosed = yield* Ref.getAndSet(closedRef, true);
if (!wasClosed) {
yield* SubscriptionRef.set(stateRef, { status: "closed" });
}
});
yield* Effect.addFinalizer(() => close);
return {
state: SubscriptionRef.get(stateRef),
changes: SubscriptionRef.changes(stateRef),
subscribe: (listener) => makeSubscribeEffect(stateRef, scope, listener),
dispatch: (input, options = {}) =>
withDispatchRequestPolicy(client.Dispatch(DispatchPayload.make(input)), options),
dispatchStream: (input, options = {}) =>
Stream.unwrap(
withDispatchRequestPolicy(
Effect.succeed(client.DispatchStream(DispatchPayload.make(input))),
options,
),
),
runDispatchStream: (input, receiver, options = {}) => {
let last: DispatchStreamChunk | undefined;
return withDispatchRequestPolicy(
client.DispatchStream(DispatchPayload.make(input)).pipe(
Stream.runForEachWhile((chunk) =>
Effect.suspend(() => {
last = chunk;
return Effect.succeed(!receiver(chunk));
}),
),
Effect.andThen(() => Effect.succeed(last)),
),
options,
);
},
close,
} satisfies TrustGraphGatewayClient;
});
export const makeTrustGraphGatewayClientLayer = (
options: TrustGraphGatewayClientOptions,
): Layer.Layer<TrustGraphGatewayClientService> =>
Layer.effect(
TrustGraphGatewayClientService,
makeTrustGraphGatewayClientScoped(options).pipe(
Effect.map(TrustGraphGatewayClientService.of),
),
);
export function makeEffectRpcClient(
url: string,
onConnect?: () => void,
onDisconnect?: () => void,
): EffectRpcClient {
const stateRef = Effect.runSync(SubscriptionRef.make<RpcConnectionState>({ status: "connecting" }));
let closed = false;
const setState = (nextState: RpcConnectionState) =>
SubscriptionRef.set(stateRef, nextState);
const makeClientLayer = (): Layer.Layer<TrustGraphRpcClientService> => {
const socketLayer = Layer.effect(
Socket.Socket,
Socket.makeWebSocket(url, {
closeCodeIsError: (code) => code !== 1000,
openTimeout: "10 seconds",
}),
).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
const hooksLayer = Layer.succeed(
RpcClient.ConnectionHooks,
RpcClient.ConnectionHooks.of({
onConnect: Effect.gen(function* () {
yield* setState({ status: "connected" });
onConnect?.();
}),
onDisconnect: Effect.gen(function* () {
if (!closed) {
yield* setState({
status: "connecting",
lastError: "Disconnected from gateway",
});
}
onDisconnect?.();
}),
}),
);
const protocolLayer = RpcClient.layerProtocolSocket({
retryTransientErrors: true,
}).pipe(
Layer.provide(socketLayer),
Layer.provide(RpcSerialization.layerNdjson),
Layer.provide(hooksLayer),
);
const clientLayer = Layer.effect(
TrustGraphRpcClientService,
RpcClient.make(TrustGraphRpcs),
).pipe(Layer.provide(protocolLayer));
return clientLayer;
const closedRef = Effect.runSync(Ref.make(false));
const scope = Effect.runSync(Scope.make());
const options: TrustGraphGatewayClientOptions = {
url,
stateRef,
closedRef,
...(onConnect === undefined ? {} : { onConnect }),
...(onDisconnect === undefined ? {} : { onDisconnect }),
};
const runtime = ManagedRuntime.make(makeClientLayer());
const clientPromise = runtime.runPromise(
TrustGraphRpcClientService.pipe(
Effect.tapCause((cause) =>
setState({
status: "failed",
lastError: Cause.pretty(cause),
})
),
),
const clientPromise = Effect.runPromise(
makeTrustGraphGatewayClientScoped(options).pipe(Scope.provide(scope)),
);
return {
subscribe: (listener) => {
let latest = SubscriptionRef.getUnsafe(stateRef);
listener(latest);
let replaySeen = false;
const fiber = Effect.runFork(
SubscriptionRef.changes(stateRef).pipe(
Stream.runForEach((nextState) =>
Effect.sync(() => {
if (!replaySeen) {
replaySeen = true;
if (nextState === latest) return;
}
latest = nextState;
listener(nextState);
})
),
),
let unsubscribe: Effect.Effect<void> | undefined;
let cancelled = false;
listener(SubscriptionRef.getUnsafe(stateRef));
void clientPromise.then((client) =>
Effect.runPromise(client.subscribe(listener)).then((release) => {
if (cancelled) {
return Effect.runPromise(release);
}
unsubscribe = release;
})
);
return () => {
Effect.runFork(Fiber.interrupt(fiber));
cancelled = true;
if (unsubscribe !== undefined) {
Effect.runFork(unsubscribe);
}
};
},
dispatch: (input, options = {}) =>
clientPromise.then((client) =>
runtime.runPromise(
withDispatchRequestPolicy(client.Dispatch(DispatchPayload.make(input)), options),
)
Effect.runPromise(client.dispatch(input, options))
),
dispatchStream: (input, receiver, options = {}) => {
let last: DispatchStreamChunk | undefined;
return clientPromise.then((client) =>
runtime.runPromise(
withDispatchRequestPolicy(
client.DispatchStream(DispatchPayload.make(input)).pipe(
Stream.runForEachWhile((chunk) =>
Effect.suspend(() => {
last = chunk;
return Effect.succeed(!receiver(chunk));
}),
),
),
options,
dispatchStream: (input, receiver, options = {}) =>
clientPromise.then((client) =>
Effect.runPromise(client.runDispatchStream(input, receiver, options))
),
close: () =>
clientPromise.then((client) =>
Effect.runPromise(
client.close.pipe(
Effect.andThen(Scope.close(scope, Exit.void)),
),
)
).then(() => last);
},
close: () => {
if (closed) return Promise.resolve();
closed = true;
Effect.runSync(setState({ status: "closed" }));
return runtime.dispose();
},
),
};
}
export const EffectRpcClient = newableFactory(makeEffectRpcClient);
export function withDispatchRequestPolicy<A, E, R>(
effect: Effect.Effect<A, E, R>,
options: DispatchOptions,

View file

@ -1,7 +1,7 @@
// Import core types and classes for the TrustGraph API
import type { Term, Triple } from "../models/Triple.js";
import {
EffectRpcClient,
type EffectRpcClient,
type DispatchInput,
type DispatchOptions,
type RpcConnectionState,
@ -445,21 +445,6 @@ function makeid(length: number) {
);
}
type NewableFactory<Args extends readonly unknown[], A extends object> = {
new (...args: Args): A;
(...args: Args): A;
readonly prototype: A;
};
function newableFactory<Args extends readonly unknown[], A extends object>(
factory: (...args: Args) => A,
): NewableFactory<Args, A> {
function Constructor(...args: Args): A {
return factory(...args);
}
return Constructor as unknown as NewableFactory<Args, A>;
}
/**
* BaseApi - Core WebSocket client for TrustGraph API
* Manages connection lifecycle, message routing, and provides base request
@ -622,27 +607,27 @@ export function makeBaseApi(
// Factory methods for creating specialized API instances
librarian() {
return new LibrarianApi(api);
return makeLibrarianApi(api);
},
flows() {
return new FlowsApi(api);
return makeFlowsApi(api);
},
flow(id: string) {
return new FlowApi(api, id);
return makeFlowApi(api, id);
},
knowledge() {
return new KnowledgeApi(api);
return makeKnowledgeApi(api);
},
config() {
return new ConfigApi(api);
return makeConfigApi(api);
},
collectionManagement() {
return new CollectionManagementApi(api);
return makeCollectionManagementApi(api);
},
};
@ -739,7 +724,7 @@ export function makeBaseApi(
}
export type BaseApi = ReturnType<typeof makeBaseApi>;
export const BaseApi = newableFactory(makeBaseApi);
export const BaseApi = makeBaseApi;
export function makeBaseApiWithRpc(
user: string,
@ -1153,7 +1138,7 @@ export function makeLibrarianApi(api: BaseApi) {
}
export type LibrarianApi = ReturnType<typeof makeLibrarianApi>;
export const LibrarianApi = newableFactory(makeLibrarianApi);
export const LibrarianApi = makeLibrarianApi;
/**
* FlowsApi - Manages processing flows and configuration
@ -1418,7 +1403,7 @@ export function makeFlowsApi(api: BaseApi) {
}
export type FlowsApi = ReturnType<typeof makeFlowsApi>;
export const FlowsApi = newableFactory(makeFlowsApi);
export const FlowsApi = makeFlowsApi;
/**
* FlowApi - Interface for interacting with a specific flow instance
@ -2205,7 +2190,7 @@ export function makeFlowApi(api: BaseApi, flowId: string) {
}
export type FlowApi = ReturnType<typeof makeFlowApi>;
export const FlowApi = newableFactory(makeFlowApi);
export const FlowApi = makeFlowApi;
/**
* ConfigApi - Dedicated configuration management interface
@ -2401,7 +2386,7 @@ export function makeConfigApi(api: BaseApi) {
}
export type ConfigApi = ReturnType<typeof makeConfigApi>;
export const ConfigApi = newableFactory(makeConfigApi);
export const ConfigApi = makeConfigApi;
/**
* KnowledgeApi - Manages knowledge graph cores and data
@ -2568,7 +2553,7 @@ export function makeKnowledgeApi(api: BaseApi) {
}
export type KnowledgeApi = ReturnType<typeof makeKnowledgeApi>;
export const KnowledgeApi = newableFactory(makeKnowledgeApi);
export const KnowledgeApi = makeKnowledgeApi;
/**
* CollectionManagementApi - Manages collections for organizing documents
@ -2677,7 +2662,7 @@ export function makeCollectionManagementApi(api: BaseApi) {
}
export type CollectionManagementApi = ReturnType<typeof makeCollectionManagementApi>;
export const CollectionManagementApi = newableFactory(makeCollectionManagementApi);
export const CollectionManagementApi = makeCollectionManagementApi;
/**
* Factory function to create a new TrustGraph WebSocket connection
@ -2690,4 +2675,4 @@ export const createTrustGraphSocket = (
user: string,
token?: string,
socketUrl?: string,
): BaseApi => new BaseApi(user, token, socketUrl);
): BaseApi => makeBaseApi(user, token, socketUrl);

View file

@ -12,21 +12,13 @@
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78",
"@mistralai/mistralai": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"@qdrant/js-client-rest": "^1.13.0",
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"effect": "4.0.0-beta.78",
"falkordb": "^5.0.0",
"ollama": "^0.6.3",

View file

@ -12,6 +12,7 @@ import { Clock, Effect, Exit, HashMap, HashSet, Option, Random, Scope, Synchroni
import {
loadMessagingRuntimeConfig,
makeNatsBackend,
makeNatsBackendScoped,
makePubSubService,
makeRequestResponseFactoryService,
messagingDeliveryError,
@ -401,3 +402,17 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
publishToTopic,
};
}
export const makeDispatcherManagerScoped = Effect.fn("makeDispatcherManagerScoped")(function* (
config: GatewayConfig,
) {
if (config.pubsub !== undefined) {
return makeDispatcherManager(config);
}
const pubsub = yield* makeNatsBackendScoped(config.natsUrl ?? "nats://localhost:4222");
return makeDispatcherManager({
...config,
pubsub,
});
});

View file

@ -1,34 +1 @@
import { Schema as S } from "effect";
import * as Rpc from "effect/unstable/rpc/Rpc";
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
export class DispatchPayload extends S.Class<DispatchPayload>("DispatchPayload")({
scope: S.Literals(["global", "flow"]),
service: S.String,
flow: S.optionalKey(S.String),
request: S.Record(S.String, S.Unknown),
}) {}
export class DispatchStreamChunk extends S.Class<DispatchStreamChunk>("DispatchStreamChunk")({
response: S.Unknown,
complete: S.Boolean,
}) {}
export class DispatchError extends S.TaggedErrorClass<DispatchError>()("DispatchError", {
message: S.String,
}) {}
export class Dispatch extends Rpc.make("Dispatch", {
payload: DispatchPayload,
success: S.Unknown,
error: DispatchError,
}) {}
export class DispatchStream extends Rpc.make("DispatchStream", {
payload: DispatchPayload,
success: DispatchStreamChunk,
error: DispatchError,
stream: true,
}) {}
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
export * from "@trustgraph/client/rpc/contract";

View file

@ -10,6 +10,7 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Clock, Config, Effect, Exit, Layer, Random, Scope } from "effect";
import * as O from "effect/Option";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import {
formatPrometheusMetrics,
@ -19,7 +20,8 @@ import {
toTgError,
type PubSubBackend,
} from "@trustgraph/base";
import { makeDispatcherManager, type DispatcherManager } from "./dispatch/manager.js";
import { GatewayWorkbenchHttpApi } from "@trustgraph/client/rpc/contract";
import { makeDispatcherManagerScoped, type DispatcherManager } from "./dispatch/manager.js";
import { makeGatewayRpcServer, type GatewayRpcServer } from "./rpc-server.js";
export interface GatewayConfig {
@ -134,6 +136,22 @@ const workbenchDispatch = (
),
);
const gatewayWorkbenchHttpApiRoutes = (
config: GatewayConfig,
dispatcher: DispatcherManager,
) => {
const handlers = HttpApiBuilder.group(
GatewayWorkbenchHttpApi,
"workbench",
(handlers) =>
handlers.handleRaw("dispatch", () => workbenchDispatch(config, dispatcher)),
);
return HttpApiBuilder.layer(GatewayWorkbenchHttpApi).pipe(
Layer.provide(handlers),
);
};
const globalDispatch = (
config: GatewayConfig,
dispatcher: DispatcherManager,
@ -240,7 +258,7 @@ export const makeGatewayRoutes = (
rpcScope: Scope.Scope,
) =>
Layer.mergeAll(
HttpRouter.add("POST", "/api/v1/workbench/dispatch", workbenchDispatch(config, dispatcher)),
gatewayWorkbenchHttpApiRoutes(config, dispatcher),
HttpRouter.add("POST", "/api/v1/:kind", globalDispatch(config, dispatcher)),
HttpRouter.add("POST", "/api/v1/flow/:flow/service/:kind", flowDispatch(config, dispatcher)),
HttpRouter.add("POST", "/api/v1/flow/:flow/load", flowLoad(config, dispatcher)),
@ -251,7 +269,7 @@ export const makeGatewayRoutes = (
export function createGateway(config: GatewayConfig) {
return Layer.effectDiscard(
Effect.scoped(Effect.gen(function* () {
const dispatcher = makeDispatcherManager(config);
const dispatcher = yield* makeDispatcherManagerScoped(config);
yield* dispatcher.start.pipe(
Effect.mapError((cause) => messagingLifecycleError("gateway", "dispatcher-start", cause)),
);

View file

@ -9,6 +9,7 @@
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"],
"references": [
{ "path": "../base" }
{ "path": "../base" },
{ "path": "../client" }
]
}

View file

@ -13,18 +13,9 @@
"dependencies": {
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78"
"@effect/platform-node": "4.0.0-beta.78",
"effect": "4.0.0-beta.78"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.78",

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";
import type { BaseApi } from "@trustgraph/client";
import { Effect, Layer } from "effect";
import { DispatchStreamChunk, type BaseApi, type TrustGraphGatewayClient } from "@trustgraph/client";
import { Effect, Layer, Stream } from "effect";
import * as S from "effect/Schema";
import { McpServer } from "effect/unstable/ai";
import * as McpSchema from "effect/unstable/ai/McpSchema";
@ -13,6 +13,7 @@ import {
TrustGraphMcpConfig,
TrustGraphMcpToolkit,
TrustGraphMcpToolkitLive,
TrustGraphGateway,
TrustGraphSocket,
} from "../server-effect.js";
@ -124,6 +125,29 @@ const makeFakeSocket = (
return { socket, calls };
};
const makeFakeGateway = (): TrustGraphGatewayClient => ({
state: Effect.succeed({ status: "connected" }),
changes: Stream.empty,
subscribe: () => Effect.succeed(Effect.void),
dispatch: () => Effect.succeed({}),
dispatchStream: () => Stream.empty,
runDispatchStream: (_input, receiver) =>
Effect.sync(() => {
const chunk = DispatchStreamChunk.make({
response: {
chunk_type: "answer",
content: "agent answer",
end_of_message: true,
end_of_dialog: true,
},
complete: true,
});
receiver(chunk);
return chunk;
}),
close: Effect.void,
});
const testConfig = TrustGraphMcpConfig.of({
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
user: "mcp-test",
@ -147,8 +171,10 @@ const makeNativeTestClientEffect = Effect.fn("makeNativeTestClient")(function*(
textCompletion: options.textCompletion,
graphRag: options.graphRag,
});
const gateway = makeFakeGateway();
const serverLayer = McpServer.toolkit(TrustGraphMcpToolkit).pipe(
Layer.provide(TrustGraphMcpToolkitLive),
Layer.provide(Layer.succeed(TrustGraphGateway, TrustGraphGateway.of(gateway))),
Layer.provide(Layer.succeed(TrustGraphSocket, TrustGraphSocket.of(socket))),
Layer.provide(Layer.succeed(TrustGraphMcpConfig, testConfig)),
Layer.provide(McpServer.layerHttp({

View file

@ -1,6 +1,12 @@
import {BunHttpServer, BunRuntime} from "@effect/platform-bun";
import {NodeRuntime, NodeStdio} from "@effect/platform-node";
import {createTrustGraphSocket, type BaseApi, type Term as ClientTerm} from "@trustgraph/client";
import {
createTrustGraphSocket,
makeTrustGraphGatewayClientScoped,
type BaseApi,
type Term as ClientTerm,
type TrustGraphGatewayClient,
} from "@trustgraph/client";
import {Config, Context, Effect, Layer} from "effect";
import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
@ -1223,6 +1229,12 @@ export interface TrustGraphMcpConfigShape {
const readNonEmpty = (value: string | undefined): string | undefined =>
value !== undefined && value.length > 0 ? value : undefined
const gatewayUrlWithToken = (config: TrustGraphMcpConfigShape): string => {
if (config.token === undefined || config.token.length === 0) return config.gatewayUrl
const separator = config.gatewayUrl.includes("?") ? "&" : "?"
return `${config.gatewayUrl}${separator}token=${encodeURIComponent(config.token)}`
}
const parsePort = (raw: string | undefined): number => {
if (raw === undefined) {
return 3000
@ -1283,6 +1295,19 @@ export class TrustGraphSocket extends Context.Service<TrustGraphSocket, BaseApi>
)
}
export class TrustGraphGateway extends Context.Service<TrustGraphGateway, TrustGraphGatewayClient>()(
"@trustgraph/mcp/server-effect/TrustGraphGateway",
) {
static readonly layer = Layer.effect(
TrustGraphGateway,
Effect.gen(function*() {
const config = yield* TrustGraphMcpConfig
const client = yield* makeTrustGraphGatewayClientScoped({url: gatewayUrlWithToken(config)})
return TrustGraphGateway.of(client)
}),
)
}
const toErrorMessage = (cause: unknown): string => {
if (Predicate.isError(cause) && cause.message.length > 0) {
return cause.message
@ -1296,6 +1321,25 @@ const toErrorMessage = (cause: unknown): string => {
return "TrustGraph MCP tool failed"
}
const asRecord = (value: unknown): Record<string, unknown> =>
Predicate.isObject(value) && !Array.isArray(value) ? value as Record<string, unknown> : {}
const stringProperty = (source: unknown, key: string): string | undefined => {
const value = asRecord(source)[key]
return Predicate.isString(value) ? value : undefined
}
const booleanProperty = (source: unknown, key: string): boolean | undefined => {
const value = asRecord(source)[key]
return typeof value === "boolean" ? value : undefined
}
const responseErrorMessage = (source: unknown): string | undefined => {
const error = asRecord(source).error
if (Predicate.isString(error)) return error
return stringProperty(error, "message")
}
const decodeJson = S.decodeUnknownEffect(S.Json)
const decodeJsonArray = S.decodeUnknownEffect(S.Array(S.Json))
@ -1316,10 +1360,59 @@ const decodeJsonArrayOrFail = <E>(
const asIriTerm = (value: string | undefined): ClientTerm | undefined =>
value !== undefined && value.length > 0 ? {t: "i", i: value} : undefined
const runAgentTool = Effect.fn("TrustGraphMcpToolkit.agent")(function*(
gateway: TrustGraphGatewayClient,
config: TrustGraphMcpConfigShape,
question: string,
) {
let fullAnswer = ""
let streamError: AgentError | undefined
yield* gateway.runDispatchStream(
{
scope: "flow",
flow: config.flowId,
service: "agent",
request: {
question,
user: config.user,
collection: "default",
streaming: true,
},
},
(chunk) => {
const resp = asRecord(chunk.response)
const chunkType = stringProperty(resp, "chunk_type")
const error = chunkType === "error"
? responseErrorMessage(resp) ?? "Unknown agent error"
: responseErrorMessage(resp)
if (error !== undefined) {
streamError = AgentError.make({message: error})
return true
}
if (chunkType === "answer" || chunkType === "final-answer") {
fullAnswer += stringProperty(resp, "content") ?? ""
}
return chunk.complete === true || booleanProperty(resp, "end_of_dialog") === true
},
{timeoutMs: 120_000, retries: 2},
).pipe(
Effect.mapError((cause) => AgentError.make({message: toErrorMessage(cause)})),
)
if (streamError !== undefined) {
return yield* streamError
}
return AgentSuccess.make({text: fullAnswer})
})
export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
Effect.gen(function*() {
const config = yield* TrustGraphMcpConfig
const socket = yield* TrustGraphSocket
const gateway = yield* TrustGraphGateway
return TrustGraphMcpToolkit.of({
text_completion: ({system, prompt}) =>
@ -1354,22 +1447,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
Effect.map((text) => DocumentRagSuccess.make({text})),
),
agent: ({question}) =>
Effect.callback<AgentSuccess, AgentError>((resume) => {
let fullAnswer = ""
socket.flow(config.flowId).agent(
question,
() => {},
() => {},
(chunk, complete) => {
fullAnswer += chunk
if (complete) {
resume(Effect.succeed(AgentSuccess.make({text: fullAnswer})))
}
},
(cause) => resume(Effect.fail(AgentError.make({message: toErrorMessage(cause)}))),
)
}),
agent: ({question}) => runAgentTool(gateway, config, question),
embeddings: ({text}) =>
Effect.tryPromise({
@ -1694,6 +1772,7 @@ const makeTrustGraphMcpHttpLayerFromConfig = (
version: config.version,
path: config.mcpPath,
})),
Layer.provide(TrustGraphGateway.layer),
Layer.provide(TrustGraphSocket.layer),
Layer.provide(Layer.succeed(TrustGraphMcpConfig, TrustGraphMcpConfig.of(config))),
)
@ -1713,6 +1792,7 @@ const makeTrustGraphMcpStdioLayerFromConfig = (
version: config.version,
})),
Layer.provide(NodeStdio.layer),
Layer.provide(TrustGraphGateway.layer),
Layer.provide(TrustGraphSocket.layer),
Layer.provide(Layer.succeed(TrustGraphMcpConfig, TrustGraphMcpConfig.of(config))),
)

View file

@ -10,21 +10,11 @@
"qa:browser": "playwright test"
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78",
"@tanstack/react-query": "^5.75.0",
"@trustgraph/client": "workspace:*",
"clsx": "^2.1.0",
"effect": "4.0.0-beta.78",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -32,8 +22,7 @@
"react-force-graph-2d": "^1.29.1",
"react-markdown": "^10.1.0",
"react-router": "^7.6.0",
"tailwind-merge": "^3.3.0",
"zustand": "^5.0.0"
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.78",

File diff suppressed because it is too large Load diff

View file

@ -20,12 +20,12 @@ export function NotificationToasts() {
if (notifications.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" aria-live="polite">
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2" aria-live="polite">
{notifications.map((n) => (
<div
key={n.id}
className={cn(
"flex items-start gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg",
"pointer-events-none flex items-start gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg",
typeStyles[n.type],
)}
>
@ -37,7 +37,7 @@ export function NotificationToasts() {
</div>
<button
onClick={() => removeNotification(n.id)}
className="shrink-0 opacity-60 hover:opacity-100"
className="pointer-events-auto shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss notification"
>
<X className="h-3.5 w-3.5" />

View file

@ -266,8 +266,10 @@ export default function SettingsPage() {
</button>
<button
onClick={() => {
if (collectionForm.id.trim().length === 0) return;
const collectionId = collectionForm.id.trim();
if (collectionId.length === 0) return;
createCollection(collectionForm);
setField({ key: "collection", value: collectionId });
setCollectionForm({ id: "", name: "", description: "", tags: "", submitting: false });
setCreateOpen(false);
}}

View file

@ -8,6 +8,7 @@ import {
type Settings,
type WorkbenchApiFactory,
} from "@/atoms/workbench";
import type { BaseApi } from "@trustgraph/client";
import { makeMockBaseApi, qaSettingsFromFixture, type MockWorkbenchFixture } from "@/qa/mock-api";
export interface WorkbenchQaWindowConfig {
@ -19,6 +20,7 @@ export interface WorkbenchQaWindowConfig {
declare global {
interface Window {
__TRUSTGRAPH_WORKBENCH_QA__?: WorkbenchQaWindowConfig;
__TRUSTGRAPH_WORKBENCH_QA_API__?: BaseApi;
}
}
@ -43,6 +45,7 @@ export function getWorkbenchQaInitialValues(): Iterable<readonly [Atom.Atom<unkn
const apiFactory: WorkbenchApiFactory = {
create: () => api,
};
window.__TRUSTGRAPH_WORKBENCH_QA_API__ = api;
return [
[apiFactoryAtom as Atom.Atom<unknown>, apiFactory],
[settingsAtom as Atom.Atom<unknown>, qaSettings(fixture)],

View file

@ -25,19 +25,21 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api/v1/rpc": {
target: "ws://localhost:8088/",
ws: true,
server: isWorkbenchQa
? {}
: {
proxy: {
"/api/v1/rpc": {
target: "ws://localhost:8088/",
ws: true,
},
"/api/v1": {
target: "http://localhost:8088/",
},
"/otel": {
target: "http://localhost:4328/",
rewrite: (p) => p.replace(/^\/otel/, ""),
},
},
},
"/api/v1": {
target: "http://localhost:8088/",
},
"/otel": {
target: "http://localhost:4328/",
rewrite: (p) => p.replace(/^\/otel/, ""),
},
},
},
});

View file

@ -28,6 +28,8 @@ const effectClassPatterns = [
/\bContext\.Service\b/,
/\bRpc\.make\b/,
/\bHttpApi\.make\b/,
/\bAtomHttpApi\.Service\b/,
/\bAtomRpc\.Service\b/,
/\bEffect\.Service\b/,
];