Migrate strict Effect runtime surfaces

This commit is contained in:
elpresidank 2026-06-02 00:22:04 -05:00
parent f6878d4dd7
commit b4ee2b691f
35 changed files with 1717 additions and 1410 deletions

View file

@ -115,11 +115,10 @@ const makeNativeRecordingProcessor = (
events.push(`pubsub:${pubsub.backend.constructor.name}`);
}),
});
const stopEffect = processor.stopEffect;
processor.stopEffect = () => {
processor.onShutdown(() => {
events.push("native-stop");
return stopEffect();
};
return Promise.resolve();
});
return processor;
};

View file

@ -19,6 +19,8 @@ import {
AckPolicy,
DeliverPolicy,
} from "nats";
import { Effect } from "effect";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import type {
@ -29,6 +31,7 @@ import type {
CreateConsumerOptions,
Message,
} from "./types.js";
import { pubSubError } from "../errors.js";
const sc = StringCodec();
@ -57,36 +60,61 @@ function makeNatsMessage<T>(msg: JsMsg, decoded: T): NatsMessage<T> {
};
}
const hasJsMsg = Predicate.hasProperty("_jsMsg");
function isAckableJsMsg(value: unknown): value is Pick<JsMsg, "ack" | "nak"> {
if (!Predicate.isObject(value)) return false;
if (!Predicate.hasProperty(value, "ack")) return false;
if (!Predicate.hasProperty(value, "nak")) return false;
return typeof value.ack === "function" && typeof value.nak === "function";
}
function isNatsMessage<T>(message: Message<T>): message is NatsMessage<T> {
return hasJsMsg(message) && isAckableJsMsg(message._jsMsg);
}
function makeNatsProducer<T>(
js: JetStreamClient,
subject: string,
schema?: S.Top,
schema?: S.Codec<T, unknown>,
): BackendProducer<T> {
return {
send: async (message, properties) => {
const encoded = schema !== undefined
? S.encodeUnknownSync(schema as S.Codec<unknown, unknown>)(message)
: message;
const data = sc.encode(JSON.stringify(encoded));
const opts: Record<string, unknown> = {};
send: (message, properties) =>
Effect.runPromise(
Effect.gen(function* () {
const encoded = schema !== undefined
? yield* S.encodeUnknownEffect(schema)(message).pipe(
Effect.mapError((error) => pubSubError(`encode:${subject}`, error)),
)
: message;
const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(encoded).pipe(
Effect.mapError((error) => pubSubError(`encode-json:${subject}`, error)),
);
const data = sc.encode(json);
const opts: Record<string, unknown> = {};
if (properties !== undefined && Object.keys(properties).length > 0) {
const { headers } = await import("nats");
const hdrs = headers();
for (const [key, val] of Object.entries(properties)) {
hdrs.append(key, val);
}
opts.headers = hdrs;
}
if (properties !== undefined && Object.keys(properties).length > 0) {
const { headers } = yield* Effect.tryPromise({
try: () => import("nats"),
catch: (error) => pubSubError("import:nats-headers", error),
});
const hdrs = headers();
for (const [key, val] of Object.entries(properties)) {
hdrs.append(key, val);
}
opts.headers = hdrs;
}
await js.publish(subject, data, opts);
},
flush: async () => {
// NATS publishes are flushed on the connection level.
},
close: async () => {
// No per-producer cleanup needed for NATS.
},
yield* Effect.tryPromise({
try: () => js.publish(subject, data, opts),
catch: (error) => pubSubError(`publish:${subject}`, error),
});
}),
),
// NATS publishes are flushed on the connection level.
flush: () => Promise.resolve(),
// No per-producer cleanup needed for NATS.
close: () => Promise.resolve(),
};
}
@ -101,60 +129,109 @@ function makeNatsConsumer<T>(
subscription: string,
initialPosition: "latest" | "earliest",
streamName: string,
schema?: S.Top,
schema?: S.Codec<T, unknown>,
): InitializableBackendConsumer<T> {
let consumer: NatsJsConsumer | null = null;
return {
init: async () => {
// Stream is already ensured by makeNatsBackend(). Create or bind to a durable consumer.
try {
consumer = await js.consumers.get(streamName, subscription);
} catch {
const deliverPolicy =
initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
init: () =>
Effect.runPromise(
Effect.gen(function* () {
const existing = yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
}).pipe(
Effect.catch(() =>
Effect.gen(function* () {
const deliverPolicy =
initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
await jsm.consumers.add(streamName, {
durable_name: subscription,
ack_policy: AckPolicy.Explicit,
deliver_policy: deliverPolicy,
filter_subject: subject,
});
yield* Effect.tryPromise({
try: () =>
jsm.consumers.add(streamName, {
durable_name: subscription,
ack_policy: AckPolicy.Explicit,
deliver_policy: deliverPolicy,
filter_subject: subject,
}),
catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error),
});
consumer = await js.consumers.get(streamName, subscription);
}
},
receive: async (timeoutMs = 2000) => {
if (consumer === null) throw new Error("Consumer not initialized");
return yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
});
}),
),
);
consumer = existing;
}),
),
receive: (timeoutMs = 2000) =>
Effect.runPromise(
Effect.gen(function* () {
const current = consumer;
if (current === null) {
return yield* pubSubError("receive", "Consumer not initialized");
}
// Pull a single message with a timeout using the pull-based API.
// consumer.next() returns a JsMsg or null when the timeout expires.
const msg = await consumer.next({ expires: timeoutMs });
if (msg === null) return null;
// Pull a single message with a timeout using the pull-based API.
// consumer.next() returns a JsMsg or null when the timeout expires.
const msg = yield* Effect.tryPromise({
try: () => current.next({ expires: timeoutMs }),
catch: (error) => pubSubError(`receive:${subject}`, error),
});
if (msg === null) return null;
const parsed = JSON.parse(sc.decode(msg.data));
const decoded = schema !== undefined
? S.decodeUnknownSync(schema as S.Codec<unknown, unknown>)(parsed) as T
: parsed as T;
return makeNatsMessage(msg, decoded);
},
acknowledge: async (message) => {
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.ack();
},
negativeAcknowledge: async (message) => {
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.nak();
},
unsubscribe: async () => {
const parsed = yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(sc.decode(msg.data)).pipe(
Effect.mapError((error) => pubSubError(`decode-json:${subject}`, error)),
);
const decoded = schema !== undefined
? yield* S.decodeUnknownEffect(schema)(parsed).pipe(
Effect.mapError((error) => pubSubError(`decode-schema:${subject}`, error)),
)
: yield* S.decodeUnknownEffect(S.Any)(parsed).pipe(
Effect.mapError((error) => pubSubError(`decode-any:${subject}`, error)),
);
return makeNatsMessage(msg, decoded);
}),
),
acknowledge: (message) =>
Effect.runPromise(
Effect.gen(function* () {
if (!isNatsMessage(message)) {
return yield* pubSubError(`acknowledge:${subject}`, "Message was not produced by NATS backend");
}
yield* Effect.sync(() => {
message._jsMsg.ack();
});
}),
),
negativeAcknowledge: (message) =>
Effect.runPromise(
Effect.gen(function* () {
if (!isNatsMessage(message)) {
return yield* pubSubError(
`negative-acknowledge:${subject}`,
"Message was not produced by NATS backend",
);
}
yield* Effect.sync(() => {
message._jsMsg.nak();
});
}),
),
unsubscribe: () => {
// The pull-based consumer does not have a persistent subscription to drain.
// Clearing the reference is sufficient; the durable consumer persists server-side.
consumer = null;
return Promise.resolve();
},
close: async () => {
close: () => {
consumer = null;
return Promise.resolve();
},
};
}
@ -165,19 +242,26 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
let jsm: JetStreamManager | null = null;
const initializedStreams = new Set<string>();
const ensureConnected = async (): Promise<void> => {
const ensureConnected = Effect.fn("NatsBackend.ensureConnected")(function* () {
if (connection === null) {
connection = await connect({ servers: url });
js = connection.jetstream();
jsm = await connection.jetstreamManager();
const conn = yield* Effect.tryPromise({
try: () => connect({ servers: url }),
catch: (error) => pubSubError("connect", error),
});
connection = conn;
js = conn.jetstream();
jsm = yield* Effect.tryPromise({
try: () => conn.jetstreamManager(),
catch: (error) => pubSubError("jetstream-manager", error),
});
}
};
});
/**
* Ensure the stream for a given subject exists with a wildcard filter.
* E.g. subject "tg.flow.config-request" stream "tg_flow" with subjects ["tg.flow.>"]
*/
const ensureStream = async (subject: string): Promise<string> => {
const ensureStream = Effect.fn("NatsBackend.ensureStream")(function* (subject: string) {
const parts = subject.split(".");
const streamName = parts.slice(0, 2).join("_");
@ -186,53 +270,78 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
const manager = jsm;
if (manager === null) throw new Error("NATS backend not connected");
if (manager === null) return yield* pubSubError("ensure-stream", "NATS backend not connected");
try {
await manager.streams.info(streamName);
} catch {
await manager.streams.add({
name: streamName,
subjects: [wildcardSubject],
});
}
yield* Effect.tryPromise({
try: () => manager.streams.info(streamName),
catch: (error) => pubSubError(`stream-info:${streamName}`, error),
}).pipe(
Effect.catch(() =>
Effect.tryPromise({
try: () =>
manager.streams.add({
name: streamName,
subjects: [wildcardSubject],
}),
catch: (error) => pubSubError(`stream-add:${streamName}`, error),
}),
),
);
initializedStreams.add(streamName);
return streamName;
};
});
return {
createProducer: async <T>(options: CreateProducerOptions) => {
await ensureConnected();
await ensureStream(options.topic);
const client = js;
if (client === null) throw new Error("NATS backend not connected");
return makeNatsProducer<T>(client, options.topic, options.schema);
},
createConsumer: async <T>(options: CreateConsumerOptions) => {
await ensureConnected();
const streamName = await ensureStream(options.topic);
const client = js;
const manager = jsm;
if (client === null || manager === null) throw new Error("NATS backend not connected");
const consumer = makeNatsConsumer<T>(
client,
manager,
options.topic,
options.subscription,
options.initialPosition ?? "latest",
streamName,
options.schema,
);
await consumer.init();
return consumer;
},
close: async () => {
if (connection !== null) {
await connection.drain();
connection = null;
js = null;
jsm = null;
}
},
createProducer: <T>(options: CreateProducerOptions<T>) =>
Effect.runPromise(
Effect.gen(function* () {
yield* ensureConnected();
yield* ensureStream(options.topic);
const client = js;
if (client === null) return yield* pubSubError("create-producer", "NATS backend not connected");
return makeNatsProducer<T>(client, options.topic, options.schema);
}),
),
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
Effect.runPromise(
Effect.gen(function* () {
yield* ensureConnected();
const streamName = yield* ensureStream(options.topic);
const client = js;
const manager = jsm;
if (client === null || manager === null) {
return yield* pubSubError("create-consumer", "NATS backend not connected");
}
const consumer = makeNatsConsumer<T>(
client,
manager,
options.topic,
options.subscription,
options.initialPosition ?? "latest",
streamName,
options.schema,
);
yield* Effect.tryPromise({
try: () => consumer.init(),
catch: (error) => pubSubError(`init-consumer:${options.topic}`, error),
});
return consumer;
}),
),
close: () =>
Effect.runPromise(
Effect.gen(function* () {
const conn = connection;
if (conn !== null) {
yield* Effect.tryPromise({
try: () => conn.drain(),
catch: (error) => pubSubError("close", error),
});
connection = null;
js = null;
jsm = null;
}
}),
),
};
}

View file

@ -20,10 +20,10 @@ import { pubSubError } from "../errors.js";
export interface PubSubService {
readonly backend: PubSubBackend;
readonly createProducer: <T>(
options: CreateProducerOptions,
options: CreateProducerOptions<T>,
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
readonly createConsumer: <T>(
options: CreateConsumerOptions,
options: CreateConsumerOptions<T>,
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
}
@ -41,12 +41,12 @@ export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgrap
export function makePubSubService(backend: PubSubBackend): PubSubService {
return {
backend,
createProducer: <T>(options: CreateProducerOptions) =>
createProducer: <T>(options: CreateProducerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createProducer<T>(options),
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
}),
createConsumer: <T>(options: CreateConsumerOptions) =>
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createConsumer<T>(options),
catch: (error) => pubSubError(`createConsumer:${options.topic}`, error),

View file

@ -29,21 +29,21 @@ export interface BackendConsumer<T = unknown> {
export type ConsumerType = "shared" | "exclusive" | "failover";
export type InitialPosition = "latest" | "earliest";
export interface CreateProducerOptions {
export interface CreateProducerOptions<T = unknown> {
topic: string;
schema?: S.Top;
schema?: S.Codec<T, unknown>;
}
export interface CreateConsumerOptions {
export interface CreateConsumerOptions<T = unknown> {
topic: string;
subscription: string;
initialPosition?: InitialPosition;
consumerType?: ConsumerType;
schema?: S.Top;
schema?: S.Codec<T, unknown>;
}
export interface PubSubBackend {
createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>>;
createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>>;
close(): Promise<void>;
}

View file

@ -168,11 +168,11 @@ export type MessagingRuntimeError =
| FlowResourceNotFoundError;
export function tooManyRequestsError(message = "Rate limit exceeded"): TooManyRequestsError {
return new TooManyRequestsError({ message });
return TooManyRequestsError.make({ message });
}
export function llmError(message: string, errorType = "llm-error"): LlmError {
return new LlmError({ message, errorType });
return LlmError.make({ message, errorType });
}
export function embeddingsError(
@ -180,7 +180,7 @@ export function embeddingsError(
error: unknown,
provider?: string,
): EmbeddingsError {
return new EmbeddingsError({
return EmbeddingsError.make({
operation,
message: errorMessage(error),
...(provider === undefined ? {} : { provider }),
@ -188,11 +188,11 @@ export function embeddingsError(
}
export function parseError(message: string): ParseError {
return new ParseError({ message });
return ParseError.make({ message });
}
export function pubSubError(operation: string, error: unknown): PubSubError {
return new PubSubError({ operation, message: errorMessage(error) });
return PubSubError.make({ operation, message: errorMessage(error) });
}
export function processorLifecycleError(
@ -200,7 +200,7 @@ export function processorLifecycleError(
operation: string,
error: unknown,
): ProcessorLifecycleError {
return new ProcessorLifecycleError({
return ProcessorLifecycleError.make({
processorId,
operation,
message: errorMessage(error),
@ -212,7 +212,7 @@ export function messagingLifecycleError(
operation: string,
error: unknown,
): MessagingLifecycleError {
return new MessagingLifecycleError({
return MessagingLifecycleError.make({
resource,
operation,
message: errorMessage(error),
@ -224,7 +224,7 @@ export function messagingDeliveryError(
operation: string,
error: unknown,
): MessagingDeliveryError {
return new MessagingDeliveryError({
return MessagingDeliveryError.make({
topic,
operation,
message: errorMessage(error),
@ -236,7 +236,7 @@ export function messagingDecodeError(
error: unknown,
topic?: string,
): MessagingDecodeError {
return new MessagingDecodeError({
return MessagingDecodeError.make({
operation,
message: errorMessage(error),
...(topic === undefined ? {} : { topic }),
@ -247,7 +247,7 @@ export function messagingTimeoutError(
operation: string,
timeoutMs: number,
): MessagingTimeoutError {
return new MessagingTimeoutError({
return MessagingTimeoutError.make({
operation,
timeoutMs,
message: `${operation} timed out after ${timeoutMs}ms`,
@ -259,7 +259,7 @@ export function messagingHandlerError(
subscription: string,
error: unknown,
): MessagingHandlerError {
return new MessagingHandlerError({
return MessagingHandlerError.make({
topic,
subscription,
message: errorMessage(error),
@ -271,7 +271,7 @@ export function flowRuntimeError(
operation: string,
error: unknown,
): FlowRuntimeError {
return new FlowRuntimeError({
return FlowRuntimeError.make({
flowName,
operation,
message: errorMessage(error),
@ -283,7 +283,7 @@ export function flowResourceNotFoundError(
resourceType: FlowResourceNotFoundError["resourceType"],
resourceName: string,
): FlowResourceNotFoundError {
return new FlowResourceNotFoundError({
return FlowResourceNotFoundError.make({
flowName,
resourceType,
resourceName,

View file

@ -4,9 +4,16 @@
* Python reference: trustgraph-base/trustgraph/base/consumer.py
*/
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
import type { BackendConsumer, Message, PubSubBackend } from "../backend/types.js";
import type { Flow } from "../processor/flow.js";
import { TooManyRequestsError } from "../errors.js";
import {
MessagingHandlerError,
TooManyRequestsError,
messagingDeliveryError,
messagingHandlerError,
messagingLifecycleError,
} from "../errors.js";
import { Duration, Effect } from "effect";
import * as S from "effect/Schema";
export type MessageHandler<T> = (
@ -44,83 +51,140 @@ export interface Consumer<T> {
export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
let backend: BackendConsumer<T> | null = null;
let running = false;
let abortController = new AbortController();
const isTooManyRequestsError = S.is(TooManyRequestsError);
const concurrency = options.concurrency ?? 1;
const rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
const handleWithRetry = async (msg: Message<T>, flow: FlowContext): Promise<void> => {
try {
await options.handler(msg.value(), msg.properties(), flow);
} catch (err) {
if (S.is(TooManyRequestsError)(err)) {
console.warn(`[Consumer] Rate limited, retrying in ${rateLimitRetryMs}ms`);
await sleep(rateLimitRetryMs);
await options.handler(msg.value(), msg.properties(), flow);
} else {
throw err;
}
const runHandler = (
message: T,
properties: Record<string, string>,
flow: FlowContext,
): Effect.Effect<void, TooManyRequestsError | MessagingHandlerError> =>
Effect.tryPromise({
try: () => options.handler(message, properties, flow),
catch: (error) =>
isTooManyRequestsError(error)
? error
: messagingHandlerError(options.topic, options.subscription, error),
});
const handleWithRetry = Effect.fn("Consumer.handleWithRetry")(function* (
message: Message<T>,
flow: FlowContext,
) {
const callHandler = runHandler(message.value(), message.properties(), flow);
yield* callHandler.pipe(
Effect.catchTag("TooManyRequestsError", () =>
Effect.logWarning("[Consumer] Rate limited, retrying", {
topic: options.topic,
subscription: options.subscription,
retryMs: rateLimitRetryMs,
}).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(rateLimitRetryMs))),
Effect.flatMap(() => callHandler),
),
),
);
});
const consumeOnce = Effect.fn("Consumer.consumeOnce")(function* (flow: FlowContext) {
const currentBackend = backend;
if (currentBackend === null) {
return yield* messagingLifecycleError(
`${options.topic}:${options.subscription}`,
"receive",
"Consumer backend not started",
);
}
};
const consumeLoop = async (flow: FlowContext): Promise<void> => {
while (running) {
let msg: Message<T> | null = null;
try {
const currentBackend = backend;
if (currentBackend === null) throw new Error("Consumer backend not started");
const message = yield* Effect.tryPromise({
try: () => currentBackend.receive(2000),
catch: (error) => messagingDeliveryError(options.topic, "receive", error),
});
if (message === null) return;
msg = await currentBackend.receive(2000);
if (msg === null) continue;
yield* handleWithRetry(message, flow).pipe(
Effect.flatMap(() =>
Effect.tryPromise({
try: () => currentBackend.acknowledge(message),
catch: (error) => messagingDeliveryError(options.topic, "acknowledge", error),
}),
),
Effect.catch((error) =>
Effect.tryPromise({
try: () => currentBackend.negativeAcknowledge(message),
catch: (nakError) => messagingDeliveryError(options.topic, "negative-acknowledge", nakError),
}).pipe(
Effect.catch((nakError) =>
Effect.logError("[Consumer] Failed to negative-acknowledge message", {
error: nakError.message,
topic: nakError.topic,
}),
),
Effect.flatMap(() => Effect.fail(error)),
),
),
);
});
await handleWithRetry(msg, flow);
await currentBackend.acknowledge(msg);
} catch (err) {
if (!running) break;
console.error("[Consumer] Error in consume loop:", err);
if (msg !== null) {
try {
const currentBackend = backend;
if (currentBackend !== null) {
await currentBackend.negativeAcknowledge(msg);
}
} catch (nakErr) {
console.error("[Consumer] Failed to nak message:", nakErr);
}
}
await sleep(1000);
}
}
};
const consumeLoop = Effect.fn("Consumer.consumeLoop")(function* (flow: FlowContext) {
yield* Effect.whileLoop({
while: () => running,
body: () =>
consumeOnce(flow).pipe(
Effect.catch((error) => {
if (!running) return Effect.void;
return Effect.logError("[Consumer] Error in consume loop", {
error: error.message,
topic: options.topic,
subscription: options.subscription,
}).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
);
}),
),
step: () => undefined,
});
});
return {
start: async (flow) => {
backend = await options.pubsub.createConsumer<T>({
topic: options.topic,
subscription: options.subscription,
initialPosition: options.initialPosition ?? "latest",
});
start: (flow) =>
Effect.runPromise(
Effect.gen(function* () {
backend = yield* Effect.tryPromise({
try: () =>
options.pubsub.createConsumer<T>({
topic: options.topic,
subscription: options.subscription,
initialPosition: options.initialPosition ?? "latest",
}),
catch: (error) =>
messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error),
});
running = true;
running = true;
// Spawn concurrent consumer tasks.
const tasks = Array.from({ length: concurrency }, () =>
consumeLoop(flow),
);
// Run all concurrently: first rejection stops all.
await Promise.all(tasks);
},
stop: async () => {
running = false;
abortController.abort();
abortController = new AbortController();
if (backend !== null) {
await backend.close();
backend = null;
}
},
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
yield* Effect.forEach(workerIndexes, () => consumeLoop(flow), {
concurrency: "unbounded",
discard: true,
});
}),
),
stop: () =>
Effect.runPromise(
Effect.gen(function* () {
running = false;
const currentBackend = backend;
backend = null;
if (currentBackend !== null) {
yield* Effect.tryPromise({
try: () => currentBackend.close(),
catch: (error) =>
messagingLifecycleError(`${options.topic}:${options.subscription}`, "close-consumer", error),
});
}
}),
),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -8,6 +8,7 @@ import type { PubSubBackend } from "../backend/types.js";
import type { ProducerMetrics } from "../metrics/prometheus.js";
import { Effect } from "effect";
import { makeEffectProducerHandle, type EffectProducer } from "./runtime.js";
import { messagingLifecycleError } from "../errors.js";
export interface Producer<T> {
readonly start: () => Promise<void>;
@ -23,28 +24,38 @@ export function makeProducer<T>(
let effectProducer: EffectProducer<T> | null = null;
return {
start: async () => {
const backend = await pubsub.createProducer<T>({ topic });
effectProducer = makeEffectProducerHandle(backend, {
topic,
...(metrics === undefined ? {} : { metrics }),
});
},
send: async (id, message) => {
if (effectProducer === null) throw new Error("Producer not started");
await Effect.runPromise(effectProducer.send(id, message));
},
stop: async () => {
if (effectProducer !== null) {
const producer = effectProducer;
await Effect.runPromise(
producer.flush.pipe(
Effect.flatMap(() => producer.close),
),
);
effectProducer = null;
}
},
start: () =>
Effect.runPromise(
Effect.gen(function* () {
const backend = yield* Effect.tryPromise({
try: () => pubsub.createProducer<T>({ topic }),
catch: (error) => messagingLifecycleError(topic, "create-producer", error),
});
effectProducer = makeEffectProducerHandle(backend, {
topic,
...(metrics === undefined ? {} : { metrics }),
});
}),
),
send: (id, message) =>
effectProducer === null
? Effect.runPromise(Effect.fail(messagingLifecycleError(topic, "send", "Producer not started")))
: Effect.runPromise(effectProducer.send(id, message)),
stop: () =>
Effect.runPromise(
Effect.gen(function* () {
if (effectProducer !== null) {
const producer = effectProducer;
yield* producer.flush.pipe(
Effect.flatMap(() => producer.close),
Effect.ensuring(
Effect.sync(() => {
effectProducer = null;
}),
),
);
}
}),
),
};
}

View file

@ -44,37 +44,42 @@ export function makeRequestResponse<TReq, TRes>(
let runtime: RequestResponseRuntime<TReq, TRes> | null = null;
return {
start: async () => {
if (runtime !== null) return;
start: () =>
runtime !== null
? Promise.resolve()
: Effect.runPromise(
Effect.gen(function* () {
const scope = yield* Scope.make();
const startRuntime = Effect.gen(function* () {
const config = yield* loadMessagingRuntimeConfig();
const requestor = yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(
PubSub.fromBackend(options.pubsub),
config,
{
requestTopic: options.requestTopic,
responseTopic: options.responseTopic,
subscription: options.subscription,
},
).pipe(Scope.provide(scope));
const scope = await Effect.runPromise(Scope.make());
runtime = { scope, requestor };
});
try {
const config = await Effect.runPromise(loadMessagingRuntimeConfig());
const requestor = await Effect.runPromise(
makeEffectRequestResponseFromPubSub<TReq, TRes>(
PubSub.fromBackend(options.pubsub),
config,
{
requestTopic: options.requestTopic,
responseTopic: options.responseTopic,
subscription: options.subscription,
},
).pipe(Scope.provide(scope)),
);
runtime = { scope, requestor };
} catch (error) {
await Effect.runPromise(Scope.close(scope, Exit.fail(error))).catch(() => undefined);
throw error;
}
},
stop: async () => {
yield* startRuntime.pipe(
Effect.catch((error) =>
Scope.close(scope, Exit.fail(error)).pipe(
Effect.flatMap(() => Effect.fail(error)),
),
),
);
}),
),
stop: () => {
const current = runtime;
runtime = null;
if (current === null) return;
await Effect.runPromise(Scope.close(current.scope, Exit.void));
return current === null
? Promise.resolve()
: Effect.runPromise(Scope.close(current.scope, Exit.void));
},
/**
* Send a request and wait for responses.
@ -85,20 +90,24 @@ export function makeRequestResponse<TReq, TRes>(
* Return `true` to indicate the final response has been received.
* If omitted, returns the first response.
*/
request: async (request, requestOptions) => {
request: (request, requestOptions) => {
const current = runtime;
if (current === null) {
throw messagingLifecycleError(
`${options.requestTopic}:${options.responseTopic}`,
"request",
"RequestResponse not started",
return Effect.runPromise(
Effect.fail(
messagingLifecycleError(
`${options.requestTopic}:${options.responseTopic}`,
"request",
"RequestResponse not started",
),
),
);
}
const timeoutMs = requestOptions?.timeoutMs ?? 300_000;
const recipient = requestOptions?.recipient;
return await Effect.runPromise(
return Effect.runPromise(
current.requestor.request(request, {
timeoutMs,
...(recipient === undefined

View file

@ -44,9 +44,9 @@ export type EffectMessageHandler<T, E = never, R = never> = (
flow: FlowContext<R>,
) => Effect.Effect<void, E, R>;
export interface EffectProducerOptions {
export interface EffectProducerOptions<T = unknown> {
readonly topic: string;
readonly schema?: S.Top;
readonly schema?: S.Codec<T, unknown>;
readonly metrics?: ProducerMetrics;
}
@ -62,7 +62,7 @@ export interface EffectConsumerOptions<T, E = never, R = never> {
readonly handler: EffectMessageHandler<T, E, R>;
readonly concurrency?: number;
readonly initialPosition?: "latest" | "earliest";
readonly schema?: S.Top;
readonly schema?: S.Codec<T, unknown>;
readonly receiveTimeoutMs?: number;
readonly errorBackoffMs?: number;
readonly rateLimitRetryMs?: number;
@ -73,12 +73,12 @@ export interface EffectConsumer {
readonly fibers: ReadonlyArray<Fiber.Fiber<void, never>>;
}
export interface EffectRequestResponseOptions {
export interface EffectRequestResponseOptions<TReq = unknown, TRes = unknown> {
readonly requestTopic: string;
readonly responseTopic: string;
readonly subscription: string;
readonly requestSchema?: S.Top;
readonly responseSchema?: S.Top;
readonly requestSchema?: S.Codec<TReq, unknown>;
readonly responseSchema?: S.Codec<TRes, unknown>;
}
export interface EffectRequestOptions<TRes, E = never, R = never> {
@ -96,7 +96,7 @@ export interface EffectRequestResponse<TReq, TRes> {
export interface ProducerFactoryService {
readonly make: <T>(
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
) => Effect.Effect<EffectProducer<T>, PubSubError, Scope.Scope>;
}
@ -109,7 +109,7 @@ export interface ConsumerFactoryService {
export interface RequestResponseFactoryService {
readonly make: <TReq, TRes>(
options: EffectRequestResponseOptions,
options: EffectRequestResponseOptions<TReq, TRes>,
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, PubSubError, Scope.Scope>;
}
@ -138,7 +138,7 @@ export class FlowRuntime extends Context.Service<FlowRuntime, FlowRuntimeService
export function makeEffectProducerHandle<T>(
backend: BackendProducer<T>,
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
): EffectProducer<T> {
return {
send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) =>
@ -168,9 +168,9 @@ export function makeEffectProducerHandle<T>(
export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* <T>(
pubsub: PubSubService,
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
) {
const createOptions: CreateProducerOptions = options.schema === undefined
const createOptions: CreateProducerOptions<T> = options.schema === undefined
? { topic: options.topic }
: { topic: options.topic, schema: options.schema };
const backend = yield* pubsub.createProducer<T>(createOptions);
@ -326,7 +326,7 @@ export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPub
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) {
const createOptions: CreateConsumerOptions = {
const createOptions: CreateConsumerOptions<T> = {
topic: options.topic,
subscription: options.subscription,
...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }),
@ -422,9 +422,9 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR
>(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
options: EffectRequestResponseOptions,
options: EffectRequestResponseOptions<TReq, TRes>,
) {
const producerOptions: CreateProducerOptions = options.requestSchema === undefined
const producerOptions: CreateProducerOptions<TReq> = options.requestSchema === undefined
? { topic: options.requestTopic }
: { topic: options.requestTopic, schema: options.requestSchema };
const producerBackend = yield* pubsub.createProducer<TReq>(producerOptions);
@ -432,7 +432,7 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR
topic: options.requestTopic,
...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }),
});
const createOptions: CreateConsumerOptions = {
const createOptions: CreateConsumerOptions<TRes> = {
topic: options.responseTopic,
subscription: options.subscription,
...(options.responseSchema === undefined ? {} : { schema: options.responseSchema }),
@ -502,7 +502,7 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR
export function makeProducerFactoryService(pubsub: PubSubService): ProducerFactoryService {
return {
make: Effect.fn("ProducerFactory.make")(<T>(options: EffectProducerOptions) =>
make: Effect.fn("ProducerFactory.make")(<T>(options: EffectProducerOptions<T>) =>
makeEffectProducerFromPubSub<T>(pubsub, options),
),
};
@ -526,13 +526,11 @@ export function makeRequestResponseFactoryService(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
): RequestResponseFactoryService {
const make = Effect.fn("RequestResponseFactory.make")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
) {
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
}) as RequestResponseFactoryService["make"];
return { make };
return {
make: Effect.fn("RequestResponseFactory.make")(<TReq, TRes>(
options: EffectRequestResponseOptions<TReq, TRes>,
) => makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options)),
};
}
export const ProducerFactoryLive = Layer.effect(
@ -589,7 +587,7 @@ export const MessagingRuntimeLive = Layer.mergeAll(
);
export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* <T>(
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
) {
const pubsub = yield* PubSub;
return yield* makeEffectProducerFromPubSub<T>(pubsub, options);
@ -605,7 +603,7 @@ export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(func
});
export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
options: EffectRequestResponseOptions<TReq, TRes>,
) {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();

View file

@ -5,6 +5,8 @@
*/
import type { PubSubBackend, BackendConsumer } from "../backend/types.js";
import { Duration, Effect, Fiber } from "effect";
import { messagingDeliveryError, messagingLifecycleError, messagingTimeoutError } from "../errors.js";
type Resolver<T> = {
queue: AsyncQueue<T>;
@ -32,28 +34,33 @@ export function makeAsyncQueue<T>(): AsyncQueue<T> {
buffer.push(item);
}
},
pop: async (timeoutMs) => {
pop: (timeoutMs) => {
const buffered = buffer.shift();
if (buffered !== undefined) return buffered;
return new Promise<T>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
if (buffered !== undefined) return Promise.resolve(buffered);
const take = Effect.callback<T>((resume) => {
const waiter = (value: T) => {
if (timer !== undefined) clearTimeout(timer);
resolve(value);
resume(Effect.succeed(value));
};
waiters.push(waiter);
if (timeoutMs !== undefined) {
timer = setTimeout(() => {
const idx = waiters.indexOf(waiter);
if (idx !== -1) waiters.splice(idx, 1);
reject(new Error(`Queue.pop timed out after ${timeoutMs}ms`));
}, timeoutMs);
}
return Effect.sync(() => {
const idx = waiters.indexOf(waiter);
if (idx !== -1) waiters.splice(idx, 1);
});
});
return Effect.runPromise(
timeoutMs === undefined
? take
: take.pipe(
Effect.timeout(Duration.millis(timeoutMs)),
Effect.catchTag("TimeoutError", () =>
Effect.fail(messagingTimeoutError("queue.pop", timeoutMs)),
),
),
);
},
get length() {
return buffer.length;
@ -77,76 +84,113 @@ export function makeSubscriber<T>(
): Subscriber<T> {
let backend: BackendConsumer<T> | null = null;
let running = false;
let fiber: Fiber.Fiber<void, never> | null = null;
// ID-specific subscriptions (request/response correlation)
const idSubscribers = new Map<string, Resolver<T>>();
// Wildcard subscribers (receive all messages)
const allSubscribers = new Map<string, Resolver<T>>();
const dispatchLoop = async (): Promise<void> => {
const dispatchLoop = Effect.fn("Subscriber.dispatchLoop")(function* () {
let consecutiveErrors = 0;
while (running) {
try {
const currentBackend = backend;
if (currentBackend === null) throw new Error("Subscriber backend not started");
const dispatchOnce = Effect.fn("Subscriber.dispatchOnce")(function* () {
const currentBackend = backend;
if (currentBackend === null) {
return yield* messagingLifecycleError(
`${topic}:${subscription}`,
"dispatch",
"Subscriber backend not started",
);
}
const msg = await currentBackend.receive(2000);
if (msg === null) continue;
const msg = yield* Effect.tryPromise({
try: () => currentBackend.receive(2000),
catch: (error) => messagingDeliveryError(topic, "receive", error),
});
if (msg === null) return;
consecutiveErrors = 0;
consecutiveErrors = 0;
const props = msg.properties();
const id = props.id;
const value = msg.value();
const props = msg.properties();
const id = props.id;
const value = msg.value();
// Route to ID-specific subscriber
if (id !== undefined && id.length > 0) {
const sub = idSubscribers.get(id);
if (sub !== undefined) {
// Route to ID-specific subscriber
if (id !== undefined && id.length > 0) {
const sub = idSubscribers.get(id);
if (sub !== undefined) {
sub.queue.push(value);
}
}
// Broadcast to all-subscribers
for (const sub of allSubscribers.values()) {
sub.queue.push(value);
}
}
// Broadcast to all-subscribers
for (const sub of allSubscribers.values()) {
sub.queue.push(value);
}
yield* Effect.tryPromise({
try: () => currentBackend.acknowledge(msg),
catch: (error) => messagingDeliveryError(topic, "acknowledge", error),
});
});
await currentBackend.acknowledge(msg);
} catch (err) {
if (!running) break;
consecutiveErrors++;
if (consecutiveErrors <= 3) {
console.error("[Subscriber] Error:", err);
} else if (consecutiveErrors === 4) {
console.error("[Subscriber] Suppressing further errors (will retry with backoff)");
}
// Exponential backoff: 1s, 2s, 4s, max 10s
const delay = Math.min(1000 * Math.pow(2, consecutiveErrors - 1), 10_000);
await new Promise((r) => setTimeout(r, delay));
}
}
};
yield* Effect.whileLoop({
while: () => running,
body: () =>
dispatchOnce().pipe(
Effect.catch((error) => {
if (!running) return Effect.void;
consecutiveErrors++;
const logEffect = consecutiveErrors <= 3
? Effect.logError("[Subscriber] Error", { error })
: consecutiveErrors === 4
? Effect.logError("[Subscriber] Suppressing further errors (will retry with backoff)", { error })
: Effect.void;
const delay = Math.min(1000 * 2 ** (consecutiveErrors - 1), 10_000);
return logEffect.pipe(Effect.flatMap(() => Effect.sleep(Duration.millis(delay))));
}),
),
step: () => undefined,
});
});
return {
start: async () => {
backend = await pubsub.createConsumer<T>({
topic,
subscription,
});
running = true;
// Start the dispatch loop (fire and forget; runs until stop).
dispatchLoop().catch((err) => {
if (running === true) console.error("[Subscriber] dispatch loop error:", err);
});
},
stop: async () => {
running = false;
if (backend !== null) {
await backend.close();
backend = null;
}
},
start: () =>
Effect.runPromise(
Effect.gen(function* () {
backend = yield* Effect.tryPromise({
try: () =>
pubsub.createConsumer<T>({
topic,
subscription,
}),
catch: (error) =>
messagingLifecycleError(`${topic}:${subscription}`, "create-consumer", error),
});
running = true;
fiber = yield* dispatchLoop().pipe(Effect.forkDetach);
}),
),
stop: () =>
Effect.runPromise(
Effect.gen(function* () {
running = false;
const activeFiber = fiber;
fiber = null;
if (activeFiber !== null) {
yield* Fiber.interrupt(activeFiber);
}
const currentBackend = backend;
if (currentBackend !== null) {
backend = null;
yield* Effect.tryPromise({
try: () => currentBackend.close(),
catch: (error) =>
messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error),
});
}
}),
),
subscribe: (id) => {
const queue = makeAsyncQueue<T>();
idSubscribers.set(id, { queue });

View file

@ -8,7 +8,7 @@
import type { PubSubBackend } from "../backend/types.js";
import { makeNatsBackend } from "../backend/nats.js";
import { Effect } from "effect";
import { Context, Effect } from "effect";
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
import { loadProcessorRuntimeConfig } from "../runtime/config.js";
@ -36,10 +36,10 @@ declare const processorRunRequirementsType: unique symbol;
export interface ProcessorRuntime<RunError = ProcessorLifecycleError, RunRequirements = never> {
readonly [processorRunErrorType]?: RunError;
readonly [processorRunRequirementsType]?: RunRequirements;
readonly start: () => Promise<void>;
readonly start: (context: Context.Context<RunRequirements>) => Promise<void>;
readonly stop: () => Promise<void>;
startEffect(): unknown;
stopEffect(): unknown;
startEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
stopEffect: Effect.Effect<void, ProcessorLifecycleError>;
}
export interface AsyncProcessorRuntime<
@ -53,8 +53,8 @@ export interface AsyncProcessorRuntime<
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
readonly onShutdown: (callback: () => Promise<void>) => void;
readonly run: () => Promise<void>;
runEffect(): unknown;
readonly run: (context: Context.Context<RunRequirements>) => Promise<void>;
runEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
}
export interface AsyncProcessorRuntimeOptions<
@ -94,8 +94,16 @@ export function makeAsyncProcessor<
}
const shutdown = () => {
console.log(`[${config.id}] Shutting down...`);
void processor.stop().then(() => process.exit(0));
void Effect.runPromise(
Effect.log(`[${config.id}] Shutting down...`).pipe(
Effect.flatMap(() =>
Effect.tryPromise({
try: () => processor.stop(),
catch: (error) => processorLifecycleError(config.id, "signal-shutdown", error),
}),
),
),
).then(() => process.exit(0), () => process.exit(1));
};
const handlers: RegisteredSignalHandler[] = [
{ signal: "SIGINT", handler: shutdown },
@ -125,29 +133,19 @@ export function makeAsyncProcessor<
registerConfigHandler: (handler) => {
configHandlers.push(handler);
},
start: async () => {
await Effect.runPromise(
processor.startEffect() as Effect.Effect<void, RunError | ProcessorLifecycleError>,
);
},
stop: async () => {
await Effect.runPromise(
processor.stopEffect() as Effect.Effect<void, ProcessorLifecycleError>,
);
},
start: (context) => Effect.runPromiseWith(context)(processor.startEffect),
stop: () => Effect.runPromise(processor.stopEffect),
onShutdown: (callback) => {
shutdownCallbacks.push(callback);
},
startEffect() {
get startEffect() {
const startProcessor = Effect.fn("trustgraph.processor.start")(function* () {
yield* Effect.sync(() => {
running = true;
registerProcessSignalHandlers();
});
yield* (
processor.runEffect() as Effect.Effect<void, RunError, RunRequirements>
);
yield* processor.runEffect;
});
return startProcessor().pipe(
Effect.withSpan("trustgraph.processor.start", {
@ -157,7 +155,7 @@ export function makeAsyncProcessor<
}),
);
},
stopEffect() {
get stopEffect() {
const stopProcessor = Effect.fn("trustgraph.processor.stop")(function* () {
yield* Effect.sync(() => {
running = false;
@ -180,18 +178,15 @@ export function makeAsyncProcessor<
});
return stopProcessor();
},
run: () =>
Effect.runPromise(
processor.runEffect() as unknown as Effect.Effect<void, RunError>,
),
runEffect: () => {
run: (context) => Effect.runPromiseWith(context)(processor.runEffect),
get runEffect() {
if (options.runEffect !== undefined) {
return options.runEffect(processor);
}
return Effect.tryPromise({
try: () => options.run?.(processor) ?? Promise.resolve(),
catch: (error) => processorLifecycleError(config.id, "start", error),
}) as unknown as Effect.Effect<void, RunError, RunRequirements>;
});
},
};
@ -208,13 +203,21 @@ export const AsyncProcessor = Object.assign(
return makeAsyncProcessor(config);
},
{
async launch<T extends ProcessorRuntime<unknown, unknown>>(
launch<T extends ProcessorRuntime<unknown, never>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void> {
const config = await Effect.runPromise(loadProcessorRuntimeConfig(id));
const processor = new this(config);
await processor.start();
const ProcessorCtor = this;
return Effect.runPromise(
Effect.gen(function* () {
const config = yield* loadProcessorRuntimeConfig(id);
const processor = new ProcessorCtor(config);
yield* Effect.tryPromise({
try: () => processor.start(Context.empty()),
catch: (error) => processorLifecycleError(id, "launch", error),
});
}),
);
},
},
) as unknown as {
@ -224,7 +227,7 @@ export const AsyncProcessor = Object.assign(
<RunError = ProcessorLifecycleError, RunRequirements = never>(
config: ProcessorConfig,
): AsyncProcessor<RunError, RunRequirements>;
launch<T extends ProcessorRuntime<unknown, unknown>>(
launch<T extends ProcessorRuntime<unknown, never>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void>;

View file

@ -38,6 +38,7 @@ import {
import { makePubSubService, PubSub } from "../backend/pubsub.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import { Duration, Effect, Exit, Scope } from "effect";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
interface ConfigPush {
@ -88,9 +89,7 @@ export interface FlowProcessorRuntime<FlowRequirements = never>
readonly configHandlers: ConfigHandler[];
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
readonly registerSpecification: <Requirements extends FlowRequirements>(
spec: Spec<Requirements>,
) => void;
readonly registerSpecification: (spec: Spec<FlowRequirements>) => void;
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
}
@ -106,6 +105,20 @@ const ConfigPushSchema = S.Struct({
config: S.Record(S.String, S.Unknown),
});
const isStringRecord = (value: unknown): value is Record<string, unknown> =>
Predicate.isObject(value) && !Array.isArray(value);
const isTopicsRecord = (value: unknown): value is Record<string, string> =>
isStringRecord(value) && Object.values(value).every((item) => typeof item === "string");
const isFlowDefinition = (value: unknown): value is FlowDefinition => {
if (!isStringRecord(value)) return false;
const topics = value.topics;
const parameters = value.parameters;
return (topics === undefined || isTopicsRecord(topics)) &&
(parameters === undefined || isStringRecord(parameters));
};
export function runFlowProcessorDefinitionScoped<
FlowRequirements = never,
ConfigHandlerError = never,
@ -202,11 +215,15 @@ export function runFlowProcessorDefinitionScoped<
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
> =>
Effect.gen(function* () {
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
const flowDefs = config.flows;
if (flowDefs === undefined) {
yield* Effect.log(`[${options.id}] No flows in config push, skipping`);
return;
}
if (!isStringRecord(flowDefs)) {
yield* Effect.logWarning(`[${options.id}] Skipping config push: flows is not an object`);
return;
}
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
Effect.catch((error) => Effect.succeed(String(error))),
@ -226,7 +243,7 @@ export function runFlowProcessorDefinitionScoped<
}
for (const [name, defn] of Object.entries(flowDefs)) {
if (typeof defn !== "object" || defn === null) {
if (!isFlowDefinition(defn)) {
yield* Effect.logWarning(`[${options.id}] Skipping flow "${name}": definition is not an object`);
continue;
}
@ -353,8 +370,8 @@ export function makeFlowProcessor<FlowRequirements = never>(
},
});
const startEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
const effect = base.startEffect() as FlowProcessorStartEffect<FlowRequirements>;
const makeStartEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
const effect = base.startEffect;
return options.provide?.(effect) ?? effect;
};
@ -362,24 +379,29 @@ export function makeFlowProcessor<FlowRequirements = never>(
...base,
specifications,
registerSpecification: (spec) => {
specifications.push(spec as Spec<FlowRequirements>);
specifications.push(spec);
},
startEffect,
start: async () => {
const pubsub = makePubSubService(base.pubsub);
const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig());
const start = startEffect().pipe(
Effect.provideService(PubSub, pubsub),
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
),
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
) as Effect.Effect<void, PubSubError | FlowRuntimeError | ProcessorLifecycleError>;
await Effect.runPromise(Effect.scoped(start));
get startEffect() {
return makeStartEffect();
},
start: (context) =>
Effect.runPromiseWith(context)(
Effect.gen(function* () {
const pubsub = makePubSubService(base.pubsub);
const messagingConfig = yield* loadMessagingRuntimeConfig();
const start = processor.startEffect.pipe(
Effect.provideService(PubSub, pubsub),
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
),
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
);
yield* Effect.scoped(start);
}),
),
};
return processor;

View file

@ -4,7 +4,7 @@
* Python reference: trustgraph-base/trustgraph/base/flow.py
*/
import { Effect, Exit, Scope } from "effect";
import { Context, Effect, Exit, Scope } from "effect";
import type { PubSubBackend } from "../backend/types.js";
import { makePubSubService } from "../backend/pubsub.js";
import {
@ -64,19 +64,20 @@ export function makeFlow<Requirements = never>(
definition: FlowDefinition,
specifications: ReadonlyArray<Spec<Requirements>>,
) {
const producers = new Map<string, EffectProducer<unknown>>();
const producers = new Map<string, EffectProducer<never>>();
const consumers = new Map<string, EffectConsumer>();
const requestors = new Map<string, EffectRequestResponse<unknown, unknown>>();
const requestors = new Map<string, EffectRequestResponse<never, unknown>>();
const parameters = new Map<string, unknown>();
let compatibilityScope: Scope.Closeable | null = null;
const ensureCompatibilityScope = async (): Promise<Scope.Closeable> => {
const ensureCompatibilityScopeEffect = Effect.fn("Flow.ensureCompatibilityScope")(function* () {
if (compatibilityScope !== null) {
return compatibilityScope;
}
compatibilityScope = await Effect.runPromise(Scope.make());
return compatibilityScope;
};
const scope = yield* Scope.make();
compatibilityScope = scope;
return scope;
});
const toEffectRequestOptions = <TRes>(
options: FlowRequestOptions<TRes> | undefined,
@ -105,41 +106,58 @@ export function makeFlow<Requirements = never>(
}
});
},
async start(): Promise<void> {
if (compatibilityScope !== null) {
await flow.stop();
}
await flow.runInCompatibilityScope(
flow.startEffect() as Effect.Effect<void, PubSubError, SpecRuntimeRequirements>,
pubsub,
start(context: Context.Context<Requirements>): Promise<void> {
return Effect.runPromise(
Effect.gen(function* () {
if (compatibilityScope !== null) {
yield* flow.stopEffect();
}
yield* flow.runInCompatibilityScopeEffect(flow.startEffect(), pubsub, context);
}),
);
},
async stop(): Promise<void> {
const scope = compatibilityScope;
compatibilityScope = null;
if (scope !== null) {
await Effect.runPromise(Scope.close(scope, Exit.void));
}
flow.clearResources();
stop(): Promise<void> {
return Effect.runPromise(flow.stopEffect());
},
async runInCompatibilityScope<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements>,
stopEffect(): Effect.Effect<void> {
return Effect.gen(function* () {
const scope = compatibilityScope;
compatibilityScope = null;
if (scope !== null) {
yield* Scope.close(scope, Exit.void);
}
flow.clearResources();
});
},
runInCompatibilityScopeEffect<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
): Promise<A> {
const scope = await ensureCompatibilityScope();
const pubsubService = makePubSubService(runtimePubsub);
const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig());
return await Effect.runPromise(
effect.pipe(
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)),
context: Context.Context<Requirements>,
) {
return Effect.gen(function* () {
const scope = yield* ensureCompatibilityScopeEffect();
const pubsubService = makePubSubService(runtimePubsub);
const messagingConfig = yield* loadMessagingRuntimeConfig();
return yield* Effect.provide(
effect.pipe(
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)),
),
Scope.provide(scope),
),
Scope.provide(scope),
),
);
context,
);
});
},
runInCompatibilityScope<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
context: Context.Context<Requirements>,
): Promise<A> {
return Effect.runPromise(flow.runInCompatibilityScopeEffect(effect, runtimePubsub, context));
},
clearResources(): void {
producers.clear();
@ -147,13 +165,13 @@ export function makeFlow<Requirements = never>(
requestors.clear();
parameters.clear();
},
registerProducer(registerName: string, producer: EffectProducer<unknown>): void {
registerProducer<T>(registerName: string, producer: EffectProducer<T>): void {
producers.set(registerName, producer);
},
registerConsumer(registerName: string, consumer: EffectConsumer): void {
consumers.set(registerName, consumer);
},
registerRequestor(registerName: string, rr: EffectRequestResponse<unknown, unknown>): void {
registerRequestor<TReq, TRes>(registerName: string, rr: EffectRequestResponse<TReq, TRes>): void {
requestors.set(registerName, rr);
},
setParameter(parameterName: string, value: unknown): void {

View file

@ -5,7 +5,7 @@
* executable path while the processor internals remain Promise-based.
*/
import { Config as EffectConfig, Effect, Layer, Scope } from "effect";
import { Config as EffectConfig, Effect, Layer } from "effect";
import {
processorLifecycleError,
type FlowRuntimeError,
@ -37,18 +37,16 @@ import type {
import { runFlowProcessorDefinitionScoped } from "./flow-processor.js";
import type { Spec } from "../spec/types.js";
type ProcessorRunError<Processor> = Processor extends ProcessorRuntime<infer Error, unknown> ? Error : never;
type ProcessorRunRequirements<Processor> = Processor extends ProcessorRuntime<unknown, infer Requirements> ? Requirements : never;
export interface ProcessorProgramOptions<
Config extends ProcessorConfig,
Error,
Requirements,
Processor extends ProcessorRuntime<unknown, unknown>,
LoadError,
LoadRequirements,
RunError,
RunRequirements,
> {
readonly id: string;
readonly make: (config: Config) => Processor;
readonly loadConfig?: Effect.Effect<Config, Error, Requirements>;
readonly make: (config: Config) => ProcessorRuntime<RunError, RunRequirements>;
readonly loadConfig?: Effect.Effect<Config, LoadError, LoadRequirements>;
}
export interface FlowProcessorProgramOptions<
@ -68,18 +66,14 @@ export interface FlowProcessorProgramOptions<
) => Layer.Layer<FlowRequirements, Error, LayerRequirements>;
}
export function runProcessorScoped<
export const runProcessorScoped = Effect.fn("runProcessorScoped")(function* <
Config extends ProcessorConfig,
Processor extends ProcessorRuntime<unknown, unknown>,
RunError,
RunRequirements,
>(
config: Config,
make: (config: Config) => Processor,
): Effect.Effect<
void,
ProcessorRunError<Processor> | ProcessorLifecycleError,
PubSub | Scope.Scope | ProcessorRunRequirements<Processor>
> {
return Effect.gen(function* () {
make: (config: Config) => ProcessorRuntime<RunError, RunRequirements>,
) {
const pubsub = yield* PubSub;
const runtimeConfig = {
...config,
@ -103,23 +97,17 @@ export function runProcessorScoped<
),
);
yield* (
processor.startEffect() as Effect.Effect<
void,
ProcessorRunError<Processor> | ProcessorLifecycleError,
ProcessorRunRequirements<Processor>
>
);
});
}
yield* processor.startEffect;
});
export function makeProcessorProgram<
Config extends ProcessorConfig,
Error = never,
Requirements = never,
Processor extends ProcessorRuntime<unknown, unknown> = ProcessorRuntime,
LoadError = never,
LoadRequirements = never,
RunError = ProcessorLifecycleError,
RunRequirements = never,
>(
options: ProcessorProgramOptions<Config, Error, Requirements, Processor>,
options: ProcessorProgramOptions<Config, LoadError, LoadRequirements, RunError, RunRequirements>,
) {
return Effect.scoped(
Effect.gen(function* () {
@ -147,7 +135,7 @@ export function makeProcessorProgram<
),
),
);
const processorEffect = runProcessorScoped<Config, Processor>(
const processorEffect = runProcessorScoped<Config, RunError, RunRequirements>(
runtimeConfig,
options.make,
);
@ -173,12 +161,37 @@ export function makeFlowProcessorProgram<
FlowRequirements = never,
LayerRequirements = never,
>(
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements>,
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements> & {
readonly layer: (config: Config) => Layer.Layer<FlowRequirements, Error, LayerRequirements>;
},
): Effect.Effect<
void,
never,
Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError,
LayerRequirements
> {
>;
export function makeFlowProcessorProgram<
Config extends ProcessorConfig,
Error = never,
FlowRequirements = never,
>(
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, FlowRequirements> & {
readonly layer?: undefined;
},
): Effect.Effect<
never,
Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError,
FlowRequirements
>;
export function makeFlowProcessorProgram<
Config extends ProcessorConfig,
Error = never,
FlowRequirements = never,
LayerRequirements = never,
>(
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements>,
) {
return Effect.scoped(
Effect.gen(function* () {
const config = yield* (
@ -226,14 +239,16 @@ export function makeFlowProcessorProgram<
),
Layer.succeed(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
);
const dependencyLayer = options.layer?.(runtimeConfig) ??
(Layer.empty as unknown as Layer.Layer<FlowRequirements, Error, LayerRequirements>);
const providedProcessorLayer = processorLayer.pipe(
Layer.provide(dependencyLayer),
Layer.provide(runtimeLayer),
);
if (options.layer !== undefined) {
return yield* Layer.launch(
processorLayer.pipe(
Layer.provide(options.layer(runtimeConfig)),
Layer.provide(runtimeLayer),
),
);
}
return yield* Layer.launch(providedProcessorLayer);
return yield* Layer.launch(processorLayer.pipe(Layer.provide(runtimeLayer)));
}),
);
}

View file

@ -4,7 +4,7 @@
* Python reference: trustgraph-base/trustgraph/base/llm_service.py
*/
import { Context, Effect } from "effect";
import { Context, Effect, Stream } from "effect";
import * as S from "effect/Schema";
import {
errorMessage,
@ -69,7 +69,7 @@ export class Llm extends Context.Service<Llm, LlmServiceShape>()(
) {}
const llmServiceError = (operation: string, cause: unknown) =>
new LlmServiceError({
LlmServiceError.make({
operation,
message: errorMessage(cause),
});
@ -135,22 +135,19 @@ const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(func
) => Effect.Effect<void, MessagingDeliveryError>;
},
) {
const context = yield* Effect.context<never>();
yield* Effect.tryPromise({
try: async () => {
for await (const chunk of llm.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
)) {
await Effect.runPromiseWith(context)(
responseProducer.send(requestId, chunkToResponse(chunk)),
);
}
},
catch: (cause) => llmServiceError("generate-content-stream", cause),
});
yield* Stream.fromAsyncIterable(
llm.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
),
(cause) => llmServiceError("generate-content-stream", cause),
).pipe(
Stream.runForEach((chunk) =>
responseProducer.send(requestId, chunkToResponse(chunk)),
),
);
});
const onLlmRequest = Effect.fn("LlmService.onRequest")(function* (
@ -168,16 +165,22 @@ const onLlmRequest = Effect.fn("LlmService.onRequest")(function* (
if (msg.streaming === true && llm.supportsStreaming()) {
yield* sendStreamingResponse(llm, requestId, msg, responseProducer).pipe(
Effect.catch((error) =>
Effect.logError("[LlmService] Error processing streaming request", {
error: error.message,
operation: error.operation,
}).pipe(
Effect.flatMap(() =>
responseProducer.send(requestId, llmErrorResponse(error)),
Effect.catchTags({
LlmServiceError: (error) =>
Effect.logError("[LlmService] Error processing streaming request", {
error: error.message,
operation: error.operation,
}).pipe(
Effect.flatMap(() =>
responseProducer.send(requestId, llmErrorResponse(error)),
),
),
),
),
MessagingDeliveryError: (error) =>
Effect.logError("[LlmService] Error sending streaming response", {
error: error.message,
operation: error.operation,
}),
}),
);
return;
}

View file

@ -62,14 +62,8 @@ export function makeConsumerSpec<T, E = never, R = never>(
return {
name,
addEffect,
add: async (flow, pubsub, definition) => {
const effect = addEffect(flow as Flow<R>, definition) as Effect.Effect<
void,
PubSubError,
SpecRuntimeRequirements
>;
await flow.runInCompatibilityScope(effect, pubsub);
},
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

@ -20,8 +20,6 @@ export function makeParameterSpec(name: string): ParameterSpec {
return {
name,
addEffect,
add: async (flow, _pubsub, definition) => {
await Effect.runPromise(addEffect(flow, definition));
},
add: (flow, _pubsub, definition) => Effect.runPromise(addEffect(flow, definition)),
};
}

View file

@ -9,7 +9,6 @@ import type { Spec } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import {
ProducerFactory,
type EffectProducer,
} from "../messaging/runtime.js";
declare const ProducerSpecType: unique symbol;
@ -26,14 +25,13 @@ export function makeProducerSpec<T>(name: string): ProducerSpec<T> {
const topic = definition.topics?.[name] ?? name;
const factory = yield* ProducerFactory;
const producer = yield* factory.make<T>({ topic });
flow.registerProducer(name, producer as EffectProducer<unknown>);
flow.registerProducer(name, producer);
});
return {
name,
addEffect,
add: async (flow, pubsub, definition) => {
await flow.runInCompatibilityScope(addEffect(flow, definition), pubsub);
},
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

@ -12,7 +12,6 @@ import type { Spec } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import {
RequestResponseFactory,
type EffectRequestResponse,
} from "../messaging/runtime.js";
declare const RequestResponseSpecType: unique symbol;
@ -41,14 +40,13 @@ export function makeRequestResponseSpec<TReq, TRes>(
responseTopic,
subscription: `${flow.processorId}-${flow.name}-${name}`,
});
flow.registerRequestor(name, requestor as EffectRequestResponse<unknown, unknown>);
flow.registerRequestor(name, requestor);
});
return {
name,
addEffect,
add: async (flow, pubsub, definition) => {
await flow.runInCompatibilityScope(addEffect(flow, definition), pubsub);
},
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

@ -4,7 +4,7 @@
* Python reference: trustgraph-base/trustgraph/base/spec.py and siblings
*/
import type { Effect, Scope } from "effect";
import type { Context, Effect, Scope } from "effect";
import type { PubSubBackend } from "../backend/types.js";
import type {
ConsumerFactory,
@ -28,5 +28,10 @@ export interface Spec<Requirements = never> {
flow: Flow<Requirements>,
definition: FlowDefinition,
): Effect.Effect<void, SpecRuntimeError, SpecRuntimeRequirements | Requirements>;
add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void>;
add(
flow: Flow<Requirements>,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<Requirements>,
): Promise<void>;
}

View file

@ -5,21 +5,20 @@
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import { cliCommandError, withSocket } from "./util.js";
export function registerAgentCommands(program: Command): void {
program
.command("agent")
.description("Ask the TrustGraph agent a question")
.argument("<question>", "Question to ask")
.action(async (question: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((question: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
await new Promise<void>((resolve, reject) => {
yield* Effect.callback<void, ReturnType<typeof cliCommandError>>((resume) => {
flow.agent(
question,
(chunk) => {
@ -35,14 +34,13 @@ export function registerAgentCommands(program: Command): void {
if (chunk.length > 0) process.stdout.write(chunk);
if (complete) {
process.stdout.write("\n");
resolve();
resume(Effect.void);
}
},
(err) => reject(new Error(err)),
(err) => resume(Effect.fail(cliCommandError("agent", err))),
);
});
} finally {
socket.close();
}
});
}),
)),
);
}

View file

@ -5,7 +5,8 @@
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerConfigCommands(program: Command): void {
const config = program
@ -15,28 +16,26 @@ export function registerConfigCommands(program: Command): void {
config
.command("show")
.description("Show current configuration")
.action(async (_opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const cfg = socket.config();
const resp = await cfg.getConfigAll();
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => cfg.getConfigAll(),
catch: (error) => cliCommandError("config.show", error),
});
yield* writeJson(resp);
}),
)),
);
config
.command("get")
.description("Get a configuration value")
.argument("<key>", "Config key (format: type/key)")
.action(async (key: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((key: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const cfg = socket.config();
// Support "type/key" format; fall back to using the whole string as key
const parts = key.split("/");
@ -44,72 +43,74 @@ export function registerConfigCommands(program: Command): void {
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/") }
: { type: "config", key };
const resp = await cfg.getConfig([configKey]);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => cfg.getConfig([configKey]),
catch: (error) => cliCommandError("config.get", error),
});
yield* writeJson(resp);
}),
)),
);
config
.command("set")
.description("Set a configuration value")
.argument("<key>", "Config key (format: type/key)")
.argument("<value>", "Config value (JSON)")
.action(async (key: string, value: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((key: string, value: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const cfg = socket.config();
const parts = key.split("/");
const configEntry =
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/"), value }
: { type: "config", key, value };
const resp = await cfg.putConfig([configEntry]);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => cfg.putConfig([configEntry]),
catch: (error) => cliCommandError("config.set", error),
});
yield* writeJson(resp);
}),
)),
);
config
.command("list")
.description("List configuration keys for a type")
.argument("[type]", "Config type to list", "config")
.action(async (type: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((type: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const cfg = socket.config();
const resp = await cfg.list(type);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => cfg.list(type),
catch: (error) => cliCommandError("config.list", error),
});
yield* writeJson(resp);
}),
)),
);
config
.command("delete")
.description("Delete a configuration entry")
.argument("<key>", "Config key (format: type/key)")
.action(async (key: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((key: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const cfg = socket.config();
const parts = key.split("/");
const configKey =
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/") }
: { type: "config", key };
const resp = await cfg.deleteConfig(configKey);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => cfg.deleteConfig(configKey),
catch: (error) => cliCommandError("config.delete", error),
});
yield* writeJson(resp);
}),
)),
);
}

View file

@ -5,23 +5,24 @@
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerEmbeddingsCommands(program: Command): void {
program
.command("embeddings")
.description("Generate text embeddings")
.argument("<text...>", "Text(s) to embed")
.action(async (texts: string[], _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((texts: string[], _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
const vectors = await flow.embeddings(texts);
console.log(JSON.stringify(vectors, null, 2));
} finally {
socket.close();
}
});
const vectors = yield* Effect.tryPromise({
try: () => flow.embeddings(texts),
catch: (error) => cliCommandError("embeddings", error),
});
yield* writeJson(vectors);
}),
)),
);
}

View file

@ -5,7 +5,9 @@
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import * as S from "effect/Schema";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerFlowCommands(program: Command): void {
const flow = program
@ -15,35 +17,35 @@ export function registerFlowCommands(program: Command): void {
flow
.command("list")
.description("List active flows")
.action(async (_opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const ids = await flows.getFlows();
console.log(JSON.stringify(ids, null, 2));
} finally {
socket.close();
}
});
const ids = yield* Effect.tryPromise({
try: () => flows.getFlows(),
catch: (error) => cliCommandError("flow.list", error),
});
yield* writeJson(ids);
}),
)),
);
flow
.command("get")
.description("Get a flow definition")
.argument("<id>", "Flow ID")
.action(async (id: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((id: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const def = await flows.getFlow(id);
console.log(JSON.stringify(def, null, 2));
} finally {
socket.close();
}
});
const def = yield* Effect.tryPromise({
try: () => flows.getFlow(id),
catch: (error) => cliCommandError("flow.get", error),
});
yield* writeJson(def);
}),
)),
);
flow
.command("start")
@ -52,42 +54,46 @@ export function registerFlowCommands(program: Command): void {
.requiredOption("-b, --blueprint <name>", "Blueprint name")
.option("-d, --description <text>", "Flow description", "")
.option("-p, --parameters <json>", "Parameters as JSON")
.action(async (id: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((id: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const rawParameters = cmdOpts.parameters as string | undefined;
const params = rawParameters !== undefined && rawParameters.length > 0
? JSON.parse(rawParameters)
? yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(rawParameters).pipe(
Effect.flatMap(S.decodeUnknownEffect(S.Record(S.String, S.Unknown))),
Effect.mapError((error) => cliCommandError("flow.start.parameters", error)),
)
: undefined;
const resp = await flows.startFlow(
id,
cmdOpts.blueprint as string,
cmdOpts.description as string,
params as Record<string, unknown> | undefined,
);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () =>
flows.startFlow(
id,
cmdOpts.blueprint as string,
cmdOpts.description as string,
params,
),
catch: (error) => cliCommandError("flow.start", error),
});
yield* writeJson(resp);
}),
)),
);
flow
.command("stop")
.description("Stop a flow")
.argument("<id>", "Flow ID")
.action(async (id: string, _opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((id: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const resp = await flows.stopFlow(id);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => flows.stopFlow(id),
catch: (error) => cliCommandError("flow.stop", error),
});
yield* writeJson(resp);
}),
)),
);
}

View file

@ -5,7 +5,8 @@
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import { cliCommandError, withSocket, writeLine } from "./util.js";
export function registerGraphRagCommands(program: Command): void {
program
@ -15,26 +16,27 @@ export function registerGraphRagCommands(program: Command): void {
.option("--entity-limit <n>", "Max entities", "50")
.option("--triple-limit <n>", "Max triples per entity", "30")
.option("--collection <name>", "Collection name")
.action(async (query: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((query: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
const collection = cmdOpts.collection as string | undefined;
const response = await flow.graphRag(
query,
{
entityLimit: parseInt(cmdOpts.entityLimit, 10),
tripleLimit: parseInt(cmdOpts.tripleLimit, 10),
},
collection,
);
console.log(response);
} finally {
socket.close();
}
});
const response = yield* Effect.tryPromise({
try: () =>
flow.graphRag(
query,
{
entityLimit: parseInt(cmdOpts.entityLimit, 10),
tripleLimit: parseInt(cmdOpts.tripleLimit, 10),
},
collection,
),
catch: (error) => cliCommandError("graph-rag", error),
});
yield* writeLine(response);
}),
)),
);
program
.command("document-rag")
@ -42,24 +44,25 @@ export function registerGraphRagCommands(program: Command): void {
.argument("<query>", "Natural language query")
.option("--doc-limit <n>", "Max documents", "20")
.option("--collection <name>", "Collection name")
.action(async (query: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((query: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
const docLimit = cmdOpts.docLimit as string | undefined;
const collection = cmdOpts.collection as string | undefined;
const response = await flow.documentRag(
query,
docLimit !== undefined && docLimit.length > 0
? parseInt(docLimit, 10)
: undefined,
collection,
);
console.log(response);
} finally {
socket.close();
}
});
const response = yield* Effect.tryPromise({
try: () =>
flow.documentRag(
query,
docLimit !== undefined && docLimit.length > 0
? parseInt(docLimit, 10)
: undefined,
collection,
),
catch: (error) => cliCommandError("document-rag", error),
});
yield* writeLine(response);
}),
)),
);
}

View file

@ -5,7 +5,8 @@
*/
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import { cliCommandError, withSocket, writeJson } from "./util.js";
function basenamePath(filepath: string): string {
const normalized = filepath.replace(/\/+$/, "");
@ -45,18 +46,18 @@ export function registerLibraryCommands(program: Command): void {
library
.command("list")
.description("List documents in the library")
.action(async (_opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const docs = await lib.getDocuments();
console.log(JSON.stringify(docs, null, 2));
} finally {
socket.close();
}
});
const docs = yield* Effect.tryPromise({
try: () => lib.getDocuments(),
catch: (error) => cliCommandError("library.list", error),
});
yield* writeJson(docs);
}),
)),
);
library
.command("load")
@ -67,64 +68,68 @@ export function registerLibraryCommands(program: Command): void {
.option("-c, --comments <text>", "Comments", "")
.option("--tags <tags...>", "Document tags")
.option("--id <id>", "Optional document ID")
.action(async (file: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((file: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const data = new Uint8Array(await Bun.file(file).arrayBuffer());
const data = new Uint8Array(yield* Effect.tryPromise({
try: () => Bun.file(file).arrayBuffer(),
catch: (error) => cliCommandError("library.load.read-file", error),
}));
const b64 = Buffer.from(data).toString("base64");
const mimeType = (cmdOpts.mimeType as string | undefined) ?? guessMimeType(file);
const title = (cmdOpts.title as string | undefined) ?? basenamePath(file);
const comments = cmdOpts.comments as string;
const tags: string[] = (cmdOpts.tags as string[] | undefined) ?? [];
const resp = await lib.loadDocument(
b64,
mimeType,
title,
comments,
tags,
cmdOpts.id as string | undefined,
);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () =>
lib.loadDocument(
b64,
mimeType,
title,
comments,
tags,
cmdOpts.id as string | undefined,
),
catch: (error) => cliCommandError("library.load", error),
});
yield* writeJson(resp);
}),
)),
);
library
.command("remove")
.description("Remove a document from the library")
.argument("<id>", "Document ID to remove")
.option("--collection <name>", "Collection name")
.action(async (id: string, cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((id: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const resp = await lib.removeDocument(id, cmdOpts.collection as string | undefined);
console.log(JSON.stringify(resp, null, 2));
} finally {
socket.close();
}
});
const resp = yield* Effect.tryPromise({
try: () => lib.removeDocument(id, cmdOpts.collection as string | undefined),
catch: (error) => cliCommandError("library.remove", error),
});
yield* writeJson(resp);
}),
)),
);
library
.command("processing")
.description("List documents currently being processed")
.action(async (_opts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const items = await lib.getProcessing();
console.log(JSON.stringify(items, null, 2));
} finally {
socket.close();
}
});
const items = yield* Effect.tryPromise({
try: () => lib.getProcessing(),
catch: (error) => cliCommandError("library.processing", error),
});
yield* writeJson(items);
}),
)),
);
}

View file

@ -6,7 +6,8 @@
import type { Command } from "commander";
import type { Term } from "@trustgraph/client";
import { createSocket, getOpts } from "./util.js";
import { Effect } from "effect";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerTriplesCommands(program: Command): void {
program
@ -17,11 +18,9 @@ export function registerTriplesCommands(program: Command): void {
.option("-o, --object <iri>", "Object IRI or literal")
.option("-l, --limit <n>", "Max results", "20")
.option("--collection <name>", "Collection name")
.action(async (cmdOpts, cmd) => {
const opts = getOpts(cmd);
const socket = await createSocket(opts);
try {
.action((cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
const subject = cmdOpts.subject as string | undefined;
const predicate = cmdOpts.predicate as string | undefined;
@ -36,16 +35,19 @@ export function registerTriplesCommands(program: Command): void {
? { t: "i", i: object }
: undefined;
const triples = await flow.triplesQuery(
s,
p,
o,
parseInt(cmdOpts.limit as string, 10),
cmdOpts.collection as string | undefined,
);
console.log(JSON.stringify(triples, null, 2));
} finally {
socket.close();
}
});
const triples = yield* Effect.tryPromise({
try: () =>
flow.triplesQuery(
s,
p,
o,
parseInt(cmdOpts.limit as string, 10),
cmdOpts.collection as string | undefined,
),
catch: (error) => cliCommandError("triples", error),
});
yield* writeJson(triples);
}),
)),
);
}

View file

@ -4,6 +4,8 @@
import type { Command } from "commander";
import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client";
import { Duration, Effect } from "effect";
import * as S from "effect/Schema";
export interface CliOpts {
gateway: string;
@ -19,36 +21,76 @@ export function getOpts(cmd: Command): CliOpts {
return root.opts() as CliOpts;
}
export class CliCommandError extends S.TaggedErrorClass<CliCommandError>()(
"CliCommandError",
{
message: S.String,
operation: S.String,
},
) {}
export function cliCommandError(operation: string, error: unknown): CliCommandError {
const message = typeof error === "object" && error !== null && "message" in error
? String(error.message)
: String(error);
return CliCommandError.make({ operation, message });
}
export const writeLine = (line: string) =>
Effect.sync(() => {
process.stdout.write(`${line}\n`);
});
export const writeJson = (value: unknown) =>
S.encodeUnknownEffect(S.UnknownFromJsonString)(value).pipe(
Effect.mapError((error) => cliCommandError("write-json", error)),
Effect.flatMap(writeLine),
);
/**
* Create a BaseApi socket client and wait for the connection to be established.
* The client auto-connects; we listen for the first "connected/authenticated"
* state before handing it back to the caller.
*/
export async function createSocket(opts: CliOpts): Promise<BaseApi> {
export function createSocketEffect(opts: CliOpts): Effect.Effect<BaseApi, CliCommandError> {
const socket = createTrustGraphSocket(opts.user, opts.token, opts.gateway);
// Wait for the socket to reach an open state
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
unsub();
reject(new Error("Timed out waiting for WebSocket connection"));
}, 15_000);
return Effect.callback<void, CliCommandError>((resume) => {
const unsub = socket.onConnectionStateChange((state) => {
if (
state.status === "authenticated" ||
state.status === "unauthenticated"
) {
clearTimeout(timeout);
if (state.status === "authenticated" || state.status === "unauthenticated") {
unsub();
resolve();
resume(Effect.void);
} else if (state.status === "failed") {
clearTimeout(timeout);
unsub();
reject(new Error(state.lastError ?? "WebSocket connection failed"));
resume(Effect.fail(cliCommandError("connect", state.lastError ?? "WebSocket connection failed")));
}
});
});
return socket;
return Effect.sync(() => {
unsub();
});
}).pipe(
Effect.timeout(Duration.seconds(15)),
Effect.catchTag("TimeoutError", () =>
Effect.fail(cliCommandError("connect", "Timed out waiting for WebSocket connection")),
),
Effect.as(socket),
);
}
export function createSocket(opts: CliOpts): Promise<BaseApi> {
return Effect.runPromise(createSocketEffect(opts));
}
export const withSocket = <A, E, R>(
cmd: Command,
use: (socket: BaseApi, opts: CliOpts) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
createSocketEffect(getOpts(cmd)),
(socket) => use(socket, getOpts(cmd)),
(socket) =>
Effect.sync(() => {
socket.close();
}),
);

View file

@ -11,7 +11,7 @@
* Python reference: trustgraph-flow/trustgraph/config/service/service.py
*/
import { Duration, Effect } from "effect";
import { Context, Duration, Effect } from "effect";
import * as S from "effect/Schema";
import {
makeAsyncProcessor,
@ -148,7 +148,7 @@ export type ConfigService = AsyncProcessorRuntime & Record<string, any>;
export function makeConfigService(config: ConfigServiceConfig): ConfigService {
const service = makeAsyncProcessor(config, {
run: () => service.run(),
run: () => service.run(Context.empty()),
}) as ConfigService;
const baseStop = service.stop;
service.store = new Map<string, WorkspaceStore>();

View file

@ -23,7 +23,7 @@ import {
errorMessage,
} from "@trustgraph/base";
import type { Message } from "@trustgraph/base";
import { Config, Duration, Effect } from "effect";
import { Config, Context, Duration, Effect } from "effect";
import * as S from "effect/Schema";
import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
@ -118,7 +118,7 @@ const closeResource = (
export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): KnowledgeCoreService {
const service = makeAsyncProcessor(config, {
run: () => service.run(),
run: () => service.run(Context.empty()),
}) as KnowledgeCoreService;
const baseStop = service.stop;
service.cores = new Map<string, KnowledgeCore>();

View file

@ -26,7 +26,7 @@ import {
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type { Message } from "@trustgraph/base";
import { Duration, Effect, Option } from "effect";
import { Context, Duration, Effect, Option } from "effect";
import * as S from "effect/Schema";
// ---------- Internal state types ----------
@ -158,7 +158,7 @@ export type FlowManagerService = AsyncProcessorRuntime & Record<string, any>;
export function makeFlowManagerService(config: ProcessorConfig): FlowManagerService {
const service = makeAsyncProcessor(config, {
run: () => service.run(),
run: () => service.run(Context.empty()),
}) as FlowManagerService;
const baseStop = service.stop;
service.flows = new Map<string, FlowInstance>();

View file

@ -25,7 +25,7 @@ import {
type ProcessingMetadata,
} from "@trustgraph/base";
import type { Message } from "@trustgraph/base";
import { Clock, Config, DateTime, Duration, Effect, Random } from "effect";
import { Clock, Config, Context, DateTime, Duration, Effect, Random } from "effect";
import * as S from "effect/Schema";
import { makeCollectionManager } from "./collection-manager.js";
import {
@ -139,7 +139,7 @@ export type LibrarianService = AsyncProcessorRuntime & Record<string, any>;
export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianService {
const service = makeAsyncProcessor(config, {
run: () => service.run(),
run: () => service.run(Context.empty()),
}) as LibrarianService;
const baseStop = service.stop;
service.documents = new Map<string, DocumentMetadata>();

View file

@ -1,14 +1,21 @@
import {OpenAiClient, OpenAiLanguageModel} from "@effect/ai-openai";
import {BunHttpServer, BunRuntime} from "@effect/platform-bun";
import {createTrustGraphSocket, type BaseApi, type Term as ClientTerm} from "@trustgraph/client";
import {Context, Effect, Layer, Redacted} from "effect";
import {Config, Context, Effect, Layer, Redacted} from "effect";
import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
import {LanguageModel, McpServer, Prompt, Tool, Toolkit} from "effect/unstable/ai";
import {FetchHttpClient, HttpRouter} from "effect/unstable/http";
import {HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi} from "effect/unstable/httpapi";
import * as S from "effect/Schema";
const annotateTool = <T extends Tool.Any>(
tool: T,
const annotateTool = <Name extends string, Config extends {
readonly parameters: S.Top
readonly success: S.Top
readonly failure: S.Top
readonly failureMode: Tool.FailureMode
}, Requirements>(
tool: Tool.Tool<Name, Config, Requirements>,
annotations: {
readonly title: string
readonly readOnly: boolean
@ -17,14 +24,14 @@ const annotateTool = <T extends Tool.Any>(
readonly openWorld: boolean
readonly strict?: boolean
},
): T =>
): Tool.Tool<Name, Config, Requirements> =>
tool
.annotate(Tool.Title, annotations.title)
.annotate(Tool.Readonly, annotations.readOnly)
.annotate(Tool.Destructive, annotations.destructive)
.annotate(Tool.Idempotent, annotations.idempotent)
.annotate(Tool.OpenWorld, annotations.openWorld)
.annotate(Tool.Strict, annotations.strict ?? true) as T
.annotate(Tool.Strict, annotations.strict ?? true)
class PromptSummary extends S.Class<PromptSummary>("PromptSummary")(
{
@ -1217,18 +1224,14 @@ export interface TrustGraphMcpConfigShape {
readonly version: string
readonly mcpPath: HttpRouter.PathInput
readonly openAiModel: string
readonly openAiApiKey: string | undefined
readonly openAiApiKey: Redacted.Redacted | undefined
readonly port: number
}
const readNonEmpty = (value: string | undefined): string | undefined =>
value !== undefined && value.length > 0 ? value : undefined
const resolvePort = (value: number | undefined): number => {
if (value !== undefined) {
return value
}
const raw = readNonEmpty(process.env.PORT)
const parsePort = (raw: string | undefined): number => {
if (raw === undefined) {
return 3000
}
@ -1236,28 +1239,46 @@ const resolvePort = (value: number | undefined): number => {
return Number.isFinite(parsed) ? parsed : 3000
}
export const loadTrustGraphMcpConfig = Effect.fn("loadTrustGraphMcpConfig")(function*(
options: TrustGraphMcpOptions = {},
) {
const gatewayUrl = O.getOrUndefined(yield* Config.string("GATEWAY_URL").pipe(Config.option))
const user = O.getOrUndefined(yield* Config.string("USER_ID").pipe(Config.option))
const gatewaySecret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option))
const token = readNonEmpty(gatewaySecret)
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))
return {
gatewayUrl: options.gatewayUrl ?? gatewayUrl ?? "ws://localhost:8088/api/v1/rpc",
user: options.user ?? user ?? "mcp",
token: options.token ?? token,
flowId: options.flowId ?? flowId ?? "default",
name: options.name ?? "trustgraph",
version: options.version ?? "0.1.0",
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)),
}
})
export const resolveTrustGraphMcpConfig = (
options: TrustGraphMcpOptions = {},
): TrustGraphMcpConfigShape => ({
gatewayUrl: options.gatewayUrl ?? process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc",
user: options.user ?? process.env.USER_ID ?? "mcp",
token: options.token ?? readNonEmpty(process.env.GATEWAY_SECRET),
flowId: options.flowId ?? process.env.FLOW_ID ?? "default",
name: options.name ?? "trustgraph",
version: options.version ?? "0.1.0",
mcpPath: options.mcpPath ?? "/mcp",
openAiModel: options.openAiModel ?? process.env.OPENAI_MODEL ?? "gpt-4.1",
openAiApiKey: options.openAiApiKey ?? readNonEmpty(process.env.OPENAI_API_KEY) ?? readNonEmpty(process.env.OPENAI_TOKEN),
port: resolvePort(options.port),
})
): TrustGraphMcpConfigShape => Effect.runSync(loadTrustGraphMcpConfig(options))
export class TrustGraphMcpConfig extends Context.Service<TrustGraphMcpConfig, TrustGraphMcpConfigShape>()(
"@trustgraph/mcp/server-effect/TrustGraphMcpConfig",
) {
static readonly layer = (options: TrustGraphMcpOptions = {}) =>
Layer.succeed(
Layer.effect(
TrustGraphMcpConfig,
TrustGraphMcpConfig.of(resolveTrustGraphMcpConfig(options)),
loadTrustGraphMcpConfig(options).pipe(Effect.map(TrustGraphMcpConfig.of)),
)
}
@ -1278,17 +1299,14 @@ export class TrustGraphSocket extends Context.Service<TrustGraphSocket, BaseApi>
}
const toErrorMessage = (cause: unknown): string => {
if (cause instanceof Error && cause.message.length > 0) {
if (Predicate.isError(cause) && cause.message.length > 0) {
return cause.message
}
if (typeof cause === "string" && cause.length > 0) {
return cause
}
if (cause !== null && typeof cause === "object" && "message" in cause) {
const message = (cause as { readonly message?: unknown }).message
if (typeof message === "string" && message.length > 0) {
return message
}
if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) {
return cause.message
}
return "TrustGraph MCP tool failed"
}
@ -1313,16 +1331,15 @@ const decodeJsonArrayOrFail = <E>(
const asIriTerm = (value: string | undefined): ClientTerm | undefined =>
value !== undefined && value.length > 0 ? {t: "i", i: value} : undefined
const openAiApiKeyOptions = (apiKey: string | undefined) =>
const openAiApiKeyOptions = (apiKey: Redacted.Redacted | undefined) =>
apiKey === undefined
? {}
: {apiKey: Redacted.make(apiKey)}
: {apiKey}
export const makeOpenAiProviderLayer = (
options: TrustGraphMcpOptions = {},
) => {
const config = resolveTrustGraphMcpConfig(options)
return OpenAiLanguageModel.layer({
const makeOpenAiProviderLayerFromConfig = (
config: TrustGraphMcpConfigShape,
) =>
OpenAiLanguageModel.layer({
model: config.openAiModel,
config: {
strictJsonSchema: true,
@ -1331,7 +1348,15 @@ export const makeOpenAiProviderLayer = (
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(
Effect.gen(function*() {
@ -1344,33 +1369,32 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
const response = yield* model.generateText({
prompt: Prompt.make(prompt).pipe(Prompt.setSystem(system)),
})
return new TextCompletionSuccess({text: response.text})
return TextCompletionSuccess.make({text: response.text})
}),
graph_rag: ({query, entity_limit, triple_limit, collection}) =>
Effect.tryPromise({
try: async () => {
const response = await socket.flow(config.flowId).graphRag(
try: () =>
socket.flow(config.flowId).graphRag(
query,
{
...(entity_limit !== undefined ? {entityLimit: entity_limit} : {}),
...(triple_limit !== undefined ? {tripleLimit: triple_limit} : {}),
},
collection,
)
return new GraphRagSuccess({text: response})
},
catch: (cause) => new GraphRagError({cause, message: toErrorMessage(cause)}),
}),
),
catch: (cause) => GraphRagError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((text) => GraphRagSuccess.make({text})),
),
document_rag: ({query, doc_limit, collection}) =>
Effect.tryPromise({
try: async () => {
const response = await socket.flow(config.flowId).documentRag(query, doc_limit, collection)
return new DocumentRagSuccess({text: response})
},
catch: (cause) => new DocumentRagError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flow(config.flowId).documentRag(query, doc_limit, collection),
catch: (cause) => DocumentRagError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((text) => DocumentRagSuccess.make({text})),
),
agent: ({question}) =>
Effect.callback<AgentSuccess, AgentError>((resume) => {
@ -1382,62 +1406,65 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
(chunk, complete) => {
fullAnswer += chunk
if (complete) {
resume(Effect.succeed(new AgentSuccess({text: fullAnswer})))
resume(Effect.succeed(AgentSuccess.make({text: fullAnswer})))
}
},
(cause) => resume(Effect.fail(new AgentError({cause, message: toErrorMessage(cause)}))),
(cause) => resume(Effect.fail(AgentError.make({cause, message: toErrorMessage(cause)}))),
)
}),
embeddings: ({text}) =>
Effect.tryPromise({
try: async () => {
const vectors = await socket.flow(config.flowId).embeddings([...text])
return new EmbeddingsSuccess({vectors})
},
catch: (cause) => new EmbeddingsError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flow(config.flowId).embeddings([...text]),
catch: (cause) => EmbeddingsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((vectors) => EmbeddingsSuccess.make({vectors})),
),
triples_query: ({s, p, o, limit, collection}) =>
Effect.tryPromise({
try: async () => {
const triples = await socket.flow(config.flowId).triplesQuery(
try: () =>
socket.flow(config.flowId).triplesQuery(
asIriTerm(s),
asIriTerm(p),
asIriTerm(o),
limit,
collection,
)
return new TriplesQuerySuccess({triples})
},
catch: (cause) => new TriplesQueryError({cause, message: toErrorMessage(cause)}),
}),
),
catch: (cause) => TriplesQueryError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((triples) => TriplesQuerySuccess.make({triples})),
),
graph_embeddings_query: ({query, limit, collection}) =>
Effect.tryPromise({
try: async () => {
const vectors = await socket.flow(config.flowId).embeddings([query])
const entities = await socket.flow(config.flowId).graphEmbeddingsQuery(
vectors[0] ?? [],
limit ?? 10,
collection,
)
return new GraphEmbeddingsQuerySuccess({entities})
},
catch: (cause) => new GraphEmbeddingsQueryError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flow(config.flowId).embeddings([query]),
catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((vectors) =>
Effect.tryPromise({
try: () => socket.flow(config.flowId).graphEmbeddingsQuery(
vectors[0] ?? [],
limit ?? 10,
collection,
),
catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}),
})
),
Effect.map((entities) => GraphEmbeddingsQuerySuccess.make({entities})),
),
get_config_all: () =>
Effect.tryPromise({
try: () => socket.config().getConfigAll(),
catch: (cause) => new GetConfigAllError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetConfigAllError({cause, message: toErrorMessage(cause)}),
(cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((config) => new GetConfigAllSuccess({config})),
Effect.map((config) => GetConfigAllSuccess.make({config})),
)
),
),
@ -1445,14 +1472,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_config: ({keys}) =>
Effect.tryPromise({
try: () => socket.config().getConfig(keys.map(({type, key}) => ({type, key}))),
catch: (cause) => new GetConfigError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetConfigError({cause, message: toErrorMessage(cause)}),
(cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((config) => new GetConfigSuccess({config})),
Effect.map((config) => GetConfigSuccess.make({config})),
)
),
),
@ -1460,14 +1487,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
put_config: ({values}) =>
Effect.tryPromise({
try: () => socket.config().putConfig(values.map(({type, key, value}) => ({type, key, value}))),
catch: (cause) => new PutConfigError({cause, message: toErrorMessage(cause)}),
catch: (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new PutConfigError({cause, message: toErrorMessage(cause)}),
(cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new PutConfigSuccess({response})),
Effect.map((response) => PutConfigSuccess.make({response})),
)
),
),
@ -1475,56 +1502,58 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
delete_config: ({type, key}) =>
Effect.tryPromise({
try: () => socket.config().deleteConfig({type, key}),
catch: (cause) => new DeleteConfigError({cause, message: toErrorMessage(cause)}),
catch: (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new DeleteConfigError({cause, message: toErrorMessage(cause)}),
(cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new DeleteConfigSuccess({response})),
Effect.map((response) => DeleteConfigSuccess.make({response})),
)
),
),
get_flows: () =>
Effect.tryPromise({
try: async () => new GetFlowsSuccess({flow_ids: await socket.flows().getFlows()}),
catch: (cause) => new GetFlowsError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.flows().getFlows(),
catch: (cause) => GetFlowsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((flow_ids) => GetFlowsSuccess.make({flow_ids})),
),
get_flow: ({flow_id}) =>
Effect.tryPromise({
try: () => socket.flows().getFlow(flow_id),
catch: (cause) => new GetFlowError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetFlowError({cause, message: toErrorMessage(cause)}),
(cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((flow) => new GetFlowSuccess({flow})),
Effect.map((flow) => GetFlowSuccess.make({flow})),
)
),
),
start_flow: ({flow_id, blueprint_name, description, parameters}) =>
Effect.tryPromise({
try: async () =>
try: () =>
socket.flows().startFlow(
flow_id,
blueprint_name,
description,
parameters === undefined ? undefined : {...parameters},
),
catch: (cause) => new StartFlowError({cause, message: toErrorMessage(cause)}),
catch: (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new StartFlowError({cause, message: toErrorMessage(cause)}),
(cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new StartFlowSuccess({response})),
Effect.map((response) => StartFlowSuccess.make({response})),
)
),
),
@ -1532,14 +1561,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
stop_flow: ({flow_id}) =>
Effect.tryPromise({
try: () => socket.flows().stopFlow(flow_id),
catch: (cause) => new StopFlowError({cause, message: toErrorMessage(cause)}),
catch: (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new StopFlowError({cause, message: toErrorMessage(cause)}),
(cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new StopFlowSuccess({response})),
Effect.map((response) => StopFlowSuccess.make({response})),
)
),
),
@ -1547,21 +1576,21 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_documents: () =>
Effect.tryPromise({
try: () => socket.librarian().getDocuments(),
catch: (cause) => new GetDocumentsError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonArrayOrFail(
value,
(cause) => new GetDocumentsError({cause, message: toErrorMessage(cause)}),
(cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((documents) => new GetDocumentsSuccess({documents})),
Effect.map((documents) => GetDocumentsSuccess.make({documents})),
)
),
),
load_document: ({document, mime_type, title, comments, tags, id}) =>
Effect.tryPromise({
try: async () =>
try: () =>
socket.librarian().loadDocument(
document,
mime_type,
@ -1570,14 +1599,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
tags === undefined ? [] : [...tags],
id,
),
catch: (cause) => new LoadDocumentError({cause, message: toErrorMessage(cause)}),
catch: (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new LoadDocumentError({cause, message: toErrorMessage(cause)}),
(cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new LoadDocumentSuccess({response})),
Effect.map((response) => LoadDocumentSuccess.make({response})),
)
),
),
@ -1585,56 +1614,60 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
remove_document: ({id, collection}) =>
Effect.tryPromise({
try: () => socket.librarian().removeDocument(id, collection),
catch: (cause) => new RemoveDocumentError({cause, message: toErrorMessage(cause)}),
catch: (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new RemoveDocumentError({cause, message: toErrorMessage(cause)}),
(cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new RemoveDocumentSuccess({response})),
Effect.map((response) => RemoveDocumentSuccess.make({response})),
)
),
),
get_prompts: () =>
Effect.tryPromise({
try: async () => new GetPromptsSuccess({prompts: await socket.config().getPrompts()}),
catch: (cause) => new GetPromptsError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.config().getPrompts(),
catch: (cause) => GetPromptsError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((prompts) => GetPromptsSuccess.make({prompts})),
),
get_prompt: ({id}) =>
Effect.tryPromise({
try: () => socket.config().getPrompt(id),
catch: (cause) => new GetPromptError({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new GetPromptError({cause, message: toErrorMessage(cause)}),
(cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((prompt) => new GetPromptSuccess({prompt})),
Effect.map((prompt) => GetPromptSuccess.make({prompt})),
)
),
),
get_knowledge_cores: () =>
Effect.tryPromise({
try: async () => new GetKnowledgeCoresSuccess({ids: await socket.knowledge().getKnowledgeCores()}),
catch: (cause) => new GetKnowledgeCoresError({cause, message: toErrorMessage(cause)}),
}),
try: () => socket.knowledge().getKnowledgeCores(),
catch: (cause) => GetKnowledgeCoresError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.map((ids) => GetKnowledgeCoresSuccess.make({ids})),
),
delete_kg_core: ({id, collection}) =>
Effect.tryPromise({
try: () => socket.knowledge().deleteKgCore(id, collection),
catch: (cause) => new DeleteKgCoreError({cause, message: toErrorMessage(cause)}),
catch: (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new DeleteKgCoreError({cause, message: toErrorMessage(cause)}),
(cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new DeleteKgCoreSuccess({response})),
Effect.map((response) => DeleteKgCoreSuccess.make({response})),
)
),
),
@ -1642,14 +1675,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
load_kg_core: ({id, flow, collection}) =>
Effect.tryPromise({
try: () => socket.knowledge().loadKgCore(id, flow, collection),
catch: (cause) => new LoadKgCoreError({cause, message: toErrorMessage(cause)}),
catch: (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => new LoadKgCoreError({cause, message: toErrorMessage(cause)}),
(cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => new LoadKgCoreSuccess({response})),
Effect.map((response) => LoadKgCoreSuccess.make({response})),
)
),
),
@ -1689,13 +1722,12 @@ export const TrustGraphMcpHttpApiRoutes = HttpApiBuilder.layer(
Layer.provide(TrustGraphMcpHttpApiHandlers),
)
export const makeTrustGraphMcpHttpLayer = (
options: TrustGraphMcpOptions = {},
const makeTrustGraphMcpHttpLayerFromConfig = (
config: TrustGraphMcpConfigShape,
) => {
const config = resolveTrustGraphMcpConfig(options)
const tools = McpServer.toolkit(TrustGraphMcpToolkit).pipe(
Layer.provide(TrustGraphMcpToolkitLive),
Layer.provide(makeOpenAiProviderLayer(config)),
Layer.provide(makeOpenAiProviderLayerFromConfig(config)),
)
return Layer.mergeAll(
@ -1708,18 +1740,31 @@ export const makeTrustGraphMcpHttpLayer = (
path: config.mcpPath,
})),
Layer.provide(TrustGraphSocket.layer),
Layer.provide(TrustGraphMcpConfig.layer(config)),
Layer.provide(Layer.succeed(TrustGraphMcpConfig, TrustGraphMcpConfig.of(config))),
)
}
export const makeTrustGraphMcpHttpServerLayer = (
options: TrustGraphMcpOptions = {},
) => {
const config = resolveTrustGraphMcpConfig(options)
return HttpRouter.serve(makeTrustGraphMcpHttpLayer(config)).pipe(
Layer.provide(BunHttpServer.layer({port: config.port})),
) =>
Layer.unwrap(
loadTrustGraphMcpConfig(options).pipe(
Effect.map((config) =>
HttpRouter.serve(makeTrustGraphMcpHttpLayerFromConfig(config)).pipe(
Layer.provide(BunHttpServer.layer({port: config.port})),
)
),
),
)
export const makeTrustGraphMcpHttpLayer = (
options: TrustGraphMcpOptions = {},
) =>
Layer.unwrap(
loadTrustGraphMcpConfig(options).pipe(
Effect.map(makeTrustGraphMcpHttpLayerFromConfig),
),
)
}
export const runHttp = (options: TrustGraphMcpOptions = {}): void => {
Layer.launch(makeTrustGraphMcpHttpServerLayer(options)).pipe(BunRuntime.runMain)

View file

@ -1,16 +1,77 @@
/**
* TrustGraph MCP server.
* TrustGraph MCP stdio compatibility server.
*
* Exposes TrustGraph capabilities as MCP tools for AI assistants.
* Uses the vendored @trustgraph/client for all gateway communication.
*
* Python reference: trustgraph-mcp/trustgraph/mcp_server/mcp.py
* This keeps the original @modelcontextprotocol/sdk entry points available,
* while moving gateway calls, callback bridging, JSON encoding, and config
* reads behind Effect values.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { createTrustGraphSocket, type BaseApi, type Term } from "@trustgraph/client";
import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js";
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
import {NodeRuntime} from "@effect/platform-node";
import {createTrustGraphSocket, type BaseApi, type Term} from "@trustgraph/client";
import {Effect, Layer, ManagedRuntime} from "effect";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import {z} from "zod";
import {loadTrustGraphMcpConfig} from "./server-effect.js";
interface ToolTextContent {
readonly type: "text"
readonly text: string
}
interface ToolTextResult extends Record<string, unknown> {
readonly content: Array<ToolTextContent>
}
class StdioMcpError extends S.TaggedErrorClass<StdioMcpError>()(
"StdioMcpError",
{
cause: S.DefectWithStack,
message: S.String,
},
) {
}
const encodeJsonText = S.encodeUnknownEffect(S.UnknownFromJsonString);
const toErrorMessage = (cause: unknown): string => {
if (Predicate.isError(cause) && cause.message.length > 0) {
return cause.message;
}
if (Predicate.isString(cause) && cause.length > 0) {
return cause;
}
if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) {
return cause.message;
}
return "TrustGraph MCP stdio operation failed";
};
const stdioMcpError = (cause: unknown) =>
StdioMcpError.make({cause, message: toErrorMessage(cause)});
const textResult = (text: string): ToolTextResult => ({
content: [{type: "text", text}],
});
const gatewayRequest = <A>(request: () => Promise<A>) =>
Effect.tryPromise({
try: request,
catch: stdioMcpError,
});
const jsonText = (value: unknown) =>
encodeJsonText(value).pipe(
Effect.mapError(stdioMcpError),
);
const runTextTool = (effect: Effect.Effect<string, StdioMcpError>) =>
Effect.runPromise(effect.pipe(Effect.map(textResult)));
const runJsonTool = (effect: Effect.Effect<unknown, StdioMcpError>) =>
Effect.runPromise(effect.pipe(Effect.flatMap(jsonText), Effect.map(textResult)));
export function createMcpServer(config: {
gatewayUrl: string;
@ -34,7 +95,6 @@ export function createMcpServer(config: {
// ===================== Flow-scoped tools =====================
// --- Text Completion ---
server.tool(
"text_completion",
"Run a text completion using the configured LLM",
@ -42,14 +102,10 @@ export function createMcpServer(config: {
system: z.string().describe("System prompt"),
prompt: z.string().describe("User prompt"),
},
async ({ system, prompt }) => {
const flow = socket.flow(flowId);
const response = await flow.textCompletion(system, prompt);
return { content: [{ type: "text" as const, text: response }] };
},
({system, prompt}) =>
runTextTool(gatewayRequest(() => socket.flow(flowId).textCompletion(system, prompt))),
);
// --- Graph RAG ---
server.tool(
"graph_rag",
"Query the knowledge graph using RAG",
@ -59,21 +115,21 @@ export function createMcpServer(config: {
triple_limit: z.number().optional().describe("Max triples per entity"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, entity_limit, triple_limit, collection }) => {
const flow = socket.flow(flowId);
const response = await flow.graphRag(
query,
{
...(entity_limit !== undefined ? { entityLimit: entity_limit } : {}),
...(triple_limit !== undefined ? { tripleLimit: triple_limit } : {}),
},
collection,
);
return { content: [{ type: "text" as const, text: response }] };
},
({query, entity_limit, triple_limit, collection}) =>
runTextTool(
gatewayRequest(() =>
socket.flow(flowId).graphRag(
query,
{
...(entity_limit !== undefined ? {entityLimit: entity_limit} : {}),
...(triple_limit !== undefined ? {tripleLimit: triple_limit} : {}),
},
collection,
)
),
),
);
// --- Document RAG ---
server.tool(
"document_rag",
"Query documents using RAG",
@ -82,56 +138,45 @@ export function createMcpServer(config: {
doc_limit: z.number().optional().describe("Max documents to retrieve"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, doc_limit, collection }) => {
const flow = socket.flow(flowId);
const response = await flow.documentRag(query, doc_limit, collection);
return { content: [{ type: "text" as const, text: response }] };
},
({query, doc_limit, collection}) =>
runTextTool(gatewayRequest(() => socket.flow(flowId).documentRag(query, doc_limit, collection))),
);
// --- Agent ---
server.tool(
"agent",
"Ask the TrustGraph agent a question",
{
question: z.string().describe("Question for the agent"),
},
async ({ question }) => {
const flow = socket.flow(flowId);
let fullAnswer = "";
await new Promise<void>((resolve, reject) => {
flow.agent(
question,
() => {}, // think — ignore for MCP
() => {}, // observe — ignore for MCP
(chunk, complete) => {
fullAnswer += chunk;
if (complete) resolve();
},
(err) => reject(new Error(err)),
);
});
return { content: [{ type: "text" as const, text: fullAnswer }] };
},
({question}) =>
runTextTool(
Effect.callback<string, StdioMcpError>((resume) => {
let fullAnswer = "";
socket.flow(flowId).agent(
question,
() => {},
() => {},
(chunk, complete) => {
fullAnswer += chunk;
if (complete) {
resume(Effect.succeed(fullAnswer));
}
},
(cause) => resume(Effect.fail(stdioMcpError(cause))),
);
}),
),
);
// --- Embeddings ---
server.tool(
"embeddings",
"Generate text embeddings",
{
text: z.array(z.string()).describe("Texts to embed"),
},
async ({ text }) => {
const flow = socket.flow(flowId);
const vectors = await flow.embeddings(text);
return { content: [{ type: "text" as const, text: JSON.stringify(vectors) }] };
},
({text}) => runJsonTool(gatewayRequest(() => socket.flow(flowId).embeddings(text))),
);
// --- Triples Query ---
server.tool(
"triples_query",
"Query the knowledge graph for triples matching a pattern",
@ -142,17 +187,16 @@ export function createMcpServer(config: {
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
async ({ s, p, o, limit, collection }) => {
const flow = socket.flow(flowId);
const sTerm: Term | undefined = s !== undefined && s.length > 0 ? { t: "i", i: s } : undefined;
const pTerm: Term | undefined = p !== undefined && p.length > 0 ? { t: "i", i: p } : undefined;
const oTerm: Term | undefined = o !== undefined && o.length > 0 ? { t: "i", i: o } : undefined;
const triples = await flow.triplesQuery(sTerm, pTerm, oTerm, limit, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(triples, null, 2) }] };
({s, p, o, limit, collection}) => {
const sTerm: Term | undefined = s !== undefined && s.length > 0 ? {t: "i", i: s} : undefined;
const pTerm: Term | undefined = p !== undefined && p.length > 0 ? {t: "i", i: p} : undefined;
const oTerm: Term | undefined = o !== undefined && o.length > 0 ? {t: "i", i: o} : undefined;
return runJsonTool(
gatewayRequest(() => socket.flow(flowId).triplesQuery(sTerm, pTerm, oTerm, limit, collection)),
);
},
);
// --- Graph Embeddings Query ---
server.tool(
"graph_embeddings_query",
"Find entities similar to a text query using vector embeddings",
@ -161,17 +205,20 @@ export function createMcpServer(config: {
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
async ({ query, limit, collection }) => {
const flow = socket.flow(flowId);
// First embed the query, then search
const vectors = await flow.embeddings([query]);
const entities = await flow.graphEmbeddingsQuery(
vectors[0],
limit ?? 10,
collection,
);
return { content: [{ type: "text" as const, text: JSON.stringify(entities, null, 2) }] };
},
({query, limit, collection}) =>
runJsonTool(
gatewayRequest(() => socket.flow(flowId).embeddings([query])).pipe(
Effect.flatMap((vectors) =>
gatewayRequest(() =>
socket.flow(flowId).graphEmbeddingsQuery(
vectors[0] ?? [],
limit ?? 10,
collection,
)
)
),
),
),
);
// ===================== Config tools =====================
@ -180,11 +227,7 @@ export function createMcpServer(config: {
"get_config_all",
"Get all configuration values",
{},
async () => {
const cfg = socket.config();
const resp = await cfg.getConfigAll();
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.config().getConfigAll())),
);
server.tool(
@ -198,11 +241,7 @@ export function createMcpServer(config: {
}),
).describe("Config keys to retrieve"),
},
async ({ keys }) => {
const cfg = socket.config();
const resp = await cfg.getConfig(keys);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({keys}) => runJsonTool(gatewayRequest(() => socket.config().getConfig(keys))),
);
server.tool(
@ -217,11 +256,7 @@ export function createMcpServer(config: {
}),
).describe("Key-value entries to set"),
},
async ({ values }) => {
const cfg = socket.config();
const resp = await cfg.putConfig(values);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({values}) => runJsonTool(gatewayRequest(() => socket.config().putConfig(values))),
);
server.tool(
@ -231,11 +266,7 @@ export function createMcpServer(config: {
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
},
async ({ type, key }) => {
const cfg = socket.config();
const resp = await cfg.deleteConfig({ type, key });
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({type, key}) => runJsonTool(gatewayRequest(() => socket.config().deleteConfig({type, key}))),
);
// ===================== Flow management tools =====================
@ -244,11 +275,7 @@ export function createMcpServer(config: {
"get_flows",
"List all available flows",
{},
async () => {
const flows = socket.flows();
const ids = await flows.getFlows();
return { content: [{ type: "text" as const, text: JSON.stringify(ids, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.flows().getFlows())),
);
server.tool(
@ -257,11 +284,7 @@ export function createMcpServer(config: {
{
flow_id: z.string().describe("Flow ID to retrieve"),
},
async ({ flow_id }) => {
const flows = socket.flows();
const def = await flows.getFlow(flow_id);
return { content: [{ type: "text" as const, text: JSON.stringify(def, null, 2) }] };
},
({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().getFlow(flow_id))),
);
server.tool(
@ -273,11 +296,10 @@ export function createMcpServer(config: {
description: z.string().describe("Flow description"),
parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"),
},
async ({ flow_id, blueprint_name, description, parameters }) => {
const flows = socket.flows();
const resp = await flows.startFlow(flow_id, blueprint_name, description, parameters);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({flow_id, blueprint_name, description, parameters}) =>
runJsonTool(
gatewayRequest(() => socket.flows().startFlow(flow_id, blueprint_name, description, parameters)),
),
);
server.tool(
@ -286,11 +308,7 @@ export function createMcpServer(config: {
{
flow_id: z.string().describe("Flow ID to stop"),
},
async ({ flow_id }) => {
const flows = socket.flows();
const resp = await flows.stopFlow(flow_id);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().stopFlow(flow_id))),
);
// ===================== Library (document) tools =====================
@ -299,11 +317,7 @@ export function createMcpServer(config: {
"get_documents",
"List all documents in the library",
{},
async () => {
const lib = socket.librarian();
const docs = await lib.getDocuments();
return { content: [{ type: "text" as const, text: JSON.stringify(docs, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.librarian().getDocuments())),
);
server.tool(
@ -317,18 +331,19 @@ export function createMcpServer(config: {
tags: z.array(z.string()).optional().describe("Document tags"),
id: z.string().optional().describe("Optional document ID"),
},
async ({ document, mime_type, title, comments, tags, id }) => {
const lib = socket.librarian();
const resp = await lib.loadDocument(
document,
mime_type,
title,
comments ?? "",
tags ?? [],
id,
);
return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] };
},
({document, mime_type, title, comments, tags, id}) =>
runJsonTool(
gatewayRequest(() =>
socket.librarian().loadDocument(
document,
mime_type,
title,
comments ?? "",
tags ?? [],
id,
)
),
),
);
server.tool(
@ -338,11 +353,7 @@ export function createMcpServer(config: {
id: z.string().describe("Document ID to remove"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, collection }) => {
const lib = socket.librarian();
const resp = await lib.removeDocument(id, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({id, collection}) => runJsonTool(gatewayRequest(() => socket.librarian().removeDocument(id, collection))),
);
// ===================== Prompt tools =====================
@ -351,11 +362,7 @@ export function createMcpServer(config: {
"get_prompts",
"List available prompt templates",
{},
async () => {
const cfg = socket.config();
const prompts = await cfg.getPrompts();
return { content: [{ type: "text" as const, text: JSON.stringify(prompts, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.config().getPrompts())),
);
server.tool(
@ -364,11 +371,7 @@ export function createMcpServer(config: {
{
id: z.string().describe("Prompt template ID"),
},
async ({ id }) => {
const cfg = socket.config();
const prompt = await cfg.getPrompt(id);
return { content: [{ type: "text" as const, text: JSON.stringify(prompt, null, 2) }] };
},
({id}) => runJsonTool(gatewayRequest(() => socket.config().getPrompt(id))),
);
// ===================== Knowledge core tools =====================
@ -377,11 +380,7 @@ export function createMcpServer(config: {
"get_knowledge_cores",
"List available knowledge graph cores",
{},
async () => {
const knowledge = socket.knowledge();
const cores = await knowledge.getKnowledgeCores();
return { content: [{ type: "text" as const, text: JSON.stringify(cores, null, 2) }] };
},
() => runJsonTool(gatewayRequest(() => socket.knowledge().getKnowledgeCores())),
);
server.tool(
@ -391,11 +390,7 @@ export function createMcpServer(config: {
id: z.string().describe("Knowledge core ID"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, collection }) => {
const knowledge = socket.knowledge();
const resp = await knowledge.deleteKgCore(id, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({id, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().deleteKgCore(id, collection))),
);
server.tool(
@ -406,31 +401,42 @@ export function createMcpServer(config: {
flow: z.string().describe("Flow to use for loading"),
collection: z.string().optional().describe("Collection name"),
},
async ({ id, flow, collection }) => {
const knowledge = socket.knowledge();
const resp = await knowledge.loadKgCore(id, flow, collection);
return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] };
},
({id, flow, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().loadKgCore(id, flow, collection))),
);
return { server, socket };
return {server, socket};
}
export async function run(): Promise<void> {
const { server, socket } = createMcpServer({
gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc",
user: process.env.USER_ID ?? "mcp",
flowId: process.env.FLOW_ID ?? "default",
...(process.env.GATEWAY_SECRET !== undefined
? { token: process.env.GATEWAY_SECRET }
: {}),
});
export const runProgram = Effect.gen(function*() {
const config = yield* loadTrustGraphMcpConfig();
const serverConfig = {
gatewayUrl: config.gatewayUrl,
user: config.user,
flowId: config.flowId,
...(config.token === undefined ? {} : {token: config.token}),
};
const {server, socket} = createMcpServer(serverConfig);
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("SIGINT", () => {
socket.close();
process.exit(0);
yield* Effect.tryPromise({
try: () => server.connect(transport),
catch: stdioMcpError,
});
yield* Effect.sync(() => {
process.on("SIGINT", () => {
socket.close();
process.exit(0);
});
});
});
const stdioRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return stdioRuntime.runPromise(runProgram);
}
export function runMain(): void {
NodeRuntime.runMain(runProgram);
}