mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
fix(ts): close effect native review blockers
This commit is contained in:
parent
b6759e75df
commit
a26463afc1
13 changed files with 438 additions and 528 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
import { makeNatsBackend } from "../backend/nats.js";
|
import { makeNatsBackend, makeNatsBackendScoped } from "../backend/nats.js";
|
||||||
|
|
||||||
const natsMock = vi.hoisted(() => {
|
const natsMock = vi.hoisted(() => {
|
||||||
const S = require("effect/Schema");
|
const S = require("effect/Schema");
|
||||||
|
|
@ -252,4 +252,17 @@ describe("NATS backend", () => {
|
||||||
operation: "negative-acknowledge:tg.test.topic",
|
operation: "negative-acknowledge:tg.test.topic",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drains the connection when a scoped NATS backend closes", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const backend = yield* makeNatsBackendScoped("nats://test");
|
||||||
|
yield* backend.createProducer<string>({ topic: "tg.test.topic" });
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(natsMock.drain).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export type {
|
||||||
InitialPosition,
|
InitialPosition,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export { makeNatsBackend } from "./nats.js";
|
export { makeNatsBackend, makeNatsBackendScoped } from "./nats.js";
|
||||||
export {
|
export {
|
||||||
PubSub,
|
PubSub,
|
||||||
NatsPubSubLive,
|
NatsPubSubLive,
|
||||||
|
|
|
||||||
|
|
@ -384,3 +384,17 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const makeNatsBackendScoped = (url = "nats://localhost:4222") =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() => makeNatsBackend(url)),
|
||||||
|
(backend) =>
|
||||||
|
backend.close.pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.logError("[NatsBackend] Failed to close scoped backend", {
|
||||||
|
error: error.message,
|
||||||
|
operation: error.operation,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type {
|
||||||
CreateProducerOptions,
|
CreateProducerOptions,
|
||||||
PubSubBackend,
|
PubSubBackend,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { makeNatsBackend } from "./nats.js";
|
import { makeNatsBackendScoped } from "./nats.js";
|
||||||
import type { PubSubError } from "../errors.js";
|
import type { PubSubError } from "../errors.js";
|
||||||
|
|
||||||
export interface PubSubService {
|
export interface PubSubService {
|
||||||
|
|
@ -49,9 +49,9 @@ export function makePubSubService(backend: PubSubBackend): PubSubService {
|
||||||
|
|
||||||
export function pubSubLayer(backend: PubSubBackend): Layer.Layer<PubSub> {
|
export function pubSubLayer(backend: PubSubBackend): Layer.Layer<PubSub> {
|
||||||
return Layer.effect(PubSub)(
|
return Layer.effect(PubSub)(
|
||||||
Effect.gen(function* () {
|
Effect.acquireRelease(
|
||||||
const service = makePubSubService(backend);
|
Effect.sync(() => makePubSubService(backend)),
|
||||||
yield* Effect.addFinalizer(() =>
|
(service) =>
|
||||||
service.close.pipe(
|
service.close.pipe(
|
||||||
Effect.catch((error) =>
|
Effect.catch((error) =>
|
||||||
Effect.logError("[PubSub] Failed to close backend", {
|
Effect.logError("[PubSub] Failed to close backend", {
|
||||||
|
|
@ -59,32 +59,28 @@ export function pubSubLayer(backend: PubSubBackend): Layer.Layer<PubSub> {
|
||||||
operation: error.operation,
|
operation: error.operation,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
);
|
).pipe(
|
||||||
return PubSub.of(service);
|
Effect.map(PubSub.of),
|
||||||
}),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeNatsPubSubLayer(url = "nats://localhost:4222"): Layer.Layer<PubSub> {
|
export function makeNatsPubSubLayer(url = "nats://localhost:4222"): Layer.Layer<PubSub> {
|
||||||
return pubSubLayer(makeNatsBackend(url));
|
return Layer.effect(PubSub)(
|
||||||
|
makeNatsBackendScoped(url).pipe(
|
||||||
|
Effect.map(makePubSubService),
|
||||||
|
Effect.map(PubSub.of),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NatsPubSubLive = Layer.effect(PubSub)(
|
export const NatsPubSubLive = Layer.effect(PubSub)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const natsUrl = O.getOrUndefined(yield* Config.string("NATS_URL").pipe(Config.option));
|
const natsUrl = O.getOrUndefined(yield* Config.string("NATS_URL").pipe(Config.option));
|
||||||
const pulsarHost = O.getOrUndefined(yield* Config.string("PULSAR_HOST").pipe(Config.option));
|
const pulsarHost = O.getOrUndefined(yield* Config.string("PULSAR_HOST").pipe(Config.option));
|
||||||
const service = makePubSubService(makeNatsBackend(natsUrl ?? pulsarHost ?? "nats://localhost:4222"));
|
const backend = yield* makeNatsBackendScoped(natsUrl ?? pulsarHost ?? "nats://localhost:4222");
|
||||||
yield* Effect.addFinalizer(() =>
|
const service = makePubSubService(backend);
|
||||||
service.close.pipe(
|
|
||||||
Effect.catch((error) =>
|
|
||||||
Effect.logError("[PubSub] Failed to close NATS backend", {
|
|
||||||
error: error.message,
|
|
||||||
operation: error.operation,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return PubSub.of(service);
|
return PubSub.of(service);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,25 @@ import { describe, expect, it } from "vitest";
|
||||||
import { guessMimeType } from "../commands/library.js";
|
import { guessMimeType } from "../commands/library.js";
|
||||||
|
|
||||||
describe("library CLI helpers", () => {
|
describe("library CLI helpers", () => {
|
||||||
|
it("keeps library load -t assigned to title while token remains long-only", () => {
|
||||||
|
const result = Bun.spawnSync({
|
||||||
|
cmd: ["bun", "src/index.ts", "library", "load", "--help"],
|
||||||
|
cwd: new URL("../../", import.meta.url).pathname,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdout = result.stdout.toString();
|
||||||
|
const stderr = result.stderr.toString();
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(stdout).toContain("--title");
|
||||||
|
expect(stdout).toContain("-t");
|
||||||
|
expect(stdout).toContain("--token");
|
||||||
|
expect(stdout).not.toContain("-t, --token");
|
||||||
|
});
|
||||||
|
|
||||||
it("detects known MIME types through the Match-backed extension mapper", () => {
|
it("detects known MIME types through the Match-backed extension mapper", () => {
|
||||||
expect(guessMimeType("paper.pdf")).toBe("application/pdf");
|
expect(guessMimeType("paper.pdf")).toBe("application/pdf");
|
||||||
expect(guessMimeType("notes.TXT")).toBe("text/plain");
|
expect(guessMimeType("notes.TXT")).toBe("text/plain");
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ export const rootCommand = Command.make("tg").pipe(
|
||||||
Flag.withDefault("cli"),
|
Flag.withDefault("cli"),
|
||||||
),
|
),
|
||||||
token: Flag.string("token").pipe(
|
token: Flag.string("token").pipe(
|
||||||
Flag.withAlias("t"),
|
|
||||||
Flag.withDescription("Authentication token"),
|
Flag.withDescription("Authentication token"),
|
||||||
Flag.optional,
|
Flag.optional,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
214
ts/packages/flow/src/__tests__/gateway-routes.test.ts
Normal file
214
ts/packages/flow/src/__tests__/gateway-routes.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { describe, expect, it } from "@effect/vitest";
|
||||||
|
import { Effect, Exit, Scope } from "effect";
|
||||||
|
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
||||||
|
import type { DispatcherManager } from "../gateway/dispatch/manager.js";
|
||||||
|
import type { GatewayRpcServer } from "../gateway/rpc-server.js";
|
||||||
|
import { makeGatewayRoutes, type GatewayConfig } from "../gateway/server.js";
|
||||||
|
|
||||||
|
interface DispatchCall {
|
||||||
|
readonly scope: "global" | "flow";
|
||||||
|
readonly flow?: string;
|
||||||
|
readonly kind: string;
|
||||||
|
readonly request: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublishCall {
|
||||||
|
readonly topic: string;
|
||||||
|
readonly message: unknown;
|
||||||
|
readonly id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteCalls {
|
||||||
|
readonly dispatches: Array<DispatchCall>;
|
||||||
|
readonly publishes: Array<PublishCall>;
|
||||||
|
rpcCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeFakeDispatcher = (
|
||||||
|
calls: RouteCalls,
|
||||||
|
response: unknown = { ok: true },
|
||||||
|
): DispatcherManager =>
|
||||||
|
({
|
||||||
|
start: Effect.void,
|
||||||
|
stop: Effect.void,
|
||||||
|
dispatchGlobalService: (kind, request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
calls.dispatches.push({ scope: "global", kind, request });
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
dispatchGlobalServiceStreaming: () => Effect.void,
|
||||||
|
dispatchFlowService: (flow, kind, request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
calls.dispatches.push({ scope: "flow", flow, kind, request });
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
dispatchFlowServiceStreaming: () => Effect.void,
|
||||||
|
publishToTopic: (topic, message, id) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
calls.publishes.push(id === undefined ? { topic, message } : { topic, message, id });
|
||||||
|
}),
|
||||||
|
}) as DispatcherManager;
|
||||||
|
|
||||||
|
const makeRequest = (
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
) =>
|
||||||
|
new Request(`http://localhost${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeRouteHarness = (
|
||||||
|
config: GatewayConfig = { port: 0, metricsPort: 0, secret: "secret" },
|
||||||
|
dispatchResponse?: unknown,
|
||||||
|
) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const rpcScope = yield* Scope.make();
|
||||||
|
yield* Effect.addFinalizer(() => Scope.close(rpcScope, Exit.void));
|
||||||
|
|
||||||
|
const calls: RouteCalls = {
|
||||||
|
dispatches: [],
|
||||||
|
publishes: [],
|
||||||
|
rpcCalls: 0,
|
||||||
|
};
|
||||||
|
const dispatcher = makeFakeDispatcher(calls, dispatchResponse);
|
||||||
|
const rpcServer: GatewayRpcServer = {
|
||||||
|
httpEffect: Effect.sync(() => {
|
||||||
|
calls.rpcCalls += 1;
|
||||||
|
return HttpServerResponse.text("rpc ok");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||||
|
makeGatewayRoutes(config, dispatcher, rpcServer, rpcScope),
|
||||||
|
{ disableLogger: true },
|
||||||
|
);
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => dispose()));
|
||||||
|
|
||||||
|
return { calls, handler };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gateway HTTP routes", () => {
|
||||||
|
it.effect(
|
||||||
|
"rejects protected dispatch routes without bearer auth before dispatching",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const { calls, handler } = yield* makeRouteHarness();
|
||||||
|
|
||||||
|
const response = yield* Effect.promise(() =>
|
||||||
|
handler(makeRequest("/api/v1/config", { operation: "get" }))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(calls.dispatches).toEqual([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"returns bad request for malformed and non-object JSON bodies",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const { calls, handler } = yield* makeRouteHarness();
|
||||||
|
const headers = { authorization: "Bearer secret" };
|
||||||
|
|
||||||
|
const malformed = yield* Effect.promise(() =>
|
||||||
|
handler(makeRequest("/api/v1/config", "{", headers))
|
||||||
|
);
|
||||||
|
const malformedBody = yield* Effect.promise(() => malformed.json()) as { error?: { message?: string } };
|
||||||
|
|
||||||
|
const arrayBody = yield* Effect.promise(() =>
|
||||||
|
handler(makeRequest("/api/v1/config", [], headers))
|
||||||
|
);
|
||||||
|
const arrayBodyJson = yield* Effect.promise(() => arrayBody.json()) as { error?: { message?: string } };
|
||||||
|
|
||||||
|
expect(malformed.status).toBe(400);
|
||||||
|
expect(malformedBody.error?.message).toBe("request body must be valid JSON");
|
||||||
|
expect(arrayBody.status).toBe(400);
|
||||||
|
expect(arrayBodyJson.error?.message).toBe("request body must be a JSON object");
|
||||||
|
expect(calls.dispatches).toEqual([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"dispatches global and flow POST routes with parsed request objects",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const { calls, handler } = yield* makeRouteHarness(undefined, { answer: 42 });
|
||||||
|
const headers = { authorization: "Bearer secret" };
|
||||||
|
|
||||||
|
const globalResponse = yield* Effect.promise(() =>
|
||||||
|
handler(makeRequest("/api/v1/config", { operation: "get", key: "demo" }, headers))
|
||||||
|
);
|
||||||
|
const flowResponse = yield* Effect.promise(() =>
|
||||||
|
handler(makeRequest("/api/v1/flow/demo/service/text-completion", { prompt: "hi" }, headers))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalResponse.status).toBe(200);
|
||||||
|
expect(flowResponse.status).toBe(200);
|
||||||
|
expect(calls.dispatches).toEqual([
|
||||||
|
{
|
||||||
|
scope: "global",
|
||||||
|
kind: "config",
|
||||||
|
request: { operation: "get", key: "demo" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: "flow",
|
||||||
|
flow: "demo",
|
||||||
|
kind: "text-completion",
|
||||||
|
request: { prompt: "hi" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"publishes flow load requests with generated metadata",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const { calls, handler } = yield* makeRouteHarness();
|
||||||
|
|
||||||
|
const response = yield* Effect.promise(() =>
|
||||||
|
handler(
|
||||||
|
makeRequest(
|
||||||
|
"/api/v1/flow/demo/load",
|
||||||
|
{ documentId: "doc-1", user: "alice", collection: "docs" },
|
||||||
|
{ authorization: "Bearer secret" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const body = yield* Effect.promise(() => response.json()) as { status?: string; documentId?: string; flow?: string };
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ status: "processing", documentId: "doc-1", flow: "demo" });
|
||||||
|
expect(calls.publishes).toHaveLength(1);
|
||||||
|
expect(calls.publishes[0]?.topic).toBe("tg.flow.document");
|
||||||
|
expect(calls.publishes[0]?.message).toMatchObject({
|
||||||
|
documentId: "doc-1",
|
||||||
|
metadata: {
|
||||||
|
root: "doc-1",
|
||||||
|
user: "alice",
|
||||||
|
collection: "docs",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"checks the RPC route token before delegating to the native websocket effect",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const { calls, handler } = yield* makeRouteHarness();
|
||||||
|
|
||||||
|
const rejected = yield* Effect.promise(() =>
|
||||||
|
handler(new Request("http://localhost/api/v1/rpc?token=wrong"))
|
||||||
|
);
|
||||||
|
const accepted = yield* Effect.promise(() =>
|
||||||
|
handler(new Request("http://localhost/api/v1/rpc?token=secret"))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rejected.status).toBe(401);
|
||||||
|
expect(accepted.status).toBe(200);
|
||||||
|
expect(yield* Effect.promise(() => accepted.text())).toBe("rpc ok");
|
||||||
|
expect(calls.rpcCalls).toBe(1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { Effect, Queue } from "effect";
|
|
||||||
import * as O from "effect/Option";
|
|
||||||
import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
|
|
||||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
|
||||||
import * as Socket from "effect/unstable/socket/Socket";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { makeSocketRpcProtocol } from "../gateway/rpc-protocol.js";
|
|
||||||
|
|
||||||
interface ReceivedMessage {
|
|
||||||
readonly clientId: number;
|
|
||||||
readonly message: RpcMessage.FromClientEncoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProtocolRunResult {
|
|
||||||
readonly messages: ReadonlyArray<ReceivedMessage>;
|
|
||||||
readonly writes: ReadonlyArray<string | Uint8Array | CloseEvent>;
|
|
||||||
readonly clientIds: ReadonlyArray<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonFrame = (value: unknown): string => `${JSON.stringify(value)}\n`;
|
|
||||||
|
|
||||||
const optionToArray = <A>(value: O.Option<A>): Array<A> =>
|
|
||||||
O.match(value, {
|
|
||||||
onNone: () => [],
|
|
||||||
onSome: (item) => [item],
|
|
||||||
});
|
|
||||||
|
|
||||||
const runProtocolFrames = (
|
|
||||||
frames: ReadonlyArray<string | Uint8Array>,
|
|
||||||
headers?: ReadonlyArray<[string, string]>,
|
|
||||||
sendResponse?: RpcMessage.FromServerEncoded,
|
|
||||||
): Promise<ProtocolRunResult> =>
|
|
||||||
Effect.runPromise(
|
|
||||||
Effect.scoped(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const received = yield* Queue.unbounded<ReceivedMessage>();
|
|
||||||
const writes: Array<string | Uint8Array | CloseEvent> = [];
|
|
||||||
const { onSocket, protocol } = yield* makeSocketRpcProtocol;
|
|
||||||
|
|
||||||
yield* protocol.run((clientId, message) =>
|
|
||||||
Queue.offer(received, { clientId, message }).pipe(Effect.asVoid)
|
|
||||||
).pipe(Effect.forkScoped);
|
|
||||||
yield* Effect.yieldNow;
|
|
||||||
|
|
||||||
const socket = Socket.make({
|
|
||||||
writer: Effect.succeed((chunk) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
writes.push(chunk);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
runRaw: (handler) =>
|
|
||||||
Effect.forEach(frames, (frame) =>
|
|
||||||
Effect.suspend(() => {
|
|
||||||
const result = handler(frame);
|
|
||||||
return result === undefined ? Effect.void : result;
|
|
||||||
}), { discard: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
yield* onSocket(socket, headers);
|
|
||||||
yield* Effect.yieldNow;
|
|
||||||
const clientIds = yield* protocol.clientIds;
|
|
||||||
if (sendResponse !== undefined) {
|
|
||||||
yield* protocol.send(0, sendResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = yield* Queue.poll(received);
|
|
||||||
const second = yield* Queue.poll(received);
|
|
||||||
const third = yield* Queue.poll(received);
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
...optionToArray(first),
|
|
||||||
...optionToArray(second),
|
|
||||||
...optionToArray(third),
|
|
||||||
],
|
|
||||||
writes,
|
|
||||||
clientIds: Array.from(clientIds),
|
|
||||||
};
|
|
||||||
}).pipe(
|
|
||||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("gateway RPC socket protocol", () => {
|
|
||||||
it("validates client request frames and prepends websocket headers", async () => {
|
|
||||||
const result = await runProtocolFrames([
|
|
||||||
jsonFrame({
|
|
||||||
_tag: "Request",
|
|
||||||
id: "1",
|
|
||||||
tag: "Dispatch",
|
|
||||||
payload: {
|
|
||||||
scope: "global",
|
|
||||||
service: "config",
|
|
||||||
request: {},
|
|
||||||
},
|
|
||||||
headers: [["rpc", "client"]],
|
|
||||||
}),
|
|
||||||
], [["socket", "header"]]);
|
|
||||||
|
|
||||||
expect(result.writes).toEqual([]);
|
|
||||||
expect(result.messages).toHaveLength(1);
|
|
||||||
|
|
||||||
const received = result.messages[0];
|
|
||||||
expect(received).toBeDefined();
|
|
||||||
if (received === undefined) return;
|
|
||||||
|
|
||||||
expect(received.clientId).toBe(0);
|
|
||||||
expect(received.message._tag).toBe("Request");
|
|
||||||
if (received.message._tag !== "Request") return;
|
|
||||||
|
|
||||||
expect(received.message.id).toBe("1");
|
|
||||||
expect(received.message.tag).toBe("Dispatch");
|
|
||||||
expect(received.message.headers).toEqual([
|
|
||||||
["socket", "header"],
|
|
||||||
["rpc", "client"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates client control frames without mutating them", async () => {
|
|
||||||
const result = await runProtocolFrames([
|
|
||||||
jsonFrame({
|
|
||||||
_tag: "Ping",
|
|
||||||
}),
|
|
||||||
jsonFrame({
|
|
||||||
_tag: "Ack",
|
|
||||||
requestId: "1",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.writes).toEqual([]);
|
|
||||||
expect(result.messages.map(({ message }) => message._tag)).toEqual(["Ping", "Ack"]);
|
|
||||||
expect(result.messages[1]?.message).toEqual({
|
|
||||||
_tag: "Ack",
|
|
||||||
requestId: "1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects server response envelopes received from the socket", async () => {
|
|
||||||
const result = await runProtocolFrames([
|
|
||||||
jsonFrame({
|
|
||||||
_tag: "Exit",
|
|
||||||
requestId: "1",
|
|
||||||
exit: {
|
|
||||||
_tag: "Success",
|
|
||||||
value: { ok: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result.messages).toEqual([]);
|
|
||||||
expect(result.writes).toHaveLength(1);
|
|
||||||
expect(String(result.writes[0])).toContain("\"_tag\":\"Defect\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends server responses through the registered client", async () => {
|
|
||||||
const result = await runProtocolFrames(
|
|
||||||
[],
|
|
||||||
undefined,
|
|
||||||
RpcMessage.ResponseDefectEncoded("server-boom"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.clientIds).toEqual([0]);
|
|
||||||
expect(result.writes).toHaveLength(1);
|
|
||||||
expect(String(result.writes[0])).toContain("server-boom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import { Effect, Queue, Scope } from "effect";
|
|
||||||
import * as MutableHashMap from "effect/MutableHashMap";
|
|
||||||
import * as MutableHashSet from "effect/MutableHashSet";
|
|
||||||
import * as O from "effect/Option";
|
|
||||||
import * as S from "effect/Schema";
|
|
||||||
import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
|
|
||||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
|
||||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
|
||||||
import * as Socket from "effect/unstable/socket/Socket";
|
|
||||||
|
|
||||||
export class RpcProtocolDecodeError extends S.TaggedErrorClass<RpcProtocolDecodeError>()(
|
|
||||||
"RpcProtocolDecodeError",
|
|
||||||
{
|
|
||||||
message: S.String,
|
|
||||||
cause: S.Unknown,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
const HeaderSchema = S.mutable(S.Tuple([S.String, S.String]));
|
|
||||||
const FromClientEncodedSchema = S.Union([
|
|
||||||
S.TaggedStruct("Request", {
|
|
||||||
id: S.String,
|
|
||||||
tag: S.String,
|
|
||||||
payload: S.Unknown,
|
|
||||||
headers: S.Array(HeaderSchema),
|
|
||||||
traceId: S.optionalKey(S.String),
|
|
||||||
spanId: S.optionalKey(S.String),
|
|
||||||
sampled: S.optionalKey(S.Boolean),
|
|
||||||
}),
|
|
||||||
S.TaggedStruct("Ack", {
|
|
||||||
requestId: S.String,
|
|
||||||
}),
|
|
||||||
S.TaggedStruct("Interrupt", {
|
|
||||||
requestId: S.String,
|
|
||||||
}),
|
|
||||||
S.TaggedStruct("Ping", {}),
|
|
||||||
S.TaggedStruct("Eof", {}),
|
|
||||||
]).pipe(S.toTaggedUnion("_tag"));
|
|
||||||
const FromClientEncodedMessagesSchema = S.Array(FromClientEncodedSchema);
|
|
||||||
const decodeFromClientMessages = S.decodeUnknownEffect(FromClientEncodedMessagesSchema);
|
|
||||||
|
|
||||||
export const makeSocketRpcProtocol = Effect.gen(function* () {
|
|
||||||
const serialization = yield* RpcSerialization.RpcSerialization;
|
|
||||||
const disconnects = yield* Queue.make<number>();
|
|
||||||
|
|
||||||
let nextClientId = 0;
|
|
||||||
const clients = MutableHashMap.empty<number, {
|
|
||||||
readonly write: (response: RpcMessage.FromServerEncoded) => Effect.Effect<void>;
|
|
||||||
}>();
|
|
||||||
const clientIds = MutableHashSet.empty<number>();
|
|
||||||
|
|
||||||
let writeRequest!: (
|
|
||||||
clientId: number,
|
|
||||||
message: RpcMessage.FromClientEncoded,
|
|
||||||
) => Effect.Effect<void>;
|
|
||||||
|
|
||||||
const onSocket = function* (
|
|
||||||
socket: Socket.Socket,
|
|
||||||
headers?: ReadonlyArray<[string, string]>,
|
|
||||||
) {
|
|
||||||
const scope = yield* Effect.scope;
|
|
||||||
const parser = serialization.makeUnsafe();
|
|
||||||
const clientId = nextClientId++;
|
|
||||||
|
|
||||||
yield* Scope.addFinalizerExit(scope, () => {
|
|
||||||
MutableHashMap.remove(clients, clientId);
|
|
||||||
MutableHashSet.remove(clientIds, clientId);
|
|
||||||
return Queue.offer(disconnects, clientId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const writeRaw = yield* socket.writer;
|
|
||||||
const writeDefect = (cause: unknown) =>
|
|
||||||
Effect.sync(() => parser.encode(RpcMessage.ResponseDefectEncoded(cause))).pipe(
|
|
||||||
Effect.flatMap((encoded) => encoded === undefined ? Effect.void : writeRaw(encoded)),
|
|
||||||
);
|
|
||||||
const write = (response: RpcMessage.FromServerEncoded) =>
|
|
||||||
Effect.sync(() => parser.encode(response)).pipe(
|
|
||||||
Effect.flatMap((encoded) =>
|
|
||||||
encoded === undefined ? Effect.void : Effect.orDie(writeRaw(encoded)),
|
|
||||||
),
|
|
||||||
Effect.catchDefect((cause: unknown) =>
|
|
||||||
writeDefect(cause).pipe(Effect.orDie),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
MutableHashMap.set(clients, clientId, { write });
|
|
||||||
MutableHashSet.add(clientIds, clientId);
|
|
||||||
|
|
||||||
yield* socket.runRaw((data) =>
|
|
||||||
Effect.try({
|
|
||||||
try: () => parser.decode(data),
|
|
||||||
catch: (cause) =>
|
|
||||||
RpcProtocolDecodeError.make({
|
|
||||||
message: "Failed to decode RPC socket frame",
|
|
||||||
cause,
|
|
||||||
}),
|
|
||||||
}).pipe(
|
|
||||||
Effect.flatMap((raw) =>
|
|
||||||
decodeFromClientMessages(raw).pipe(
|
|
||||||
Effect.mapError((cause) =>
|
|
||||||
RpcProtocolDecodeError.make({
|
|
||||||
message: "RPC socket frame did not contain valid client messages",
|
|
||||||
cause,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Effect.flatMap((decoded) =>
|
|
||||||
Effect.forEach(decoded, (message) => {
|
|
||||||
if (message._tag === "Request" && headers !== undefined) {
|
|
||||||
return writeRequest(clientId, {
|
|
||||||
...message,
|
|
||||||
headers: headers.concat(message.headers),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return writeRequest(clientId, message);
|
|
||||||
}, { discard: true }),
|
|
||||||
),
|
|
||||||
Effect.catch((cause) => writeDefect(cause)),
|
|
||||||
Effect.catchDefect((cause: unknown) => writeDefect(cause)),
|
|
||||||
)
|
|
||||||
).pipe(
|
|
||||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
|
||||||
Effect.orDie,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const protocol = yield* RpcServer.Protocol.make((writeRequest_) => {
|
|
||||||
writeRequest = writeRequest_;
|
|
||||||
return Effect.succeed({
|
|
||||||
disconnects,
|
|
||||||
send: (clientId, response) =>
|
|
||||||
O.match(MutableHashMap.get(clients, clientId), {
|
|
||||||
onNone: () => Effect.void,
|
|
||||||
onSome: (client) => Effect.orDie(client.write(response)),
|
|
||||||
}),
|
|
||||||
end: () => Effect.void,
|
|
||||||
clientIds: Effect.sync(() => new Set(clientIds)),
|
|
||||||
initialMessage: Effect.succeedNone,
|
|
||||||
supportsAck: true,
|
|
||||||
supportsTransferables: false,
|
|
||||||
supportsSpanPropagation: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return { onSocket, protocol } as const;
|
|
||||||
});
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
import { Cause, Effect, Layer, Queue, Scope } from "effect";
|
import { Cause, Effect, Layer, Queue, Scope } from "effect";
|
||||||
|
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
||||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||||
import type * as Socket from "effect/unstable/socket/Socket";
|
|
||||||
import { errorMessage } from "@trustgraph/base";
|
import { errorMessage } from "@trustgraph/base";
|
||||||
import type { DispatcherManager, DispatcherStreamError } from "./dispatch/manager.js";
|
import type { DispatcherManager, DispatcherStreamError } from "./dispatch/manager.js";
|
||||||
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
||||||
import { makeSocketRpcProtocol } from "./rpc-protocol.js";
|
|
||||||
|
|
||||||
export interface GatewayRpcServer {
|
export interface GatewayRpcServer {
|
||||||
readonly onSocket: (
|
readonly httpEffect: Effect.Effect<
|
||||||
socket: Socket.Socket,
|
HttpServerResponse.HttpServerResponse,
|
||||||
headers?: ReadonlyArray<[string, string]>,
|
never,
|
||||||
) => Effect.Effect<void, never, Scope.Scope>;
|
Scope.Scope | HttpServerRequest.HttpServerRequest
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function* (
|
export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function* (
|
||||||
dispatcher: DispatcherManager,
|
dispatcher: DispatcherManager,
|
||||||
) {
|
) {
|
||||||
const { onSocket, protocol } = yield* makeSocketRpcProtocol;
|
const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
|
||||||
|
|
||||||
const serverLayer = RpcServer.layer(TrustGraphRpcs, {
|
const serverLayer = RpcServer.layer(TrustGraphRpcs, {
|
||||||
disableFatalDefects: true,
|
disableFatalDefects: true,
|
||||||
|
|
@ -30,9 +30,7 @@ export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function*
|
||||||
yield* Layer.launch(serverLayer).pipe(Effect.forkScoped);
|
yield* Layer.launch(serverLayer).pipe(Effect.forkScoped);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onSocket: Effect.fn("GatewayRpc.onSocket")(function* (socket, headers) {
|
httpEffect,
|
||||||
yield* onSocket(socket, headers);
|
|
||||||
}),
|
|
||||||
} satisfies GatewayRpcServer;
|
} satisfies GatewayRpcServer;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,25 @@ const dispatchResult = (result: unknown) => {
|
||||||
|
|
||||||
const readJsonRecord = Effect.gen(function* () {
|
const readJsonRecord = Effect.gen(function* () {
|
||||||
const request = yield* HttpServerRequest.HttpServerRequest;
|
const request = yield* HttpServerRequest.HttpServerRequest;
|
||||||
const body = yield* request.json;
|
const body = yield* request.json.pipe(
|
||||||
return isRecord(body) ? body : {};
|
Effect.mapError(() => "request body must be valid JSON"),
|
||||||
|
);
|
||||||
|
if (!isRecord(body)) {
|
||||||
|
return yield* Effect.fail("request body must be a JSON object");
|
||||||
|
}
|
||||||
|
return body;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const withJsonRecord = <R>(
|
||||||
|
use: (body: Record<string, unknown>) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, R>,
|
||||||
|
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest.HttpServerRequest | R> =>
|
||||||
|
readJsonRecord.pipe(
|
||||||
|
Effect.matchEffect({
|
||||||
|
onFailure: (message) => Effect.succeed(badRequest(message)),
|
||||||
|
onSuccess: use,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const bearerAuthResponse = (config: GatewayConfig) =>
|
const bearerAuthResponse = (config: GatewayConfig) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (config.secret === undefined || config.secret.length === 0) return null;
|
if (config.secret === undefined || config.secret.length === 0) return null;
|
||||||
|
|
@ -98,26 +113,25 @@ const workbenchDispatch = (
|
||||||
) =>
|
) =>
|
||||||
withBearerAuth(
|
withBearerAuth(
|
||||||
config,
|
config,
|
||||||
Effect.gen(function* () {
|
withJsonRecord((body) =>
|
||||||
const body = yield* readJsonRecord.pipe(
|
Effect.gen(function* () {
|
||||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
const service = typeof body.service === "string" ? body.service : undefined;
|
||||||
);
|
const payload = isRecord(body.request) ? body.request : undefined;
|
||||||
const service = typeof body.service === "string" ? body.service : undefined;
|
if (service === undefined || service.length === 0 || payload === undefined) {
|
||||||
const payload = isRecord(body.request) ? body.request : undefined;
|
return badRequest("service and request are required");
|
||||||
if (service === undefined || service.length === 0 || payload === undefined) {
|
}
|
||||||
return badRequest("service and request are required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatch = body.scope === "flow"
|
const dispatch = body.scope === "flow"
|
||||||
? dispatcher.dispatchFlowService(
|
? dispatcher.dispatchFlowService(
|
||||||
typeof body.flow === "string" ? body.flow : "default",
|
typeof body.flow === "string" ? body.flow : "default",
|
||||||
service,
|
service,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
: dispatcher.dispatchGlobalService(service, payload);
|
: dispatcher.dispatchGlobalService(service, payload);
|
||||||
|
|
||||||
return yield* withDispatchError(dispatch, "workbench-dispatch");
|
return yield* withDispatchError(dispatch, "workbench-dispatch");
|
||||||
}),
|
})
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const globalDispatch = (
|
const globalDispatch = (
|
||||||
|
|
@ -128,12 +142,11 @@ const globalDispatch = (
|
||||||
config,
|
config,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const params = yield* HttpRouter.params;
|
const params = yield* HttpRouter.params;
|
||||||
const body = yield* readJsonRecord.pipe(
|
return yield* withJsonRecord((body) =>
|
||||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
withDispatchError(
|
||||||
);
|
dispatcher.dispatchGlobalService(params.kind ?? "", body),
|
||||||
return yield* withDispatchError(
|
"global-dispatch",
|
||||||
dispatcher.dispatchGlobalService(params.kind ?? "", body),
|
)
|
||||||
"global-dispatch",
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -146,12 +159,11 @@ const flowDispatch = (
|
||||||
config,
|
config,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const params = yield* HttpRouter.params;
|
const params = yield* HttpRouter.params;
|
||||||
const body = yield* readJsonRecord.pipe(
|
return yield* withJsonRecord((body) =>
|
||||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
withDispatchError(
|
||||||
);
|
dispatcher.dispatchFlowService(params.flow ?? "default", params.kind ?? "", body),
|
||||||
return yield* withDispatchError(
|
"flow-dispatch",
|
||||||
dispatcher.dispatchFlowService(params.flow ?? "default", params.kind ?? "", body),
|
)
|
||||||
"flow-dispatch",
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -164,33 +176,34 @@ const flowLoad = (
|
||||||
config,
|
config,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const params = yield* HttpRouter.params;
|
const params = yield* HttpRouter.params;
|
||||||
const body = yield* readJsonRecord.pipe(
|
return yield* withJsonRecord((body) =>
|
||||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
Effect.gen(function* () {
|
||||||
|
const documentId = typeof body.documentId === "string" ? body.documentId : undefined;
|
||||||
|
if (documentId === undefined || documentId.length === 0) {
|
||||||
|
return badRequest("documentId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = typeof body.user === "string" ? body.user : "default";
|
||||||
|
const collection = typeof body.collection === "string" ? body.collection : "default";
|
||||||
|
const timestamp = yield* Clock.currentTimeMillis;
|
||||||
|
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
|
||||||
|
const metadata = {
|
||||||
|
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
|
||||||
|
root: documentId,
|
||||||
|
user,
|
||||||
|
collection,
|
||||||
|
};
|
||||||
|
|
||||||
|
yield* dispatcher.publishToTopic("tg.flow.document", { metadata, documentId }).pipe(
|
||||||
|
Effect.mapError((cause) => messagingLifecycleError("gateway", "publish-load", cause)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({ status: "processing", documentId, flow: params.flow ?? "default" });
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((error) => Effect.succeed(dispatchError(error))),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const documentId = typeof body.documentId === "string" ? body.documentId : undefined;
|
}),
|
||||||
if (documentId === undefined || documentId.length === 0) {
|
|
||||||
return badRequest("documentId is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = typeof body.user === "string" ? body.user : "default";
|
|
||||||
const collection = typeof body.collection === "string" ? body.collection : "default";
|
|
||||||
const timestamp = yield* Clock.currentTimeMillis;
|
|
||||||
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
|
|
||||||
const metadata = {
|
|
||||||
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
|
|
||||||
root: documentId,
|
|
||||||
user,
|
|
||||||
collection,
|
|
||||||
};
|
|
||||||
|
|
||||||
yield* dispatcher.publishToTopic("tg.flow.document", { metadata, documentId }).pipe(
|
|
||||||
Effect.mapError((cause) => messagingLifecycleError("gateway", "publish-load", cause)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return json({ status: "processing", documentId, flow: params.flow ?? "default" });
|
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) => Effect.succeed(dispatchError(error))),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const rpcRoute = (
|
const rpcRoute = (
|
||||||
|
|
@ -206,14 +219,10 @@ const rpcRoute = (
|
||||||
return json({ error: "Unauthorized" }, 401);
|
return json({ error: "Unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = yield* request.upgrade;
|
return yield* rpcServer.httpEffect.pipe(
|
||||||
yield* rpcServer.onSocket(socket, headersFrom(request.headers)).pipe(
|
|
||||||
Scope.provide(rpcScope),
|
Scope.provide(rpcScope),
|
||||||
);
|
);
|
||||||
return HttpServerResponse.empty();
|
});
|
||||||
}).pipe(
|
|
||||||
Effect.catch((error) => Effect.succeed(dispatchError(error))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const metricsRoute =
|
const metricsRoute =
|
||||||
formatPrometheusMetrics.pipe(
|
formatPrometheusMetrics.pipe(
|
||||||
|
|
@ -224,7 +233,7 @@ const metricsRoute =
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const gatewayRoutes = (
|
export const makeGatewayRoutes = (
|
||||||
config: GatewayConfig,
|
config: GatewayConfig,
|
||||||
dispatcher: DispatcherManager,
|
dispatcher: DispatcherManager,
|
||||||
rpcServer: GatewayRpcServer,
|
rpcServer: GatewayRpcServer,
|
||||||
|
|
@ -265,7 +274,7 @@ export function createGateway(config: GatewayConfig) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const serverLayer = HttpRouter.serve(
|
const serverLayer = HttpRouter.serve(
|
||||||
gatewayRoutes(config, dispatcher, rpcServer, rpcScope),
|
makeGatewayRoutes(config, dispatcher, rpcServer, rpcScope),
|
||||||
).pipe(
|
).pipe(
|
||||||
Layer.provideMerge(NodeHttpServer.layer(createServer, {
|
Layer.provideMerge(NodeHttpServer.layer(createServer, {
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
|
@ -279,15 +288,6 @@ export function createGateway(config: GatewayConfig) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> {
|
|
||||||
return Object.entries(headers).flatMap(([key, value]) => {
|
|
||||||
if (typeof value === "string") return [[key, value] satisfies [string, string]];
|
|
||||||
if (typeof value === "number") return [[key, String(value)] satisfies [string, string]];
|
|
||||||
if (Array.isArray(value)) return value.map((item) => [key, item] satisfies [string, string]);
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runMain(): void {
|
export function runMain(): void {
|
||||||
NodeRuntime.runMain(program);
|
NodeRuntime.runMain(program);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, expect, it } from "@effect/vitest";
|
import { describe, expect, it } from "@effect/vitest";
|
||||||
import type { BaseApi } from "@trustgraph/client";
|
import type { BaseApi } from "@trustgraph/client";
|
||||||
import { Effect, Layer, Stream } from "effect";
|
import { Effect, Layer } from "effect";
|
||||||
import * as S from "effect/Schema";
|
import * as S from "effect/Schema";
|
||||||
import { LanguageModel, McpServer } from "effect/unstable/ai";
|
import { McpServer } from "effect/unstable/ai";
|
||||||
import * as McpSchema from "effect/unstable/ai/McpSchema";
|
import * as McpSchema from "effect/unstable/ai/McpSchema";
|
||||||
import { FetchHttpClient, HttpRouter } from "effect/unstable/http";
|
import { FetchHttpClient, HttpRouter } from "effect/unstable/http";
|
||||||
import { RpcSerialization } from "effect/unstable/rpc";
|
import { RpcSerialization } from "effect/unstable/rpc";
|
||||||
|
|
@ -52,7 +52,7 @@ interface FakeSocketCalls {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NativeTestClientOptions {
|
interface NativeTestClientOptions {
|
||||||
readonly languageText?: string | undefined;
|
readonly textCompletion?: (() => Promise<string>) | undefined;
|
||||||
readonly graphRag?: (() => Promise<string>) | undefined;
|
readonly graphRag?: (() => Promise<string>) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +60,7 @@ const decodeJsonText = S.decodeUnknownSync(S.UnknownFromJsonString);
|
||||||
|
|
||||||
const makeFakeSocket = (
|
const makeFakeSocket = (
|
||||||
options: {
|
options: {
|
||||||
|
readonly textCompletion?: (() => Promise<string>) | undefined;
|
||||||
readonly graphRag?: (() => Promise<string>) | undefined;
|
readonly graphRag?: (() => Promise<string>) | undefined;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -73,7 +74,9 @@ const makeFakeSocket = (
|
||||||
flow: (flowId: string) => {
|
flow: (flowId: string) => {
|
||||||
calls.flowIds.push(flowId);
|
calls.flowIds.push(flowId);
|
||||||
return {
|
return {
|
||||||
textCompletion: () => Promise.resolve("legacy text completion should not be used"),
|
textCompletion: () => options.textCompletion === undefined
|
||||||
|
? Promise.resolve("gateway text completion")
|
||||||
|
: options.textCompletion(),
|
||||||
graphRag: (query: string, ragOptions: unknown, collection?: string) => {
|
graphRag: (query: string, ragOptions: unknown, collection?: string) => {
|
||||||
calls.graphRag.push({ query, options: ragOptions, collection });
|
calls.graphRag.push({ query, options: ragOptions, collection });
|
||||||
return options.graphRag === undefined
|
return options.graphRag === undefined
|
||||||
|
|
@ -121,15 +124,6 @@ const makeFakeSocket = (
|
||||||
return { socket, calls };
|
return { socket, calls };
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeLanguageModelLayer = (text: string) =>
|
|
||||||
Layer.effect(
|
|
||||||
LanguageModel.LanguageModel,
|
|
||||||
LanguageModel.make({
|
|
||||||
generateText: () => Effect.succeed([{ type: "text", text }]),
|
|
||||||
streamText: () => Stream.empty,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const testConfig = TrustGraphMcpConfig.of({
|
const testConfig = TrustGraphMcpConfig.of({
|
||||||
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
|
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
|
||||||
user: "mcp-test",
|
user: "mcp-test",
|
||||||
|
|
@ -138,8 +132,6 @@ const testConfig = TrustGraphMcpConfig.of({
|
||||||
name: "trustgraph",
|
name: "trustgraph",
|
||||||
version: "0.1.0-test",
|
version: "0.1.0-test",
|
||||||
mcpPath: "/mcp",
|
mcpPath: "/mcp",
|
||||||
openAiModel: "test-model",
|
|
||||||
openAiApiKey: undefined,
|
|
||||||
port: 3000,
|
port: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -151,10 +143,12 @@ const makeNativeTestClient = (
|
||||||
const makeNativeTestClientEffect = Effect.fn("makeNativeTestClient")(function*(
|
const makeNativeTestClientEffect = Effect.fn("makeNativeTestClient")(function*(
|
||||||
options: NativeTestClientOptions,
|
options: NativeTestClientOptions,
|
||||||
) {
|
) {
|
||||||
const { socket, calls } = makeFakeSocket({ graphRag: options.graphRag });
|
const { socket, calls } = makeFakeSocket({
|
||||||
|
textCompletion: options.textCompletion,
|
||||||
|
graphRag: options.graphRag,
|
||||||
|
});
|
||||||
const serverLayer = McpServer.toolkit(TrustGraphMcpToolkit).pipe(
|
const serverLayer = McpServer.toolkit(TrustGraphMcpToolkit).pipe(
|
||||||
Layer.provide(TrustGraphMcpToolkitLive),
|
Layer.provide(TrustGraphMcpToolkitLive),
|
||||||
Layer.provide(makeLanguageModelLayer(options.languageText ?? "direct ai answer")),
|
|
||||||
Layer.provide(Layer.succeed(TrustGraphSocket, TrustGraphSocket.of(socket))),
|
Layer.provide(Layer.succeed(TrustGraphSocket, TrustGraphSocket.of(socket))),
|
||||||
Layer.provide(Layer.succeed(TrustGraphMcpConfig, testConfig)),
|
Layer.provide(Layer.succeed(TrustGraphMcpConfig, testConfig)),
|
||||||
Layer.provide(McpServer.layerHttp({
|
Layer.provide(McpServer.layerHttp({
|
||||||
|
|
@ -225,7 +219,6 @@ describe("Effect MCP server", () => {
|
||||||
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
|
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
|
||||||
user: "mcp-test",
|
user: "mcp-test",
|
||||||
flowId: "default",
|
flowId: "default",
|
||||||
openAiApiKey: "test-key",
|
|
||||||
}),
|
}),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
|
|
||||||
|
|
@ -252,11 +245,11 @@ describe("Effect MCP server", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
it.effect(
|
it.effect(
|
||||||
"calls text_completion through the direct Effect language model",
|
"calls text_completion through the configured TrustGraph flow",
|
||||||
Effect.fnUntraced(function*() {
|
Effect.fnUntraced(function*() {
|
||||||
yield* Effect.scoped(Effect.gen(function*() {
|
yield* Effect.scoped(Effect.gen(function*() {
|
||||||
const { client, calls } = yield* makeNativeTestClient({
|
const { client, calls } = yield* makeNativeTestClient({
|
||||||
languageText: "direct model response",
|
textCompletion: () => Promise.resolve("gateway model response"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = yield* client["tools/call"]({
|
const result = yield* client["tools/call"]({
|
||||||
|
|
@ -268,9 +261,9 @@ describe("Effect MCP server", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.isError).toBe(false);
|
expect(result.isError).toBe(false);
|
||||||
expect(result.structuredContent).toEqual({ text: "direct model response" });
|
expect(result.structuredContent).toEqual({ text: "gateway model response" });
|
||||||
expect(decodeJsonText(textContent(result))).toEqual({ text: "direct model response" });
|
expect(decodeJsonText(textContent(result))).toEqual({ text: "gateway model response" });
|
||||||
expect(calls.flowIds).toEqual([]);
|
expect(calls.flowIds).toEqual(["default"]);
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -319,12 +312,32 @@ describe("Effect MCP server", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.structuredContent).toEqual({
|
expect(result.isError).toBe(true);
|
||||||
_tag: "GraphRagError",
|
expect(result.structuredContent).toBeUndefined();
|
||||||
message: "gateway unavailable",
|
expect(textContent(result)).toContain("gateway unavailable");
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"returns MCP errors for text_completion failures",
|
||||||
|
Effect.fnUntraced(function*() {
|
||||||
|
yield* Effect.scoped(Effect.gen(function*() {
|
||||||
|
const { client } = yield* makeNativeTestClient({
|
||||||
|
textCompletion: () => Promise.reject(new Error("text service down")),
|
||||||
});
|
});
|
||||||
expect(result.structuredContent).not.toHaveProperty("cause");
|
|
||||||
expect(decodeJsonText(textContent(result))).toEqual(result.structuredContent);
|
const result = yield* client["tools/call"]({
|
||||||
|
name: "text_completion",
|
||||||
|
arguments: {
|
||||||
|
system: "You are concise.",
|
||||||
|
prompt: "Say hello.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.structuredContent).toBeUndefined();
|
||||||
|
expect(textContent(result)).toContain("text service down");
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import {OpenAiClient, OpenAiLanguageModel} from "@effect/ai-openai";
|
|
||||||
import {BunHttpServer, BunRuntime} from "@effect/platform-bun";
|
import {BunHttpServer, BunRuntime} from "@effect/platform-bun";
|
||||||
import {NodeRuntime, NodeStdio} from "@effect/platform-node";
|
import {NodeRuntime, NodeStdio} from "@effect/platform-node";
|
||||||
import {createTrustGraphSocket, type BaseApi, type Term as ClientTerm} from "@trustgraph/client";
|
import {createTrustGraphSocket, type BaseApi, type Term as ClientTerm} from "@trustgraph/client";
|
||||||
import {Config, Context, Effect, Layer, Redacted} from "effect";
|
import {Config, Context, Effect, Layer} from "effect";
|
||||||
import * as O from "effect/Option";
|
import * as O from "effect/Option";
|
||||||
import * as Predicate from "effect/Predicate";
|
import * as Predicate from "effect/Predicate";
|
||||||
import {LanguageModel, McpServer, Prompt, Tool, Toolkit} from "effect/unstable/ai";
|
import {McpServer, Tool, Toolkit} from "effect/unstable/ai";
|
||||||
import {FetchHttpClient, HttpRouter} from "effect/unstable/http";
|
import {HttpRouter} from "effect/unstable/http";
|
||||||
import {HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi} from "effect/unstable/httpapi";
|
import {HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi} from "effect/unstable/httpapi";
|
||||||
import * as S from "effect/Schema";
|
import * as S from "effect/Schema";
|
||||||
|
|
||||||
|
|
@ -160,7 +159,7 @@ export const TextCompletionTool = annotateTool(
|
||||||
parameters: TextCompletionParameters,
|
parameters: TextCompletionParameters,
|
||||||
success: TextCompletionSuccess,
|
success: TextCompletionSuccess,
|
||||||
failure: TextCompletionError,
|
failure: TextCompletionError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
title: "Text Completion",
|
title: "Text Completion",
|
||||||
|
|
@ -210,7 +209,7 @@ export const GraphRagTool = annotateTool(
|
||||||
parameters: GraphRagParameters,
|
parameters: GraphRagParameters,
|
||||||
success: GraphRagSuccess,
|
success: GraphRagSuccess,
|
||||||
failure: GraphRagError,
|
failure: GraphRagError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
title: "Graph RAG",
|
title: "Graph RAG",
|
||||||
|
|
@ -258,7 +257,7 @@ export const DocumentRagTool = annotateTool(
|
||||||
parameters: DocumentRagParameters,
|
parameters: DocumentRagParameters,
|
||||||
success: DocumentRagSuccess,
|
success: DocumentRagSuccess,
|
||||||
failure: DocumentRagError,
|
failure: DocumentRagError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
title: "Document RAG",
|
title: "Document RAG",
|
||||||
|
|
@ -298,7 +297,7 @@ export const AgentTool = annotateTool(
|
||||||
parameters: AgentParameters,
|
parameters: AgentParameters,
|
||||||
success: AgentSuccess,
|
success: AgentSuccess,
|
||||||
failure: AgentError,
|
failure: AgentError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Ask the TrustGraph agent a question"
|
description: "Ask the TrustGraph agent a question"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -341,7 +340,7 @@ export const EmbeddingsTool = annotateTool(
|
||||||
parameters: EmbeddingsParameters,
|
parameters: EmbeddingsParameters,
|
||||||
success: EmbeddingsSuccess,
|
success: EmbeddingsSuccess,
|
||||||
failure: EmbeddingsError,
|
failure: EmbeddingsError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Generate text embeddings"
|
description: "Generate text embeddings"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -397,7 +396,7 @@ export const TriplesQueryTool = annotateTool(
|
||||||
parameters: TriplesQueryParameters,
|
parameters: TriplesQueryParameters,
|
||||||
success: TriplesQuerySuccess,
|
success: TriplesQuerySuccess,
|
||||||
failure: TriplesQueryError,
|
failure: TriplesQueryError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Query the knowledge graph for triples matching a pattern"
|
description: "Query the knowledge graph for triples matching a pattern"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -456,7 +455,7 @@ export const GraphEmbeddingsQueryTool = annotateTool(
|
||||||
parameters: GraphEmbeddingsQueryParameters,
|
parameters: GraphEmbeddingsQueryParameters,
|
||||||
success: GraphEmbeddingsQuerySuccess,
|
success: GraphEmbeddingsQuerySuccess,
|
||||||
failure: GraphEmbeddingsQueryError,
|
failure: GraphEmbeddingsQueryError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Find entities similar to a text query using vector embeddings"
|
description: "Find entities similar to a text query using vector embeddings"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -493,7 +492,7 @@ export const GetConfigAllTool = annotateTool(
|
||||||
parameters: GetConfigAllParameters,
|
parameters: GetConfigAllParameters,
|
||||||
success: GetConfigAllSuccess,
|
success: GetConfigAllSuccess,
|
||||||
failure: GetConfigAllError,
|
failure: GetConfigAllError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Get all configuration values"
|
description: "Get all configuration values"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -544,7 +543,7 @@ export const GetConfigTool = annotateTool(
|
||||||
parameters: GetConfigParameters,
|
parameters: GetConfigParameters,
|
||||||
success: GetConfigSuccess,
|
success: GetConfigSuccess,
|
||||||
failure: GetConfigError,
|
failure: GetConfigError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Get specific configuration values"
|
description: "Get specific configuration values"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -597,7 +596,7 @@ export const PutConfigTool = annotateTool(
|
||||||
parameters: PutConfigParameters,
|
parameters: PutConfigParameters,
|
||||||
success: PutConfigSuccess,
|
success: PutConfigSuccess,
|
||||||
failure: PutConfigError,
|
failure: PutConfigError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Set configuration values"
|
description: "Set configuration values"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -641,7 +640,7 @@ export const DeleteConfigTool = annotateTool(
|
||||||
parameters: DeleteConfigParameters,
|
parameters: DeleteConfigParameters,
|
||||||
success: DeleteConfigSuccess,
|
success: DeleteConfigSuccess,
|
||||||
failure: DeleteConfigError,
|
failure: DeleteConfigError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Delete a configuration entry"
|
description: "Delete a configuration entry"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -682,7 +681,7 @@ export const GetFlowTool = annotateTool(
|
||||||
parameters: GetFlowParameters,
|
parameters: GetFlowParameters,
|
||||||
success: GetFlowSuccess,
|
success: GetFlowSuccess,
|
||||||
failure: GetFlowError,
|
failure: GetFlowError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Get a specific flow definition"
|
description: "Get a specific flow definition"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -721,7 +720,7 @@ export const GetFlowsTool = annotateTool(
|
||||||
parameters: GetFlowsParameters,
|
parameters: GetFlowsParameters,
|
||||||
success: GetFlowsSuccess,
|
success: GetFlowsSuccess,
|
||||||
failure: GetFlowsError,
|
failure: GetFlowsError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "List all available flows"
|
description: "List all available flows"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -771,7 +770,7 @@ export const StartFlowTool = annotateTool(
|
||||||
parameters: StartFlowParameters,
|
parameters: StartFlowParameters,
|
||||||
success: StartFlowSuccess,
|
success: StartFlowSuccess,
|
||||||
failure: StartFlowError,
|
failure: StartFlowError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Start a flow instance"
|
description: "Start a flow instance"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -812,7 +811,7 @@ export const StopFlowTool = annotateTool(
|
||||||
parameters: StopFlowParameters,
|
parameters: StopFlowParameters,
|
||||||
success: StopFlowSuccess,
|
success: StopFlowSuccess,
|
||||||
failure: StopFlowError,
|
failure: StopFlowError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Stop a running flow"
|
description: "Stop a running flow"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -851,7 +850,7 @@ export const GetDocumentsTool = annotateTool(
|
||||||
parameters: GetDocumentsParameters,
|
parameters: GetDocumentsParameters,
|
||||||
success: GetDocumentsSuccess,
|
success: GetDocumentsSuccess,
|
||||||
failure: GetDocumentsError,
|
failure: GetDocumentsError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "List all documents in the library"
|
description: "List all documents in the library"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -907,7 +906,7 @@ export const LoadDocumentTool = annotateTool(
|
||||||
parameters: LoadDocumentParameters,
|
parameters: LoadDocumentParameters,
|
||||||
success: LoadDocumentSuccess,
|
success: LoadDocumentSuccess,
|
||||||
failure: LoadDocumentError,
|
failure: LoadDocumentError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Upload a document to the library"
|
description: "Upload a document to the library"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -951,7 +950,7 @@ export const RemoveDocumentTool = annotateTool(
|
||||||
parameters: RemoveDocumentParameters,
|
parameters: RemoveDocumentParameters,
|
||||||
success: RemoveDocumentSuccess,
|
success: RemoveDocumentSuccess,
|
||||||
failure: RemoveDocumentError,
|
failure: RemoveDocumentError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Remove a document from the library"
|
description: "Remove a document from the library"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -990,7 +989,7 @@ export const GetPromptsTool = annotateTool(
|
||||||
parameters: GetPromptsParameters,
|
parameters: GetPromptsParameters,
|
||||||
success: GetPromptsSuccess,
|
success: GetPromptsSuccess,
|
||||||
failure: GetPromptsError,
|
failure: GetPromptsError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "List available prompt templates"
|
description: "List available prompt templates"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -1031,7 +1030,7 @@ export const GetPromptTool = annotateTool(
|
||||||
parameters: GetPromptParameters,
|
parameters: GetPromptParameters,
|
||||||
success: GetPromptSuccess,
|
success: GetPromptSuccess,
|
||||||
failure: GetPromptError,
|
failure: GetPromptError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Get a specific prompt template"
|
description: "Get a specific prompt template"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -1070,7 +1069,7 @@ export const GetKnowledgeCoresTool = annotateTool(
|
||||||
parameters: GetKnowledgeCoresParameters,
|
parameters: GetKnowledgeCoresParameters,
|
||||||
success: GetKnowledgeCoresSuccess,
|
success: GetKnowledgeCoresSuccess,
|
||||||
failure: GetKnowledgeCoresError,
|
failure: GetKnowledgeCoresError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "List available knowledge graph cores"
|
description: "List available knowledge graph cores"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -1114,7 +1113,7 @@ export const DeleteKgCoreTool = annotateTool(
|
||||||
parameters: DeleteKgCoreParameters,
|
parameters: DeleteKgCoreParameters,
|
||||||
success: DeleteKgCoreSuccess,
|
success: DeleteKgCoreSuccess,
|
||||||
failure: DeleteKgCoreError,
|
failure: DeleteKgCoreError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Delete a knowledge graph core"
|
description: "Delete a knowledge graph core"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -1161,7 +1160,7 @@ export const LoadKgCoreTool = annotateTool(
|
||||||
parameters: LoadKgCoreParameters,
|
parameters: LoadKgCoreParameters,
|
||||||
success: LoadKgCoreSuccess,
|
success: LoadKgCoreSuccess,
|
||||||
failure: LoadKgCoreError,
|
failure: LoadKgCoreError,
|
||||||
failureMode: "return",
|
failureMode: "error",
|
||||||
description: "Load a knowledge graph core"
|
description: "Load a knowledge graph core"
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
@ -1207,8 +1206,6 @@ export interface TrustGraphMcpOptions {
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
readonly version?: string | undefined
|
readonly version?: string | undefined
|
||||||
readonly mcpPath?: HttpRouter.PathInput | undefined
|
readonly mcpPath?: HttpRouter.PathInput | undefined
|
||||||
readonly openAiModel?: string | undefined
|
|
||||||
readonly openAiApiKey?: string | undefined
|
|
||||||
readonly port?: number | undefined
|
readonly port?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1220,8 +1217,6 @@ export interface TrustGraphMcpConfigShape {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly version: string
|
readonly version: string
|
||||||
readonly mcpPath: HttpRouter.PathInput
|
readonly mcpPath: HttpRouter.PathInput
|
||||||
readonly openAiModel: string
|
|
||||||
readonly openAiApiKey: Redacted.Redacted | undefined
|
|
||||||
readonly port: number
|
readonly port: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1244,9 +1239,6 @@ export const loadTrustGraphMcpConfig = Effect.fn("loadTrustGraphMcpConfig")(func
|
||||||
const gatewaySecret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option))
|
const gatewaySecret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option))
|
||||||
const token = readNonEmpty(gatewaySecret)
|
const token = readNonEmpty(gatewaySecret)
|
||||||
const flowId = O.getOrUndefined(yield* Config.string("FLOW_ID").pipe(Config.option))
|
const flowId = O.getOrUndefined(yield* Config.string("FLOW_ID").pipe(Config.option))
|
||||||
const openAiModel = O.getOrUndefined(yield* Config.string("OPENAI_MODEL").pipe(Config.option))
|
|
||||||
const openAiApiKey = O.getOrUndefined(yield* Config.redacted("OPENAI_API_KEY").pipe(Config.option))
|
|
||||||
const openAiToken = O.getOrUndefined(yield* Config.redacted("OPENAI_TOKEN").pipe(Config.option))
|
|
||||||
const port = O.getOrUndefined(yield* Config.string("PORT").pipe(Config.option))
|
const port = O.getOrUndefined(yield* Config.string("PORT").pipe(Config.option))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1257,10 +1249,6 @@ export const loadTrustGraphMcpConfig = Effect.fn("loadTrustGraphMcpConfig")(func
|
||||||
name: options.name ?? "trustgraph",
|
name: options.name ?? "trustgraph",
|
||||||
version: options.version ?? "0.1.0",
|
version: options.version ?? "0.1.0",
|
||||||
mcpPath: options.mcpPath ?? "/mcp",
|
mcpPath: options.mcpPath ?? "/mcp",
|
||||||
openAiModel: options.openAiModel ?? openAiModel ?? "gpt-4.1",
|
|
||||||
openAiApiKey: options.openAiApiKey === undefined
|
|
||||||
? openAiApiKey ?? openAiToken
|
|
||||||
: Redacted.make(options.openAiApiKey),
|
|
||||||
port: options.port ?? parsePort(readNonEmpty(port)),
|
port: options.port ?? parsePort(readNonEmpty(port)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1328,46 +1316,19 @@ const decodeJsonArrayOrFail = <E>(
|
||||||
const asIriTerm = (value: string | undefined): ClientTerm | undefined =>
|
const asIriTerm = (value: string | undefined): ClientTerm | undefined =>
|
||||||
value !== undefined && value.length > 0 ? {t: "i", i: value} : undefined
|
value !== undefined && value.length > 0 ? {t: "i", i: value} : undefined
|
||||||
|
|
||||||
const openAiApiKeyOptions = (apiKey: Redacted.Redacted | undefined) =>
|
|
||||||
apiKey === undefined
|
|
||||||
? {}
|
|
||||||
: {apiKey}
|
|
||||||
|
|
||||||
const makeOpenAiProviderLayerFromConfig = (
|
|
||||||
config: TrustGraphMcpConfigShape,
|
|
||||||
) =>
|
|
||||||
OpenAiLanguageModel.layer({
|
|
||||||
model: config.openAiModel,
|
|
||||||
config: {
|
|
||||||
strictJsonSchema: true,
|
|
||||||
},
|
|
||||||
}).pipe(
|
|
||||||
Layer.provide(OpenAiClient.layer(openAiApiKeyOptions(config.openAiApiKey))),
|
|
||||||
Layer.provide(FetchHttpClient.layer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const makeOpenAiProviderLayer = (
|
|
||||||
options: TrustGraphMcpOptions = {},
|
|
||||||
) =>
|
|
||||||
Layer.unwrap(
|
|
||||||
loadTrustGraphMcpConfig(options).pipe(
|
|
||||||
Effect.map(makeOpenAiProviderLayerFromConfig),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
|
export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
|
||||||
Effect.gen(function*() {
|
Effect.gen(function*() {
|
||||||
const config = yield* TrustGraphMcpConfig
|
const config = yield* TrustGraphMcpConfig
|
||||||
const socket = yield* TrustGraphSocket
|
const socket = yield* TrustGraphSocket
|
||||||
const model = yield* LanguageModel.LanguageModel
|
|
||||||
|
|
||||||
return TrustGraphMcpToolkit.of({
|
return TrustGraphMcpToolkit.of({
|
||||||
text_completion: Effect.fn("TrustGraphMcp.text_completion")(function*({system, prompt}) {
|
text_completion: ({system, prompt}) =>
|
||||||
const response = yield* model.generateText({
|
Effect.tryPromise({
|
||||||
prompt: Prompt.make(prompt).pipe(Prompt.setSystem(system)),
|
try: () => socket.flow(config.flowId).textCompletion(system, prompt),
|
||||||
})
|
catch: (cause) => TextCompletionError.make({message: toErrorMessage(cause)}),
|
||||||
return TextCompletionSuccess.make({text: response.text})
|
}).pipe(
|
||||||
}),
|
Effect.map((text) => TextCompletionSuccess.make({text})),
|
||||||
|
),
|
||||||
|
|
||||||
graph_rag: ({query, entity_limit, triple_limit, collection}) =>
|
graph_rag: ({query, entity_limit, triple_limit, collection}) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
|
|
@ -1722,7 +1683,7 @@ export const TrustGraphMcpHttpApiRoutes = HttpApiBuilder.layer(
|
||||||
const makeTrustGraphMcpHttpLayerFromConfig = (
|
const makeTrustGraphMcpHttpLayerFromConfig = (
|
||||||
config: TrustGraphMcpConfigShape,
|
config: TrustGraphMcpConfigShape,
|
||||||
) => {
|
) => {
|
||||||
const tools = makeTrustGraphMcpToolkitLayerFromConfig(config)
|
const tools = makeTrustGraphMcpToolkitLayer()
|
||||||
|
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
TrustGraphMcpHttpApiRoutes,
|
TrustGraphMcpHttpApiRoutes,
|
||||||
|
|
@ -1738,18 +1699,15 @@ const makeTrustGraphMcpHttpLayerFromConfig = (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeTrustGraphMcpToolkitLayerFromConfig = (
|
const makeTrustGraphMcpToolkitLayer = () =>
|
||||||
config: TrustGraphMcpConfigShape,
|
|
||||||
) =>
|
|
||||||
McpServer.toolkit(TrustGraphMcpToolkit).pipe(
|
McpServer.toolkit(TrustGraphMcpToolkit).pipe(
|
||||||
Layer.provide(TrustGraphMcpToolkitLive),
|
Layer.provide(TrustGraphMcpToolkitLive),
|
||||||
Layer.provide(makeOpenAiProviderLayerFromConfig(config)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const makeTrustGraphMcpStdioLayerFromConfig = (
|
const makeTrustGraphMcpStdioLayerFromConfig = (
|
||||||
config: TrustGraphMcpConfigShape,
|
config: TrustGraphMcpConfigShape,
|
||||||
) =>
|
) =>
|
||||||
makeTrustGraphMcpToolkitLayerFromConfig(config).pipe(
|
makeTrustGraphMcpToolkitLayer().pipe(
|
||||||
Layer.provide(McpServer.layerStdio({
|
Layer.provide(McpServer.layerStdio({
|
||||||
name: config.name,
|
name: config.name,
|
||||||
version: config.version,
|
version: config.version,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue