refactor(ts): make port effect native

This commit is contained in:
elpresidank 2026-06-06 10:33:10 -05:00
parent 2868ced2d3
commit b6759e75df
113 changed files with 4140 additions and 4554 deletions

View file

@ -20,19 +20,19 @@
"test": "bunx --bun vitest run"
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.75",
"@effect/ai-openai": "4.0.0-beta.75",
"@effect/ai-openrouter": "4.0.0-beta.75",
"@effect/atom-react": "4.0.0-beta.75",
"@effect/openapi-generator": "4.0.0-beta.75",
"@effect/opentelemetry": "4.0.0-beta.75",
"@effect/platform-browser": "4.0.0-beta.75",
"@effect/platform-bun": "4.0.0-beta.75",
"effect": "4.0.0-beta.75",
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"effect": "4.0.0-beta.78",
"nats": "^2.29.0"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.75",
"@effect/vitest": "4.0.0-beta.78",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6"

View file

@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Effect } from "effect";
import { makeConsumer, type ConsumerOptions, type FlowContext } from "../messaging/consumer.js";
import type {
PubSubBackend,
@ -25,14 +26,17 @@ function createMockBackendConsumer<T>(): BackendConsumer<T> & {
acknowledge: ReturnType<typeof vi.fn>;
negativeAcknowledge: ReturnType<typeof vi.fn>;
unsubscribe: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
close: Effect.Effect<void>;
closeMock: ReturnType<typeof vi.fn>;
} {
const closeMock = vi.fn();
return {
receive: vi.fn().mockResolvedValue(null),
acknowledge: vi.fn().mockResolvedValue(undefined),
negativeAcknowledge: vi.fn().mockResolvedValue(undefined),
unsubscribe: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
receive: vi.fn().mockReturnValue(Effect.succeed(null)),
acknowledge: vi.fn().mockReturnValue(Effect.void),
negativeAcknowledge: vi.fn().mockReturnValue(Effect.void),
unsubscribe: vi.fn().mockReturnValue(Effect.void),
close: Effect.sync(closeMock),
closeMock,
};
}
@ -41,9 +45,9 @@ function createMockPubSub<T>(
backendConsumer: BackendConsumer<T>,
): PubSubBackend {
return {
createProducer: vi.fn().mockResolvedValue({} as BackendProducer<unknown>),
createConsumer: vi.fn().mockResolvedValue(backendConsumer),
close: vi.fn().mockResolvedValue(undefined),
createProducer: vi.fn().mockReturnValue(Effect.succeed({} as BackendProducer<unknown>)),
createConsumer: vi.fn().mockReturnValue(Effect.succeed(backendConsumer)),
close: Effect.void,
};
}
@ -94,7 +98,7 @@ describe("Consumer", () => {
expect(consumer).toMatchObject({
start: expect.any(Function),
stop: expect.any(Function),
stop: expect.any(Object),
});
});
@ -111,13 +115,13 @@ describe("Consumer", () => {
expect(consumer).toMatchObject({
start: expect.any(Function),
stop: expect.any(Function),
stop: expect.any(Object),
});
});
// ── start() creates consumer and calls handler ─────────────────
it("starts a scoped consumer and invokes handler for received messages", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const handler = vi.fn().mockReturnValue(Effect.void);
const msg = createMockMessage({ data: "hello" }, { id: "1" });
const consumer = makeConsumer({
@ -127,11 +131,11 @@ describe("Consumer", () => {
handler,
});
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
await consumer.start(flowCtx);
await Effect.runPromise(consumer.start(flowCtx));
await advanceUntil(() => handler.mock.calls.length > 0);
await consumer.stop();
await Effect.runPromise(consumer.stop);
expect(pubsub.createConsumer).toHaveBeenCalledWith({
topic: "topic-a",
@ -143,7 +147,7 @@ describe("Consumer", () => {
// ── Messages are acknowledged after successful handling ────────
it("acknowledges messages after successful handling", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const handler = vi.fn().mockReturnValue(Effect.void);
const msg = createMockMessage("payload");
const consumer = makeConsumer({
@ -153,11 +157,11 @@ describe("Consumer", () => {
handler,
});
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
await consumer.start(flowCtx);
await Effect.runPromise(consumer.start(flowCtx));
await advanceUntil(() => backendConsumer.acknowledge.mock.calls.length > 0);
await consumer.stop();
await Effect.runPromise(consumer.stop);
expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg);
expect(backendConsumer.negativeAcknowledge).not.toHaveBeenCalled();
@ -165,7 +169,7 @@ describe("Consumer", () => {
// ── Messages are negatively acknowledged on handler error ──────
it("negatively acknowledges messages when the handler throws", async () => {
const handler = vi.fn().mockRejectedValue("handler boom");
const handler = vi.fn().mockReturnValue(Effect.fail("handler boom"));
const msg = createMockMessage("bad-payload");
const consumer = makeConsumer({
@ -175,11 +179,11 @@ describe("Consumer", () => {
handler,
});
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
await consumer.start(flowCtx);
await Effect.runPromise(consumer.start(flowCtx));
await advanceUntil(() => backendConsumer.negativeAcknowledge.mock.calls.length > 0);
await consumer.stop();
await Effect.runPromise(consumer.stop);
expect(backendConsumer.negativeAcknowledge).toHaveBeenCalledWith(msg);
expect(backendConsumer.acknowledge).not.toHaveBeenCalled();
@ -188,12 +192,17 @@ describe("Consumer", () => {
// ── TooManyRequestsError triggers retry ────────────────────────
it("retries the handler on TooManyRequestsError", async () => {
let handlerCalls = 0;
const handler = vi.fn().mockImplementation(async () => {
handlerCalls++;
if (handlerCalls === 1) {
throw tooManyRequestsError("rate limited");
}
// Second call succeeds
const handler = vi.fn().mockImplementation(() => {
return Effect.sync(() => {
handlerCalls++;
return handlerCalls;
}).pipe(
Effect.flatMap((attempt) =>
attempt === 1
? Effect.fail(tooManyRequestsError("rate limited"))
: Effect.void
),
);
});
const msg = createMockMessage("rate-limited-payload");
@ -206,17 +215,17 @@ describe("Consumer", () => {
rateLimitRetryMs: 500,
});
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await consumer.start(flowCtx);
await Effect.runPromise(consumer.start(flowCtx));
await vi.advanceTimersByTimeAsync(600);
await advanceUntil(() => handler.mock.calls.length >= 2);
await consumer.stop();
await Effect.runPromise(consumer.stop);
// Handler called twice: first throws TooManyRequestsError, second succeeds
expect(handler).toHaveBeenCalledTimes(2);
expect(handlerCalls).toBe(2);
// Message should be acknowledged (retry succeeded)
expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg);
@ -225,11 +234,17 @@ describe("Consumer", () => {
it("retries repeated TooManyRequestsError until success within the timeout", async () => {
let handlerCalls = 0;
const handler = vi.fn().mockImplementation(async () => {
handlerCalls++;
if (handlerCalls <= 2) {
throw tooManyRequestsError("rate limited");
}
const handler = vi.fn().mockImplementation(() => {
return Effect.sync(() => {
handlerCalls++;
return handlerCalls;
}).pipe(
Effect.flatMap((attempt) =>
attempt <= 2
? Effect.fail(tooManyRequestsError("rate limited"))
: Effect.void
),
);
});
const msg = createMockMessage("rate-limited-payload");
@ -243,22 +258,27 @@ describe("Consumer", () => {
rateLimitTimeoutMs: 2_000,
});
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
await consumer.start(flowCtx);
await Effect.runPromise(consumer.start(flowCtx));
await vi.advanceTimersByTimeAsync(1_100);
await advanceUntil(() => backendConsumer.acknowledge.mock.calls.length > 0);
await consumer.stop();
await Effect.runPromise(consumer.stop);
expect(handler).toHaveBeenCalledTimes(3);
expect(handlerCalls).toBe(3);
expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg);
expect(backendConsumer.negativeAcknowledge).not.toHaveBeenCalled();
});
it("negatively acknowledges when rate-limit retry timeout elapses", async () => {
const handler = vi.fn().mockImplementation(async () => {
throw tooManyRequestsError("rate limited");
});
let handlerCalls = 0;
const handler = vi.fn().mockReturnValue(
Effect.sync(() => {
handlerCalls++;
}).pipe(
Effect.flatMap(() => Effect.fail(tooManyRequestsError("rate limited"))),
),
);
const msg = createMockMessage("rate-limited-payload");
const consumer = makeConsumer({
@ -270,21 +290,21 @@ describe("Consumer", () => {
rateLimitTimeoutMs: 1_000,
});
backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null);
backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null));
await consumer.start(flowCtx);
await Effect.runPromise(consumer.start(flowCtx));
await vi.advanceTimersByTimeAsync(1_100);
await advanceUntil(() => backendConsumer.negativeAcknowledge.mock.calls.length > 0);
await consumer.stop();
await Effect.runPromise(consumer.stop);
expect(handler).toHaveBeenCalledTimes(2);
expect(handlerCalls).toBeGreaterThanOrEqual(2);
expect(backendConsumer.negativeAcknowledge).toHaveBeenCalledWith(msg);
expect(backendConsumer.acknowledge).not.toHaveBeenCalled();
});
// ── stop() closes the backend ──────────────────────────────────
it("stop() sets running=false and closes the backend", async () => {
backendConsumer.receive.mockResolvedValue(null);
backendConsumer.receive.mockReturnValue(Effect.succeed(null));
const consumer = makeConsumer({
pubsub,
@ -293,10 +313,10 @@ describe("Consumer", () => {
handler: vi.fn(),
});
await consumer.start(flowCtx);
await consumer.stop();
await Effect.runPromise(consumer.start(flowCtx));
await Effect.runPromise(consumer.stop);
expect(backendConsumer.close).toHaveBeenCalled();
await expect(consumer.stop()).resolves.toBeUndefined();
expect(backendConsumer.closeMock).toHaveBeenCalled();
await expect(Effect.runPromise(consumer.stop)).resolves.toBeUndefined();
});
});

View file

@ -62,13 +62,15 @@ const waitFor = (condition: () => boolean, label: string) =>
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
});
}
async flush(): Promise<void> {}
readonly flush: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
class PushConsumer<T> implements BackendConsumer<T> {
@ -87,32 +89,38 @@ class PushConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return message ?? null;
}
return await new Promise((resolve) => {
this.waiters.push(resolve);
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return Promise.resolve(message ?? null);
}
return new Promise((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
}
});
}
class EmbeddingsBackend implements PubSubBackend {
@ -121,24 +129,28 @@ class EmbeddingsBackend implements PubSubBackend {
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
closeCount = 0;
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
const producer = new RecordingProducer<unknown>();
this.producersByTopic.set(options.topic, producer);
return producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
const producer = new RecordingProducer<unknown>();
this.producersByTopic.set(options.topic, producer);
return producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
const consumer = new PushConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
return consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
const consumer = new PushConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
return consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
pushConfig(): void {
this.configConsumer.push(

View file

@ -58,17 +58,19 @@ class RecordingProducer<T> implements BackendProducer<T> {
closeCount = 0;
flushCount = 0;
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class PushConsumer<T> implements BackendConsumer<T> {
@ -88,33 +90,39 @@ class PushConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return message ?? null;
}
return await new Promise((resolve) => {
this.waiters.push(resolve);
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return Promise.resolve(message ?? null);
}
return new Promise((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
});
}
class FlowProcessorBackend implements PubSubBackend {
@ -124,24 +132,28 @@ class FlowProcessorBackend implements PubSubBackend {
readonly producers: Array<RecordingProducer<unknown>> = [];
closeCount = 0;
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions.push(options);
const producer = new RecordingProducer<unknown>();
this.producers.push(producer);
return producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions.push(options);
const producer = new RecordingProducer<unknown>();
this.producers.push(producer);
return producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions.push(options);
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
return new PushConsumer<T>();
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions.push(options);
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
return new PushConsumer<T>();
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
pushConfig(version: number, flows: Record<string, unknown>): void {
this.pushFlowConfig(version, flows);
@ -159,9 +171,9 @@ class TestFlowProcessor extends FlowProcessor {
) {
super(config);
this.registerSpecification(makeProducerSpec<string>("output"));
this.registerConfigHandler(async (_config, version) => {
this.registerConfigHandler((_config, version) => Effect.sync(() => {
this.events.push(`handler:${version}`);
});
}));
}
}

View file

@ -4,7 +4,6 @@ import * as S from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import {
makeConsumerSpec,
makeConsumerSpecFromPromise,
Flow,
MessagingRuntimeLive,
makeParameterSpec,
@ -34,18 +33,20 @@ class RecordingProducer<T> implements BackendProducer<T> {
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class ScriptedConsumer<T> implements BackendConsumer<T> {
@ -72,33 +73,39 @@ class ScriptedConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || !this.waitForMessages || this.closed) {
return message ?? null;
}
return await new Promise((resolve) => {
this.waiters.push(resolve);
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || !this.waitForMessages || this.closed) {
return Promise.resolve(message ?? null);
}
return new Promise((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
});
}
class RuntimeBackend implements PubSubBackend {
@ -114,19 +121,23 @@ class RuntimeBackend implements PubSubBackend {
this.producer = new RecordingProducer<unknown>(onSend);
}
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
const fastMessagingConfig = ConfigProvider.layer(
@ -187,7 +198,7 @@ describe("Effect-native flow specifications", () => {
);
it.effect(
"runs Promise handlers through the explicit makeConsumerSpec compatibility helper",
"runs Effect handlers through makeConsumerSpec",
Effect.fnUntraced(function* () {
const message = createMessage("payload", { id: "request-1" });
const consumer = new ScriptedConsumer<string>([message]);
@ -199,11 +210,11 @@ describe("Effect-native flow specifications", () => {
backend,
{},
[
makeConsumerSpecFromPromise<string>(
makeConsumerSpec<string>(
"input",
async (value, properties, flowContext: FlowContext) => {
(value, properties, flowContext: FlowContext) => Effect.sync(() => {
handled.push(`${flowContext.name}:${properties.id}:${value}`);
},
}),
),
],
);
@ -226,7 +237,7 @@ describe("Effect-native flow specifications", () => {
);
it.effect(
"registers request-response specs through Effect queues and keeps the Promise facade working",
"registers request-response specs through Effect queues and exposes Effect requestors",
Effect.fnUntraced(function* () {
const responseConsumer = new ScriptedConsumer<string>([], true);
const backend = new RuntimeBackend(
@ -257,10 +268,8 @@ describe("Effect-native flow specifications", () => {
yield* flow.startEffect;
const duplicateSpecError = yield* flow.requestorEffect(duplicateRequestResponseSpec).pipe(Effect.flip);
expect(duplicateSpecError._tag).toBe("FlowResourceNotFoundError");
const requestor = flow.requestor(requestResponseSpec);
const fiber = yield* Effect.promise(() =>
requestor.request("request", { timeoutMs: 250 }),
).pipe(Effect.forkChild);
const requestor = yield* flow.requestor(requestResponseSpec);
const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild);
yield* TestClock.adjust(Duration.millis(5));
return yield* Fiber.join(fiber);
}),
@ -299,7 +308,8 @@ describe("Effect-native flow specifications", () => {
const legacyParameter = yield* flow.parameterEffect("present");
const parameterError = yield* flow.parameterEffect("missing-parameter").pipe(Effect.flip);
const invalidParameterError = yield* flow.parameterEffect(invalidParameter).pipe(Effect.flip);
return { producerError, parameter, legacyParameter, parameterError, invalidParameterError };
const legacyProducerError = yield* flow.producer("missing-producer").pipe(Effect.flip);
return { producerError, legacyProducerError, parameter, legacyParameter, parameterError, invalidParameterError };
}),
),
);
@ -313,10 +323,10 @@ describe("Effect-native flow specifications", () => {
expect(errors.parameterError.resourceType).toBe("parameter");
expect(errors.invalidParameterError._tag).toBe("FlowParameterDecodeError");
expect(errors.invalidParameterError.parameterName).toBe("present");
expect(errors.legacyProducerError._tag).toBe("FlowResourceNotFoundError");
expect(flow.parameter(presentParameter)).toBe(42);
expect(flow.parameter("present")).toBe(42);
expect(() => flow.parameter(invalidParameter)).toThrow("failed schema decoding");
expect(() => flow.producer("missing-producer")).toThrow("not found");
}),
);
});

View file

@ -36,18 +36,20 @@ class RecordingProducer<T> implements BackendProducer<T> {
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class ScriptedConsumer<T> implements BackendConsumer<T> {
@ -64,27 +66,33 @@ class ScriptedConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined) {
return message;
}
return null;
receive(): Effect.Effect<Message<T> | null> {
return Effect.sync(() => {
const message = this.messages.shift();
if (message !== undefined) {
return message;
}
return null;
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class RuntimeBackend implements PubSubBackend {
@ -100,19 +108,23 @@ class RuntimeBackend implements PubSubBackend {
this.producer = new RecordingProducer<unknown>(onSend);
}
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class ConsumerHandle {
@ -123,31 +135,33 @@ class ConcurrentConsumerBackend implements PubSubBackend {
readonly consumerOptions: Array<CreateConsumerOptions> = [];
readonly consumers: Array<ConsumerHandle> = [];
async createProducer<T>(_options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
return {
send: async () => {},
flush: async () => {},
close: async () => {},
};
createProducer<T>(_options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.succeed({
send: () => Effect.void,
flush: Effect.void,
close: Effect.void,
});
}
async createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>> {
const handle = new ConsumerHandle();
this.consumerOptions.push(options);
this.consumers.push(handle);
createConsumer<T>(options: CreateConsumerOptions<T>): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
const handle = new ConsumerHandle();
this.consumerOptions.push(options);
this.consumers.push(handle);
return {
receive: async () => null,
acknowledge: async () => {},
negativeAcknowledge: async () => {},
unsubscribe: async () => {},
close: async () => {
handle.closeCount += 1;
},
};
return {
receive: () => Effect.succeed(null),
acknowledge: () => Effect.void,
negativeAcknowledge: () => Effect.void,
unsubscribe: Effect.void,
close: Effect.sync(() => {
handle.closeCount += 1;
}),
};
});
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
const flowContext: FlowContext = {

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Effect } from "effect";
import { makeNatsBackend } from "../backend/nats.js";
const natsMock = vi.hoisted(() => {
@ -34,6 +35,7 @@ const natsMock = vi.hoisted(() => {
const publish = vi.fn();
const consumersGet = vi.fn();
const consumersInfo = vi.fn();
const consumersAdd = vi.fn();
const streamsInfo = vi.fn();
const streamsAdd = vi.fn();
@ -50,6 +52,7 @@ const natsMock = vi.hoisted(() => {
connect,
consumersAdd,
consumersGet,
consumersInfo,
decoder,
drain,
encoder,
@ -86,6 +89,7 @@ function resetNatsMock(): void {
natsMock.publish.mockResolvedValue({ duplicate: false, seq: 1, stream: "tg_test" });
natsMock.consumersGet.mockResolvedValue({ next: natsMock.next });
natsMock.consumersInfo.mockResolvedValue({ name: "worker" });
natsMock.consumersAdd.mockResolvedValue(undefined);
natsMock.streamsInfo.mockResolvedValue({ config: { name: "tg_test" } });
natsMock.streamsAdd.mockResolvedValue(undefined);
@ -108,7 +112,7 @@ function resetNatsMock(): void {
}),
jetstreamManager: () =>
Promise.resolve({
consumers: { add: natsMock.consumersAdd },
consumers: { add: natsMock.consumersAdd, info: natsMock.consumersInfo },
streams: {
add: natsMock.streamsAdd,
info: natsMock.streamsInfo,
@ -126,7 +130,7 @@ describe("NATS backend", () => {
natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("404", 404));
const backend = makeNatsBackend("nats://test");
await backend.createProducer<string>({ topic: "tg.test.topic" });
await Effect.runPromise(backend.createProducer<string>({ topic: "tg.test.topic" }));
expect(natsMock.streamsAdd).toHaveBeenCalledWith({
name: "tg_test",
@ -137,11 +141,11 @@ describe("NATS backend", () => {
it("caches initialized streams through the Effect mutable set", async () => {
const backend = makeNatsBackend("nats://test");
await backend.createProducer<string>({ topic: "tg.test.topic" });
await backend.createConsumer<string>({
await Effect.runPromise(backend.createProducer<string>({ topic: "tg.test.topic" }));
await Effect.runPromise(backend.createConsumer<string>({
topic: "tg.test.other",
subscription: "worker",
});
}));
expect(natsMock.streamsInfo).toHaveBeenCalledTimes(1);
expect(natsMock.streamsInfo).toHaveBeenCalledWith("tg_test");
@ -151,7 +155,9 @@ describe("NATS backend", () => {
natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
const backend = makeNatsBackend("nats://test");
const error = await backend.createProducer<string>({ topic: "tg.test.topic" }).catch((caught: unknown) => caught);
const error = await Effect.runPromise(
backend.createProducer<string>({ topic: "tg.test.topic" }),
).catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "PubSubError",
@ -161,15 +167,13 @@ describe("NATS backend", () => {
});
it("creates durable consumers only when consumer lookup returns a JetStream 404", async () => {
natsMock.consumersGet
.mockRejectedValueOnce(makeNatsError("404", 404))
.mockResolvedValueOnce({ next: natsMock.next });
natsMock.consumersInfo.mockRejectedValueOnce(makeNatsError("404", 404));
const backend = makeNatsBackend("nats://test");
await backend.createConsumer<string>({
await Effect.runPromise(backend.createConsumer<string>({
topic: "tg.test.topic",
subscription: "worker",
});
}));
expect(natsMock.consumersAdd).toHaveBeenCalledWith("tg_test", {
ack_policy: "explicit",
@ -180,13 +184,13 @@ describe("NATS backend", () => {
});
it("does not create durable consumers for non-missing lookup failures", async () => {
natsMock.consumersGet.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
natsMock.consumersInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
const backend = makeNatsBackend("nats://test");
const error = await backend.createConsumer<string>({
const error = await Effect.runPromise(backend.createConsumer<string>({
topic: "tg.test.topic",
subscription: "worker",
}).catch((caught: unknown) => caught);
})).catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "PubSubError",
@ -200,9 +204,11 @@ describe("NATS backend", () => {
throw "invalid header";
});
const backend = makeNatsBackend("nats://test");
const producer = await backend.createProducer<string>({ topic: "tg.test.topic" });
const producer = await Effect.runPromise(backend.createProducer<string>({ topic: "tg.test.topic" }));
const error = await producer.send("hello", { bad: "value" }).catch((caught: unknown) => caught);
const error = await Effect.runPromise(
producer.send("hello", { bad: "value" }),
).catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "PubSubError",
@ -219,19 +225,23 @@ describe("NATS backend", () => {
throw "nak failed";
});
const backend = makeNatsBackend("nats://test");
const consumer = await backend.createConsumer<string>({
const consumer = await Effect.runPromise(backend.createConsumer<string>({
topic: "tg.test.topic",
subscription: "worker",
});
const message = await consumer.receive(1);
}));
const message = await Effect.runPromise(consumer.receive(1));
expect(message).not.toBeNull();
if (message === null) {
return;
}
const ackError = await consumer.acknowledge(message).catch((caught: unknown) => caught);
const nakError = await consumer.negativeAcknowledge(message).catch((caught: unknown) => caught);
const ackError = await Effect.runPromise(
consumer.acknowledge(message),
).catch((caught: unknown) => caught);
const nakError = await Effect.runPromise(
consumer.negativeAcknowledge(message),
).catch((caught: unknown) => caught);
expect(ackError).toMatchObject({
_tag: "PubSubError",

View file

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { Effect } from "effect";
import {
makeProducer,
pubSubError,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
@ -15,30 +17,33 @@ class ProducerBackend implements PubSubBackend {
flushCount = 0;
failFlush = false;
async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
this.producerTopics.push(options.topic);
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerTopics.push(options.topic);
return {
send: async (message, properties) => {
return {
send: (message, properties) => Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
},
flush: async () => {
this.flushCount += 1;
if (this.failFlush) {
return Promise.reject("flush failed");
}
},
close: async () => {
this.closeCount += 1;
},
};
}),
flush: Effect.suspend(() => {
this.flushCount += 1;
if (this.failFlush) {
return Effect.fail(pubSubError("flush", "flush failed"));
}
return Effect.void;
}),
close: Effect.sync(() => {
this.closeCount += 1;
}),
};
});
}
createConsumer<T>(_options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>> {
return Promise.reject("consumer not supported");
createConsumer<T>(_options: CreateConsumerOptions<T>): Effect.Effect<BackendConsumer<T>> {
return Effect.fail(pubSubError("create-consumer", "consumer not supported"));
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
describe("Producer", () => {
@ -46,9 +51,9 @@ describe("Producer", () => {
const backend = new ProducerBackend();
const producer = makeProducer<string>(backend, "tg.test.producer");
await producer.start();
await producer.send("message-1", "hello");
await producer.stop();
await Effect.runPromise(producer.start);
await Effect.runPromise(producer.send("message-1", "hello"));
await Effect.runPromise(producer.stop);
expect(backend.producerTopics).toEqual(["tg.test.producer"]);
expect(backend.sent).toEqual([
@ -56,9 +61,11 @@ describe("Producer", () => {
]);
expect(backend.flushCount).toBe(1);
expect(backend.closeCount).toBe(1);
await expect(producer.stop()).resolves.toBeUndefined();
await expect(Effect.runPromise(producer.stop)).resolves.toBeUndefined();
const error = await producer.send("message-2", "late").catch((caught: unknown) => caught);
const error = await Effect.runPromise(
producer.send("message-2", "late"),
).catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "MessagingLifecycleError",
operation: "send",
@ -70,10 +77,10 @@ describe("Producer", () => {
const backend = new ProducerBackend();
const producer = makeProducer<string>(backend, "tg.test.producer");
await producer.start();
await Effect.runPromise(producer.start);
backend.failFlush = true;
const error = await producer.stop().catch((caught: unknown) => caught);
const error = await Effect.runPromise(producer.stop).catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "MessagingDeliveryError",

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { Effect } from "effect";
import {
makeRequestResponse,
type BackendConsumer,
@ -23,18 +24,20 @@ class RecordingProducer<T> implements BackendProducer<T> {
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class WaitingConsumer<T> implements BackendConsumer<T> {
@ -55,32 +58,38 @@ class WaitingConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) return message ?? null;
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || this.closed) return Promise.resolve(message ?? null);
return await new Promise((resolve) => {
this.waiters.push(resolve);
return new Promise((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
});
}
class RuntimeBackend implements PubSubBackend {
@ -96,19 +105,23 @@ class RuntimeBackend implements PubSubBackend {
this.producer = new RecordingProducer<unknown>(onSend);
}
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
describe("RequestResponse compatibility facade", () => {
@ -127,9 +140,9 @@ describe("RequestResponse compatibility facade", () => {
subscription: "sub",
});
await requestor.start();
const response = await requestor.request("request", { timeoutMs: 250 });
await requestor.stop();
await Effect.runPromise(requestor.start);
const response = await Effect.runPromise(requestor.request("request", { timeoutMs: 250 }));
await Effect.runPromise(requestor.stop);
expect(response).toBe("response");
expect(backend.producerOptions).toEqual({ topic: "request-topic" });
@ -150,9 +163,11 @@ describe("RequestResponse compatibility facade", () => {
subscription: "sub",
});
await requestor.start();
const error = await requestor.request("request", { timeoutMs: 5 }).catch((caught: unknown) => caught);
await requestor.stop();
await Effect.runPromise(requestor.start);
const error = await Effect.runPromise(
requestor.request("request", { timeoutMs: 5 }),
).catch((caught: unknown) => caught);
await Effect.runPromise(requestor.stop);
expect(error).toMatchObject({
_tag: "MessagingTimeoutError",
@ -171,7 +186,9 @@ describe("RequestResponse compatibility facade", () => {
subscription: "sub",
});
const error = await requestor.request("request").catch((caught: unknown) => caught);
const error = await Effect.runPromise(
requestor.request("request"),
).catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "MessagingLifecycleError",

View file

@ -4,6 +4,7 @@ import * as S from "effect/Schema";
import {
PubSub,
makeAsyncProcessor,
pubSubError,
runProcessorScoped,
type BackendConsumer,
type BackendProducer,
@ -24,39 +25,45 @@ class FakeProducer<T> implements BackendProducer<T> {
closeCount = 0;
flushCount = 0;
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(
properties === undefined
? { message }
: { message, properties },
);
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(
properties === undefined
? { message }
: { message, properties },
);
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class FakeConsumer<T> implements BackendConsumer<T> {
closeCount = 0;
async receive(): Promise<Message<T> | null> {
return null;
receive(): Effect.Effect<Message<T> | null> {
return Effect.succeed(null);
}
async acknowledge(): Promise<void> {}
acknowledge(): Effect.Effect<void> {
return Effect.void;
}
async negativeAcknowledge(): Promise<void> {}
negativeAcknowledge(): Effect.Effect<void> {
return Effect.void;
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class FakePubSubBackend implements PubSubBackend {
@ -64,24 +71,30 @@ class FakePubSubBackend implements PubSubBackend {
producerOptions: CreateProducerOptions | null = null;
consumerOptions: CreateConsumerOptions | null = null;
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions = options;
return new FakeProducer<T>();
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions = options;
return new FakeProducer<T>();
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions = options;
return new FakeConsumer<T>();
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions = options;
return new FakeConsumer<T>();
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class FailingProducerBackend extends FakePubSubBackend {
override async createProducer<T>(): Promise<BackendProducer<T>> {
throw RuntimeServicesTestError.make({ message: "producer unavailable" });
override createProducer<T>(): Effect.Effect<BackendProducer<T>> {
return Effect.fail(
pubSubError("createProducer:tg.test.failure", RuntimeServicesTestError.make({ message: "producer unavailable" })),
);
}
}
@ -90,23 +103,19 @@ const makeRecordingProcessor = (
events: Array<string>,
) => {
const processor = makeAsyncProcessor(config, {
run: async (runtime) => {
run: (runtime) => Effect.sync(() => {
events.push(`run:${runtime.config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`);
},
}),
});
const stop = processor.stop;
processor.stop = async () => {
processor.onShutdown(() => Effect.sync(() => {
events.push("stop");
await stop();
};
}));
return processor;
};
const makeFailingProcessor = (config: ProcessorConfig) =>
makeAsyncProcessor(config, {
run: async () => {
throw RuntimeServicesTestError.make({ message: "processor failed" });
},
run: () => Effect.fail(RuntimeServicesTestError.make({ message: "processor failed" })),
});
const makeNativeRecordingProcessor = (
@ -122,8 +131,9 @@ const makeNativeRecordingProcessor = (
}),
});
processor.onShutdown(() => {
events.push("native-stop");
return Promise.resolve();
return Effect.sync(() => {
events.push("native-stop");
});
});
return processor;
};
@ -138,7 +148,7 @@ describe("Effect runtime services", () => {
Effect.gen(function* () {
const pubsub = yield* PubSub;
const producer = yield* pubsub.createProducer<string>({ topic: "tg.test.topic" });
yield* Effect.promise(() => producer.send("hello", { id: "1" }));
yield* producer.send("hello", { id: "1" });
expect(backend.producerOptions).toEqual({ topic: "tg.test.topic" });
expect(pubsub.backend).toBe(backend);

View file

@ -36,7 +36,7 @@ import type {
CreateConsumerOptions,
Message,
} from "./types.js";
import { pubSubError } from "../errors.js";
import { pubSubError, type PubSubError } from "../errors.js";
const sc = StringCodec();
@ -113,7 +113,7 @@ function makeNatsProducer<T>(
): BackendProducer<T> {
const makePublishOptions = (
properties: Record<string, string> | undefined,
): Effect.Effect<Partial<JetStreamPublishOptions>, ReturnType<typeof pubSubError>> => {
): Effect.Effect<Partial<JetStreamPublishOptions>, PubSubError> => {
if (properties === undefined || Object.keys(properties).length === 0) {
return Effect.succeed({});
}
@ -131,35 +131,32 @@ function makeNatsProducer<T>(
};
return {
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 = yield* makePublishOptions(properties);
send: Effect.fn(`NatsProducer.send:${subject}`)(function*(message: T, properties?: Record<string, string>) {
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 = yield* makePublishOptions(properties);
yield* Effect.tryPromise({
try: () => js.publish(subject, data, opts),
catch: (error) => pubSubError(`publish:${subject}`, error),
});
}),
),
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(),
flush: Effect.void,
// No per-producer cleanup needed for NATS.
close: () => Promise.resolve(),
close: Effect.void,
};
}
interface InitializableBackendConsumer<T> extends BackendConsumer<T> {
readonly init: () => Promise<void>;
readonly init: Effect.Effect<void, PubSubError>;
}
function makeNatsConsumer<T>(
@ -173,115 +170,111 @@ function makeNatsConsumer<T>(
): InitializableBackendConsumer<T> {
let consumer: NatsJsConsumer | null = null;
const isReceiveTimeoutError = (error: unknown): boolean => {
const code = P.isObject(error) ? (error as { readonly code?: unknown }).code : undefined;
return code === 408 || code === "408" || code === ErrorCode.Timeout;
};
return {
init: () =>
Effect.runPromise(
Effect.gen(function* () {
const existing = yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => natsLookupError(`get-consumer:${streamName}:${subscription}`, error),
}).pipe(
Effect.catchIf(
isMissingLookupError,
() =>
Effect.gen(function* () {
const deliverPolicy =
initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
init: Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => jsm.consumers.info(streamName, subscription),
catch: (error) => natsLookupError(`consumer-info:${streamName}:${subscription}`, error),
}).pipe(
Effect.catchIf(
isMissingLookupError,
() =>
Effect.gen(function* () {
const deliverPolicy =
initialPosition === "earliest"
? DeliverPolicy.All
: DeliverPolicy.New;
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),
});
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),
});
}),
(error) => Effect.fail(pubSubError(error.operation, error.cause)),
),
);
consumer = yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
});
}),
receive: Effect.fn(`NatsConsumer.receive:${subject}`)(function*(timeoutMs = 2000) {
const current = consumer;
if (current === null) {
return yield* pubSubError("receive", "Consumer not initialized");
}
return yield* Effect.tryPromise({
try: () => js.consumers.get(streamName, subscription),
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
});
}),
(error) => Effect.fail(pubSubError(error.operation, error.cause)),
),
const msg = yield* Effect.tryPromise({
try: () => current.next({ expires: timeoutMs }),
catch: (error) =>
isReceiveTimeoutError(error)
? pubSubError(`receive-timeout:${subject}`, error)
: pubSubError(`receive:${subject}`, error),
}).pipe(
Effect.catchIf(
(error) => error.operation === `receive-timeout:${subject}`,
() => Effect.succeed(null),
),
);
if (msg === null) return null;
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)),
);
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 = yield* Effect.tryPromise({
try: () => current.next({ expires: timeoutMs }),
catch: (error) => pubSubError(`receive:${subject}`, error),
});
if (msg === null) return null;
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.try({
try: () => {
message._jsMsg.ack();
},
catch: (error) => pubSubError(`acknowledge:${subject}`, error),
});
}),
),
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.try({
try: () => {
message._jsMsg.nak();
},
catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error),
});
}),
),
unsubscribe: () => {
// The pull-based consumer does not have a persistent subscription to drain.
// Clearing the reference is sufficient; the durable consumer persists server-side.
return makeNatsMessage(msg, decoded);
}),
acknowledge: Effect.fn(`NatsConsumer.acknowledge:${subject}`)(function*(message: Message<T>) {
if (!isNatsMessage(message)) {
return yield* pubSubError(
`acknowledge:${subject}`,
"Message was not produced by NATS backend",
);
}
yield* Effect.try({
try: () => {
message._jsMsg.ack();
},
catch: (error) => pubSubError(`acknowledge:${subject}`, error),
});
}),
negativeAcknowledge: Effect.fn(`NatsConsumer.negativeAcknowledge:${subject}`)(function*(message: Message<T>) {
if (!isNatsMessage(message)) {
return yield* pubSubError(
`negative-acknowledge:${subject}`,
"Message was not produced by NATS backend",
);
}
yield* Effect.try({
try: () => {
message._jsMsg.nak();
},
catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error),
});
}),
unsubscribe: Effect.sync(() => {
consumer = null;
return Promise.resolve();
},
close: () => {
}),
close: Effect.sync(() => {
consumer = null;
return Promise.resolve();
},
}),
};
}
@ -319,7 +312,9 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
const manager = jsm;
if (manager === null) return yield* pubSubError("ensure-stream", "NATS backend not connected");
if (manager === null) {
return yield* pubSubError("ensure-stream", "NATS backend not connected");
}
yield* Effect.tryPromise({
try: () => manager.streams.info(streamName),
@ -344,56 +339,48 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
});
return {
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;
}
}),
),
createProducer: Effect.fn("NatsBackend.createProducer")(function*<T>(options: CreateProducerOptions<T>) {
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: Effect.fn("NatsBackend.createConsumer")(function*<T>(options: CreateConsumerOptions<T>) {
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* consumer.init.pipe(
Effect.mapError((error) => pubSubError(`init-consumer:${options.topic}`, error)),
);
return consumer;
}),
close: 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

@ -1,8 +1,8 @@
/**
* Effect-native pub/sub capability for runtime composition.
*
* The existing Promise-based backend protocol stays available as the
* compatibility bridge while service code moves to `Context.Service`/Layers.
* The backend protocol is Effect-native; this service provides the
* Context.Service/Layer boundary used by runtime composition.
*/
import { Config, Context, Effect, Layer } from "effect";
@ -15,17 +15,17 @@ import type {
PubSubBackend,
} from "./types.js";
import { makeNatsBackend } from "./nats.js";
import { pubSubError } from "../errors.js";
import type { PubSubError } from "../errors.js";
export interface PubSubService {
readonly backend: PubSubBackend;
readonly createProducer: <T>(
options: CreateProducerOptions<T>,
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
) => Effect.Effect<BackendProducer<T>, PubSubError>;
readonly createConsumer: <T>(
options: CreateConsumerOptions<T>,
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
) => Effect.Effect<BackendConsumer<T>, PubSubError>;
readonly close: Effect.Effect<void, PubSubError>;
}
export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgraph/base/backend/pubsub") {
@ -41,20 +41,9 @@ export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgrap
export function makePubSubService(backend: PubSubBackend): PubSubService {
return {
backend,
createProducer: <T>(options: CreateProducerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createProducer<T>(options),
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
}),
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createConsumer<T>(options),
catch: (error) => pubSubError(`createConsumer:${options.topic}`, error),
}),
close: Effect.tryPromise({
try: () => backend.close(),
catch: (error) => pubSubError("close", error),
}),
createProducer: <T>(options: CreateProducerOptions<T>) => backend.createProducer<T>(options),
createConsumer: <T>(options: CreateConsumerOptions<T>) => backend.createConsumer<T>(options),
close: backend.close,
};
}

View file

@ -5,7 +5,9 @@
* (NATS, Pulsar, Redis Streams) implements these interfaces.
*/
import type { Effect } from "effect";
import type * as S from "effect/Schema";
import type { PubSubError } from "../errors.js";
export interface Message<T = unknown> {
value(): T;
@ -13,17 +15,17 @@ export interface Message<T = unknown> {
}
export interface BackendProducer<T = unknown> {
send(message: T, properties?: Record<string, string>): Promise<void>;
flush(): Promise<void>;
close(): Promise<void>;
send(message: T, properties?: Record<string, string>): Effect.Effect<void, PubSubError>;
flush: Effect.Effect<void, PubSubError>;
close: Effect.Effect<void, PubSubError>;
}
export interface BackendConsumer<T = unknown> {
receive(timeoutMs?: number): Promise<Message<T> | null>;
acknowledge(message: Message<T>): Promise<void>;
negativeAcknowledge(message: Message<T>): Promise<void>;
unsubscribe(): Promise<void>;
close(): Promise<void>;
receive(timeoutMs?: number): Effect.Effect<Message<T> | null, PubSubError>;
acknowledge(message: Message<T>): Effect.Effect<void, PubSubError>;
negativeAcknowledge(message: Message<T>): Effect.Effect<void, PubSubError>;
unsubscribe: Effect.Effect<void, PubSubError>;
close: Effect.Effect<void, PubSubError>;
}
export type ConsumerType = "shared" | "exclusive" | "failover";
@ -43,7 +45,7 @@ export interface CreateConsumerOptions<T = unknown> {
}
export interface PubSubBackend {
createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>>;
close(): Promise<void>;
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>, PubSubError>;
createConsumer<T>(options: CreateConsumerOptions<T>): Effect.Effect<BackendConsumer<T>, PubSubError>;
close: Effect.Effect<void, PubSubError>;
}

View file

@ -104,7 +104,7 @@ export class MessagingTimeoutError extends S.TaggedErrorClass<MessagingTimeoutEr
{
message: S.String,
operation: S.String,
timeoutMs: S.Number,
timeoutMs: S.Finite,
},
) {}

View file

@ -12,18 +12,22 @@ import {
TooManyRequestsError,
messagingHandlerError,
messagingLifecycleError,
type MessagingLifecycleError,
} from "../errors.js";
import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
import { Config as EffectConfig, Effect, Exit, Scope } from "effect";
import * as P from "effect/Predicate";
import * as S from "effect/Schema";
import { loadMessagingRuntimeConfig } from "../runtime/index.ts";
import { makeEffectConsumerFromPubSub, type EffectConsumer } from "./runtime.js";
import {
makeEffectConsumerFromPubSub,
type EffectConsumer,
} from "./runtime.js";
export type MessageHandler<T> = (
message: T,
properties: Record<string, string>,
flow: FlowContext,
) => Promise<void>;
) => Effect.Effect<void, TooManyRequestsError | MessagingHandlerError>;
export interface FlowContext<Requirements = never> {
id: string;
@ -47,8 +51,10 @@ declare const ConsumerMessageType: unique symbol;
export interface Consumer<T> {
readonly [ConsumerMessageType]?: (_: T) => T;
readonly start: (flow: FlowContext) => Promise<void>;
readonly stop: () => Promise<void>;
readonly start: (
flow: FlowContext,
) => Effect.Effect<void, MessagingLifecycleError | EffectConfig.ConfigError>;
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
}
interface ConsumerRuntime {
@ -56,8 +62,6 @@ interface ConsumerRuntime {
readonly consumer: EffectConsumer;
}
const consumerRuntime = ManagedRuntime.make(Layer.empty);
export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
let runtime: ConsumerRuntime | null = null;
const isTooManyRequestsError = S.is(TooManyRequestsError);
@ -67,62 +71,58 @@ export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
properties: Record<string, string>,
flow: FlowContext,
): Effect.Effect<void, TooManyRequestsError | MessagingHandlerError> =>
Effect.tryPromise({
try: () => options.handler(message, properties, flow),
catch: (error) =>
options.handler(message, properties, flow).pipe(
Effect.mapError((error) =>
isTooManyRequestsError(error)
? error
: messagingHandlerError(options.topic, options.subscription, error),
});
: messagingHandlerError(options.topic, options.subscription, error)
),
);
return {
start: (flow) =>
P.isNotNull(runtime)
? Promise.resolve()
: consumerRuntime.runPromise(
Effect.gen(function* () {
const scope = yield* Scope.make();
const startConsumer = Effect.gen(function* () {
const config = yield* loadMessagingRuntimeConfig();
const consumer = yield* makeEffectConsumerFromPubSub<T, TooManyRequestsError | MessagingHandlerError, never>(
PubSub.fromBackend(options.pubsub),
config,
{
topic: options.topic,
subscription: options.subscription,
handler: runHandler,
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
initialPosition: options.initialPosition ?? "latest",
...(options.rateLimitRetryMs === undefined ? {} : { rateLimitRetryMs: options.rateLimitRetryMs }),
...(options.rateLimitTimeoutMs === undefined
? {}
: { rateLimitTimeoutMs: options.rateLimitTimeoutMs }),
},
flow,
).pipe(
Scope.provide(scope),
Effect.mapError((error) =>
messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error)
),
);
runtime = { scope, consumer };
});
yield* startConsumer.pipe(
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
? Effect.void
: Effect.gen(function* () {
const scope = yield* Scope.make();
const startConsumer = Effect.gen(function* () {
const config = yield* loadMessagingRuntimeConfig();
const consumer = yield* makeEffectConsumerFromPubSub<T, TooManyRequestsError | MessagingHandlerError, never>(
PubSub.fromBackend(options.pubsub),
config,
{
topic: options.topic,
subscription: options.subscription,
handler: runHandler,
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
initialPosition: options.initialPosition ?? "latest",
...(options.rateLimitRetryMs === undefined ? {} : { rateLimitRetryMs: options.rateLimitRetryMs }),
...(options.rateLimitTimeoutMs === undefined
? {}
: { rateLimitTimeoutMs: options.rateLimitTimeoutMs }),
},
flow,
).pipe(
Scope.provide(scope),
Effect.mapError((error) =>
messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error)
),
);
}),
),
stop: () => {
runtime = { scope, consumer };
});
yield* startConsumer.pipe(
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
);
}),
stop: Effect.suspend(() => {
const current = runtime;
runtime = null;
return current === null
? Promise.resolve()
: consumerRuntime.runPromise(
current.consumer.stop.pipe(
Effect.ensuring(Scope.close(current.scope, Exit.void)),
),
? Effect.void
: current.consumer.stop.pipe(
Effect.ensuring(Scope.close(current.scope, Exit.void)),
);
},
}),
};
}

View file

@ -9,12 +9,16 @@ import type { ProducerMetrics } from "../metrics/index.ts";
import { Effect, Exit, Scope } from "effect";
import { PubSub } from "../backend/pubsub.js";
import { makeEffectProducerFromPubSub, type EffectProducer } from "./runtime.js";
import { messagingLifecycleError } from "../errors.js";
import {
messagingLifecycleError,
type MessagingDeliveryError,
type MessagingLifecycleError,
} from "../errors.js";
export interface Producer<T> {
readonly start: () => Promise<void>;
readonly send: (id: string, message: T) => Promise<void>;
readonly stop: () => Promise<void>;
readonly start: Effect.Effect<void, MessagingLifecycleError>;
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError | MessagingLifecycleError>;
readonly stop: Effect.Effect<void, MessagingDeliveryError>;
}
interface ProducerRuntime<T> {
@ -29,49 +33,46 @@ export function makeProducer<T>(
): Producer<T> {
let runtime: ProducerRuntime<T> | null = null;
const start = Effect.fn(`Producer.start:${topic}`)(function* () {
if (runtime !== null) return;
const scope = yield* Scope.make();
const startProducer = Effect.gen(function* () {
const producer = yield* makeEffectProducerFromPubSub<T>(
PubSub.fromBackend(pubsub),
{
topic,
...(metrics === undefined ? {} : { metrics }),
},
).pipe(
Scope.provide(scope),
Effect.mapError((error) => messagingLifecycleError(topic, "create-producer", error)),
);
runtime = { scope, producer };
});
yield* startProducer.pipe(
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
);
});
return {
start: () =>
runtime !== null
? Promise.resolve()
: Effect.runPromise(
Effect.gen(function* () {
const scope = yield* Scope.make();
const startProducer = Effect.gen(function* () {
const producer = yield* makeEffectProducerFromPubSub<T>(
PubSub.fromBackend(pubsub),
{
topic,
...(metrics === undefined ? {} : { metrics }),
},
).pipe(
Scope.provide(scope),
Effect.mapError((error) => messagingLifecycleError(topic, "create-producer", error)),
);
runtime = { scope, producer };
});
yield* startProducer.pipe(
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
);
}),
),
start: start(),
send: (id, message) => {
const current = runtime;
return current === null
? Effect.runPromise(Effect.fail(messagingLifecycleError(topic, "send", "Producer not started")))
: Effect.runPromise(current.producer.send(id, message));
? Effect.fail(messagingLifecycleError(topic, "send", "Producer not started"))
: current.producer.send(id, message);
},
stop: () => {
stop: Effect.suspend(() => {
const current = runtime;
runtime = null;
return current === null
? Promise.resolve()
: Effect.runPromise(
current.producer.flush.pipe(
Effect.ensuring(Scope.close(current.scope, Exit.void)),
),
? Effect.void
: current.producer.flush.pipe(
Effect.ensuring(Scope.close(current.scope, Exit.void)),
);
},
}),
};
}

View file

@ -7,12 +7,22 @@
* Python reference: trustgraph-base/trustgraph/base/request_response_spec.py
*/
import { Effect, Exit, Scope } from "effect";
import { Config as EffectConfig, Effect, Exit, Scope } from "effect";
import type { PubSubBackend } from "../backend/types.js";
import { PubSub } from "../backend/pubsub.js";
import { messagingDeliveryError, messagingLifecycleError } from "../errors.js";
import {
messagingLifecycleError,
type MessagingDeliveryError,
type MessagingLifecycleError,
type MessagingTimeoutError,
type PubSubError,
} from "../errors.js";
import { loadMessagingRuntimeConfig } from "../runtime/index.ts";
import { makeEffectRequestResponseFromPubSub, type EffectRequestResponse } from "./runtime.js";
import {
makeEffectRequestResponseFromPubSub,
type EffectRequestOptions,
type EffectRequestResponse,
} from "./runtime.js";
export interface RequestResponseOptions {
pubsub: PubSubBackend;
@ -22,15 +32,12 @@ export interface RequestResponseOptions {
}
export interface RequestResponse<TReq, TRes> {
readonly start: () => Promise<void>;
readonly stop: () => Promise<void>;
readonly request: (
readonly start: Effect.Effect<void, PubSubError | EffectConfig.ConfigError>;
readonly stop: Effect.Effect<void>;
readonly request: <E = never, R = never>(
request: TReq,
options?: {
timeoutMs?: number;
recipient?: (response: TRes) => Promise<boolean>;
},
) => Promise<TRes>;
options?: EffectRequestOptions<TRes, E, R>,
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingLifecycleError | MessagingTimeoutError | E, R>;
}
interface RequestResponseRuntime<TReq, TRes> {
@ -43,44 +50,43 @@ export function makeRequestResponse<TReq, TRes>(
): RequestResponse<TReq, TRes> {
let runtime: RequestResponseRuntime<TReq, TRes> | null = null;
const start = Effect.fn(`RequestResponse.start:${options.requestTopic}:${options.responseTopic}`)(function* () {
if (runtime !== null) return;
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));
runtime = { scope, requestor };
});
yield* startRuntime.pipe(
Effect.catch((error) =>
Scope.close(scope, Exit.fail(error)).pipe(
Effect.flatMap(() => Effect.fail(error)),
),
),
);
});
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));
runtime = { scope, requestor };
});
yield* startRuntime.pipe(
Effect.catch((error) =>
Scope.close(scope, Exit.fail(error)).pipe(
Effect.flatMap(() => Effect.fail(error)),
),
),
);
}),
),
stop: () => {
start: start(),
stop: Effect.suspend(() => {
const current = runtime;
runtime = null;
return current === null
? Promise.resolve()
: Effect.runPromise(Scope.close(current.scope, Exit.void));
},
? Effect.void
: Scope.close(current.scope, Exit.void);
}),
/**
* Send a request and wait for responses.
*
@ -93,34 +99,21 @@ export function makeRequestResponse<TReq, TRes>(
request: (request, requestOptions) => {
const current = runtime;
if (current === null) {
return Effect.runPromise(
Effect.fail(
messagingLifecycleError(
`${options.requestTopic}:${options.responseTopic}`,
"request",
"RequestResponse not started",
),
return Effect.fail(
messagingLifecycleError(
`${options.requestTopic}:${options.responseTopic}`,
"request",
"RequestResponse not started",
),
);
}
const timeoutMs = requestOptions?.timeoutMs ?? 300_000;
const recipient = requestOptions?.recipient;
return Effect.runPromise(
current.requestor.request(request, {
timeoutMs,
...(recipient === undefined
? {}
: {
recipient: (response) =>
Effect.tryPromise({
try: () => recipient(response),
catch: (error) => messagingDeliveryError(options.responseTopic, "recipient", error),
}),
}),
}),
);
return current.requestor.request(request, {
...requestOptions,
timeoutMs,
});
},
};
}

View file

@ -168,10 +168,8 @@ export function makeEffectProducerHandle<T>(
): EffectProducer<T> {
return {
send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) =>
Effect.tryPromise({
try: () => backend.send(message, { id }),
catch: (error) => messagingDeliveryError(options.topic, "send", error),
}).pipe(
backend.send(message, { id }).pipe(
Effect.mapError((error) => messagingDeliveryError(options.topic, "send", error)),
Effect.tap(() =>
options.metrics === undefined
? Effect.void
@ -179,14 +177,12 @@ export function makeEffectProducerHandle<T>(
),
),
),
flush: Effect.tryPromise({
try: () => backend.flush(),
catch: (error) => messagingDeliveryError(options.topic, "flush", error),
}),
close: Effect.tryPromise({
try: () => backend.close(),
catch: (error) => messagingDeliveryError(options.topic, "close", error),
}),
flush: backend.flush.pipe(
Effect.mapError((error) => messagingDeliveryError(options.topic, "flush", error)),
),
close: backend.close.pipe(
Effect.mapError((error) => messagingDeliveryError(options.topic, "close", error)),
),
};
}
@ -219,40 +215,36 @@ const closeConsumerBackend = <T>(
topic: string,
subscription: string,
) =>
Effect.tryPromise({
try: () => backend.close(),
catch: (error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error),
});
backend.close.pipe(
Effect.mapError((error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error)),
);
const acknowledgeMessage = <T>(
backend: BackendConsumer<T>,
message: Message<T>,
topic: string,
) =>
Effect.tryPromise({
try: () => backend.acknowledge(message),
catch: (error) => messagingDeliveryError(topic, "acknowledge", error),
});
backend.acknowledge(message).pipe(
Effect.mapError((error) => messagingDeliveryError(topic, "acknowledge", error)),
);
const negativeAcknowledgeMessage = <T>(
backend: BackendConsumer<T>,
message: Message<T>,
topic: string,
) =>
Effect.tryPromise({
try: () => backend.negativeAcknowledge(message),
catch: (error) => messagingDeliveryError(topic, "negative-acknowledge", error),
});
backend.negativeAcknowledge(message).pipe(
Effect.mapError((error) => messagingDeliveryError(topic, "negative-acknowledge", error)),
);
const receiveMessage = <T>(
backend: BackendConsumer<T>,
topic: string,
timeoutMs: number,
) =>
Effect.tryPromise({
try: () => backend.receive(timeoutMs),
catch: (error) => messagingDeliveryError(topic, "receive", error),
});
backend.receive(timeoutMs).pipe(
Effect.mapError((error) => messagingDeliveryError(topic, "receive", error)),
);
const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* <T, E, R>(
options: EffectConsumerOptions<T, E, R>,

View file

@ -8,7 +8,7 @@
import type { PubSubBackend } from "../backend/types.js";
import { makeNatsBackend } from "../backend/nats.js";
import { Context, Effect, Layer, ManagedRuntime } from "effect";
import { Cause, Config as EffectConfig, Context, Effect } from "effect";
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
import { loadProcessorRuntimeConfig } from "../runtime/config.js";
@ -23,7 +23,7 @@ export interface ProcessorConfig {
export type ConfigHandler = (
config: Record<string, unknown>,
version: number,
) => Promise<void>;
) => Effect.Effect<void, Cause.UnknownError>;
export type EffectConfigHandler<E = never, R = never> = (
config: Record<string, unknown>,
@ -36,8 +36,10 @@ declare const processorRunRequirementsType: unique symbol;
export interface ProcessorRuntime<RunError = ProcessorLifecycleError, RunRequirements = never> {
readonly [processorRunErrorType]?: RunError;
readonly [processorRunRequirementsType]?: RunRequirements;
readonly start: (context: Context.Context<RunRequirements>) => Promise<void>;
readonly stop: () => Promise<void>;
readonly start: (
context: Context.Context<RunRequirements>,
) => Effect.Effect<void, RunError | ProcessorLifecycleError>;
readonly stop: Effect.Effect<void, ProcessorLifecycleError>;
startEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
stopEffect: Effect.Effect<void, ProcessorLifecycleError>;
}
@ -48,12 +50,16 @@ export interface AsyncProcessorRuntime<
> extends ProcessorRuntime<RunError, RunRequirements> {
readonly config: ProcessorConfig;
readonly pubsub: PubSubBackend;
readonly configHandlers: ConfigHandler[];
readonly configHandlers: Array<EffectConfigHandler<RunError | ProcessorLifecycleError, RunRequirements>>;
readonly running: boolean;
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
readonly onShutdown: (callback: () => Promise<void>) => void;
readonly run: (context: Context.Context<RunRequirements>) => Promise<void>;
readonly registerConfigHandler: (
handler: EffectConfigHandler<RunError | ProcessorLifecycleError, RunRequirements>,
) => void;
readonly onShutdown: (callback: () => Effect.Effect<void, Cause.UnknownError>) => void;
readonly run: (
context: Context.Context<RunRequirements>,
) => Effect.Effect<void, RunError | ProcessorLifecycleError>;
runEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
}
@ -63,7 +69,7 @@ export interface AsyncProcessorRuntimeOptions<
> {
readonly run?: (
processor: AsyncProcessorRuntime<RunError, RunRequirements>,
) => Promise<void>;
) => Effect.Effect<void, RunError, RunRequirements>;
readonly runEffect?: (
processor: AsyncProcessorRuntime<RunError, RunRequirements>,
) => Effect.Effect<void, RunError, RunRequirements>;
@ -74,8 +80,6 @@ interface RegisteredSignalHandler {
readonly handler: () => void;
}
const asyncProcessorRuntime = ManagedRuntime.make(Layer.empty);
export function makeAsyncProcessor<
RunError = ProcessorLifecycleError,
RunRequirements = never,
@ -85,8 +89,8 @@ export function makeAsyncProcessor<
): AsyncProcessorRuntime<RunError, RunRequirements> {
const pubsub = config.pubsub ?? makeNatsBackend(config.pubsubUrl ?? "nats://localhost:4222");
const ownsPubSub = config.pubsub === undefined;
const configHandlers: ConfigHandler[] = [];
const shutdownCallbacks: Array<() => Promise<void>> = [];
const configHandlers: Array<EffectConfigHandler<RunError | ProcessorLifecycleError, RunRequirements>> = [];
const shutdownCallbacks: Array<() => Effect.Effect<void, Cause.UnknownError>> = [];
let running = false;
let signalHandlers: RegisteredSignalHandler[] = [];
@ -96,12 +100,16 @@ export function makeAsyncProcessor<
}
const shutdown = () => {
void asyncProcessorRuntime.runPromise(
Effect.runFork(
Effect.log(`[${config.id}] Shutting down...`).pipe(
Effect.flatMap(() => processor.stopEffect),
Effect.flatMap(() => processor.stop),
Effect.mapError((error) => processorLifecycleError(config.id, "signal-shutdown", error)),
Effect.match({
onFailure: () => process.exit(1),
onSuccess: () => process.exit(0),
}),
),
).then(() => process.exit(0), () => process.exit(1));
);
};
const handlers: RegisteredSignalHandler[] = [
{ signal: "SIGINT", handler: shutdown },
@ -131,8 +139,10 @@ export function makeAsyncProcessor<
registerConfigHandler: (handler) => {
configHandlers.push(handler);
},
start: (context) => asyncProcessorRuntime.runPromise(Effect.provide(processor.startEffect, context)),
stop: () => asyncProcessorRuntime.runPromise(processor.stopEffect),
start: (context) => Effect.provide(processor.startEffect, context),
get stop() {
return processor.stopEffect;
},
onShutdown: (callback) => {
shutdownCallbacks.push(callback);
},
@ -161,30 +171,27 @@ export function makeAsyncProcessor<
});
for (const cb of shutdownCallbacks) {
yield* Effect.tryPromise({
try: () => cb(),
catch: (error) => processorLifecycleError(config.id, "shutdown-callback", error),
});
yield* cb().pipe(
Effect.mapError((error) => processorLifecycleError(config.id, "shutdown-callback", error)),
);
}
if (ownsPubSub) {
yield* Effect.tryPromise({
try: () => pubsub.close(),
catch: (error) => processorLifecycleError(config.id, "close-pubsub", error),
});
yield* pubsub.close.pipe(
Effect.mapError((error) => processorLifecycleError(config.id, "close-pubsub", error)),
);
}
});
return stopProcessor();
},
run: (context) => asyncProcessorRuntime.runPromise(Effect.provide(processor.runEffect, context)),
run: (context) => Effect.provide(processor.runEffect, context),
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),
});
return options.run?.(processor).pipe(
Effect.mapError((error) => processorLifecycleError(config.id, "start", error)),
) ?? Effect.void;
},
};
@ -201,21 +208,18 @@ export const AsyncProcessor = Object.assign(
return makeAsyncProcessor(config);
},
{
launch<T extends ProcessorRuntime<unknown, never>>(
launch<RunError, T extends ProcessorRuntime<RunError, never>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void> {
): Effect.Effect<void, ProcessorLifecycleError | EffectConfig.ConfigError> {
const ProcessorCtor = this;
return asyncProcessorRuntime.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),
});
}),
);
return Effect.gen(function* () {
const config = yield* loadProcessorRuntimeConfig(id);
const processor = new ProcessorCtor(config);
yield* processor.start(Context.empty()).pipe(
Effect.mapError((error) => processorLifecycleError(id, "launch", error)),
);
});
},
},
) as unknown as {
@ -225,8 +229,8 @@ export const AsyncProcessor = Object.assign(
<RunError = ProcessorLifecycleError, RunRequirements = never>(
config: ProcessorConfig,
): AsyncProcessor<RunError, RunRequirements>;
launch<T extends ProcessorRuntime<unknown, never>>(
launch<RunError, T extends ProcessorRuntime<RunError, never>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void>;
): Effect.Effect<void, ProcessorLifecycleError | EffectConfig.ConfigError>;
};

View file

@ -10,7 +10,6 @@
import {
makeAsyncProcessor,
type AsyncProcessorRuntime,
type ConfigHandler,
type EffectConfigHandler,
type ProcessorRuntime,
type ProcessorConfig,
@ -38,7 +37,7 @@ import {
} from "../messaging/runtime.js";
import { makePubSubService, PubSub } from "../backend/pubsub.js";
import { loadMessagingRuntimeConfig } from "../runtime/index.ts";
import { Context, Duration, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
import { Config as EffectConfig, Context, Duration, Effect, Exit, Scope } from "effect";
import * as MutableHashMap from "effect/MutableHashMap";
import * as O from "effect/Option";
import * as S from "effect/Schema";
@ -75,22 +74,38 @@ type FlowProcessorRuntimeRequirements<FlowRequirements> =
| Scope.Scope
| FlowRequirements;
type FlowProcessorRunError =
| PubSubError
| FlowRuntimeError
| ProcessorLifecycleError
| EffectConfig.ConfigError;
export type FlowProcessorStartEffect<FlowRequirements> = Effect.Effect<
void,
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
FlowProcessorRunError,
FlowProcessorRuntimeRequirements<FlowRequirements>
>;
export interface FlowProcessorRuntime<FlowRequirements = never>
extends ProcessorRuntime<
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
FlowProcessorRunError,
FlowProcessorRuntimeRequirements<FlowRequirements>
> {
> {
readonly config: ProcessorConfig;
readonly pubsub: PubSubBackend;
readonly configHandlers: ConfigHandler[];
readonly configHandlers: ReadonlyArray<
EffectConfigHandler<
FlowProcessorRunError,
FlowProcessorRuntimeRequirements<FlowRequirements>
>
>;
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
readonly registerConfigHandler: (
handler: EffectConfigHandler<
FlowProcessorRunError,
FlowProcessorRuntimeRequirements<FlowRequirements>
>,
) => void;
readonly registerSpecification: (spec: Spec<FlowRequirements>) => void;
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
}
@ -103,7 +118,7 @@ export interface MakeFlowProcessorOptions<FlowRequirements = never> {
}
const ConfigPushSchema = S.Struct({
version: S.Number,
version: S.Finite,
config: S.Record(S.String, S.Unknown),
});
@ -162,10 +177,8 @@ export function runFlowProcessorDefinitionScoped<
if (consumer === null) {
return Effect.void;
}
return Effect.tryPromise({
try: () => consumer.close(),
catch: (error) => pubSubError("close:config-push", error),
}).pipe(
return consumer.close.pipe(
Effect.mapError((error) => pubSubError("close:config-push", error)),
Effect.catch((error) =>
Effect.logError(`[${options.id}] Failed to close config consumer`, {
error: error.message,
@ -253,10 +266,9 @@ export function runFlowProcessorDefinitionScoped<
return;
}
const msg = yield* Effect.tryPromise({
try: () => consumer.receive(2000),
catch: (error) => pubSubError("receive:config-push", error),
});
const msg = yield* consumer.receive(2000).pipe(
Effect.mapError((error) => pubSubError("receive:config-push", error)),
);
if (msg === null) {
return;
}
@ -270,10 +282,9 @@ export function runFlowProcessorDefinitionScoped<
yield* handler(push.config, push.version);
}
yield* Effect.tryPromise({
try: () => consumer.acknowledge(msg),
catch: (error) => pubSubError("acknowledge:config-push", error),
});
yield* consumer.acknowledge(msg).pipe(
Effect.mapError((error) => pubSubError("acknowledge:config-push", error)),
);
});
const processNextConfigPushSafelyEffect = Effect.fn("FlowProcessor.processNextConfigPushSafely")(function* () {
@ -324,29 +335,19 @@ export function makeFlowProcessor<FlowRequirements = never>(
const specifications: Array<Spec<FlowRequirements>> = [
...(options.specifications ?? []),
];
const compatibilityRuntime = ManagedRuntime.make(Layer.empty);
let processor: FlowProcessorRuntime<FlowRequirements>;
const base: AsyncProcessorRuntime<
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
FlowProcessorRunError,
FlowProcessorRuntimeRequirements<FlowRequirements>
> = makeAsyncProcessor(config, {
runEffect: (runtime) => {
const configHandlers = runtime.configHandlers.map(
(handler): EffectConfigHandler<PubSubError> =>
(pushedConfig, version) =>
Effect.tryPromise({
try: () => handler(pushedConfig, version),
catch: (error) => pubSubError("config-handler", error),
}),
);
return runFlowProcessorDefinitionScoped({
runEffect: (runtime) =>
runFlowProcessorDefinitionScoped({
id: runtime.config.id,
pubsub: runtime.pubsub,
specifications,
configHandlers,
configHandlers: runtime.configHandlers,
isRunning: runtime.isRunning,
});
},
}),
});
const makeStartEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
@ -381,7 +382,7 @@ export function makeFlowProcessor<FlowRequirements = never>(
get startEffect() {
return makeStartEffect();
},
start: (context) => compatibilityRuntime.runPromise(startProcessorEffect(context)),
start: (context) => startProcessorEffect(context),
};
return processor;

View file

@ -4,7 +4,7 @@
* Python reference: trustgraph-base/trustgraph/base/flow.py
*/
import { Config as EffectConfig, Context, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
import { Config as EffectConfig, Context, Effect, Exit, Scope } from "effect";
import * as MutableHashMap from "effect/MutableHashMap";
import * as O from "effect/Option";
import * as S from "effect/Schema";
@ -15,6 +15,9 @@ import {
flowResourceNotFoundError,
type FlowParameterDecodeError,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
type MessagingLifecycleError,
type MessagingTimeoutError,
type PubSubError,
} from "../errors.js";
import {
@ -43,26 +46,26 @@ export interface FlowDefinition {
}
export interface FlowProducer<T> {
readonly send: (id: string, message: T) => Promise<void>;
readonly flush: () => Promise<void>;
readonly stop: () => Promise<void>;
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError>;
readonly flush: Effect.Effect<void, MessagingDeliveryError>;
readonly stop: Effect.Effect<void, MessagingDeliveryError>;
}
export interface FlowConsumer {
readonly stop: () => Promise<void>;
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
}
export interface FlowRequestOptions<TRes> {
export interface FlowRequestOptions<TRes, E = never, R = never> {
readonly timeoutMs?: number;
readonly recipient?: (response: TRes) => Promise<boolean>;
readonly recipient?: (response: TRes) => Effect.Effect<boolean, E, R>;
}
export interface FlowRequestor<TReq, TRes> {
readonly request: (
readonly request: <E = never, R = never>(
request: TReq,
options?: FlowRequestOptions<TRes>,
) => Promise<TRes>;
readonly stop: () => Promise<void>;
options?: FlowRequestOptions<TRes, E, R>,
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingLifecycleError | MessagingTimeoutError | E, R>;
readonly stop: Effect.Effect<void, MessagingLifecycleError | MessagingDeliveryError>;
}
type FlowParameterError = FlowResourceNotFoundError | FlowParameterDecodeError;
@ -71,19 +74,14 @@ export interface Flow<Requirements = never> {
readonly name: string;
readonly processorId: string;
startEffect: Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
start: (context: Context.Context<Requirements>) => Promise<void>;
stop: () => Promise<void>;
start: (context: Context.Context<Requirements>) => Effect.Effect<void, PubSubError | EffectConfig.ConfigError>;
stop: Effect.Effect<void>;
stopEffect: Effect.Effect<void>;
runInCompatibilityScopeEffect: <A, E>(
runInRuntimeScopeEffect: <A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
context: Context.Context<Requirements>,
) => Effect.Effect<A, E | EffectConfig.ConfigError>;
runInCompatibilityScope: <A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
context: Context.Context<Requirements>,
) => Promise<A>;
clearResources: () => void;
registerProducer: <T>(registerName: string, producer: EffectProducer<T>) => void;
registerConsumer: (registerName: string, consumer: EffectConsumer) => void;
@ -108,13 +106,15 @@ export interface Flow<Requirements = never> {
(parameterName: string): Effect.Effect<unknown, FlowResourceNotFoundError>;
};
producer: {
<T>(producerSpec: ProducerSpec<T>): FlowProducer<T>;
(producerName: string): FlowProducer<never>;
<T>(producerSpec: ProducerSpec<T>): Effect.Effect<FlowProducer<T>, FlowResourceNotFoundError>;
(producerName: string): Effect.Effect<FlowProducer<never>, FlowResourceNotFoundError>;
};
consumer: (consumerName: string) => FlowConsumer;
consumer: (consumerName: string) => Effect.Effect<FlowConsumer, FlowResourceNotFoundError>;
requestor: {
<TReq, TRes>(requestorSpec: RequestResponseSpec<TReq, TRes>): FlowRequestor<TReq, TRes>;
(requestorName: string): FlowRequestor<never, unknown>;
<TReq, TRes>(
requestorSpec: RequestResponseSpec<TReq, TRes>,
): Effect.Effect<FlowRequestor<TReq, TRes>, FlowResourceNotFoundError>;
(requestorName: string): Effect.Effect<FlowRequestor<never, unknown>, FlowResourceNotFoundError>;
};
parameter: {
<T>(parameterSpec: ParameterSpec<T>): T;
@ -133,21 +133,20 @@ export function makeFlow<Requirements = never>(
const consumers = MutableHashMap.empty<string, EffectConsumer>();
const requestors = MutableHashMap.empty<string, EffectRequestResponse<never, unknown>>();
const parameters = MutableHashMap.empty<string, unknown>();
let compatibilityScope: Scope.Closeable | null = null;
const compatibilityRuntime = ManagedRuntime.make(Layer.empty);
let runtimeScope: Scope.Closeable | null = null;
const ensureCompatibilityScopeEffect = Effect.fn("Flow.ensureCompatibilityScope")(function* () {
if (compatibilityScope !== null) {
return compatibilityScope;
const ensureRuntimeScopeEffect = Effect.fn("Flow.ensureRuntimeScope")(function* () {
if (runtimeScope !== null) {
return runtimeScope;
}
const scope = yield* Scope.make();
compatibilityScope = scope;
runtimeScope = scope;
return scope;
});
const toEffectRequestOptions = <TRes>(
options: FlowRequestOptions<TRes> | undefined,
): EffectRequestOptions<TRes> | undefined => {
const toEffectRequestOptions = <TRes, E, R>(
options: FlowRequestOptions<TRes, E, R> | undefined,
): EffectRequestOptions<TRes, E, R> | undefined => {
if (options === undefined) {
return undefined;
}
@ -157,7 +156,7 @@ export function makeFlow<Requirements = never>(
...(recipient === undefined
? {}
: {
recipient: (response: TRes) => Effect.promise(() => recipient(response)),
recipient,
}),
};
};
@ -198,12 +197,6 @@ export function makeFlow<Requirements = never>(
: Effect.succeed(producer);
};
const getProducer = (producerName: string): EffectProducer<never> => {
const producer = O.getOrUndefined(MutableHashMap.get(producers, producerName));
if (producer === undefined) throw flowResourceNotFoundError(name, "producer", producerName);
return producer;
};
const getRequestorEffect = (
requestorName: string,
): Effect.Effect<EffectRequestResponse<never, unknown>, FlowResourceNotFoundError> => {
@ -213,31 +206,21 @@ export function makeFlow<Requirements = never>(
: Effect.succeed(requestor);
};
const getRequestor = (
requestorName: string,
): EffectRequestResponse<never, unknown> => {
const requestor = O.getOrUndefined(MutableHashMap.get(requestors, requestorName));
if (requestor === undefined) throw flowResourceNotFoundError(name, "requestor", requestorName);
return requestor;
};
const toFlowProducer = <T>(producer: EffectProducer<T>): FlowProducer<T> => ({
send: (id, message) => compatibilityRuntime.runPromise(producer.send(id, message)),
flush: () => compatibilityRuntime.runPromise(producer.flush),
stop: () => compatibilityRuntime.runPromise(producer.flush.pipe(Effect.flatMap(() => producer.close))),
send: producer.send,
flush: producer.flush,
stop: producer.flush.pipe(Effect.flatMap(() => producer.close)),
});
const toFlowRequestor = <TReq, TRes>(
requestor: EffectRequestResponse<TReq, TRes>,
): FlowRequestor<TReq, TRes> => ({
request: (request, options) =>
compatibilityRuntime.runPromise(
requestor.request(
request,
toEffectRequestOptions(options),
),
requestor.request(
request,
toEffectRequestOptions(options),
),
stop: () => compatibilityRuntime.runPromise(requestor.stop),
stop: requestor.stop,
});
function producerEffect<T>(
@ -303,32 +286,26 @@ export function makeFlow<Requirements = never>(
return decodeParameter(parameter, value);
}
function producer<T>(producerSpec: ProducerSpec<T>): FlowProducer<T>;
function producer(producerName: string): FlowProducer<never>;
function producer<T>(producerSpec: ProducerSpec<T>): Effect.Effect<FlowProducer<T>, FlowResourceNotFoundError>;
function producer(producerName: string): Effect.Effect<FlowProducer<never>, FlowResourceNotFoundError>;
function producer<T>(producer: string | ProducerSpec<T>) {
if (typeof producer === "string") {
return toFlowProducer(getProducer(producer));
return getProducerEffect(producer).pipe(Effect.map(toFlowProducer));
}
if (!MutableHashMap.has(producers, producer.name)) {
throw flowResourceNotFoundError(name, "producer", producer.name);
}
return toFlowProducer(compatibilityRuntime.runSync(producer.producerEffect(flow)));
return producer.producerEffect(flow).pipe(Effect.map(toFlowProducer));
}
function requestor<TReq, TRes>(
requestorSpec: RequestResponseSpec<TReq, TRes>,
): FlowRequestor<TReq, TRes>;
function requestor(requestorName: string): FlowRequestor<never, unknown>;
): Effect.Effect<FlowRequestor<TReq, TRes>, FlowResourceNotFoundError>;
function requestor(requestorName: string): Effect.Effect<FlowRequestor<never, unknown>, FlowResourceNotFoundError>;
function requestor<TReq, TRes>(
requestor: string | RequestResponseSpec<TReq, TRes>,
) {
if (typeof requestor === "string") {
return toFlowRequestor(getRequestor(requestor));
return getRequestorEffect(requestor).pipe(Effect.map(toFlowRequestor));
}
if (!MutableHashMap.has(requestors, requestor.name)) {
throw flowResourceNotFoundError(name, "requestor", requestor.name);
}
return toFlowRequestor(compatibilityRuntime.runSync(requestor.requestorEffect(flow)));
return requestor.requestorEffect(flow).pipe(Effect.map(toFlowRequestor));
}
const flow: Flow<Requirements> = {
@ -339,33 +316,31 @@ export function makeFlow<Requirements = never>(
yield* spec.addEffect(flow, definition);
}
}).pipe(Effect.withSpan("Flow.startEffect")),
start(context: Context.Context<Requirements>): Promise<void> {
return compatibilityRuntime.runPromise(
Effect.gen(function* () {
if (compatibilityScope !== null) {
yield* flow.stopEffect;
}
yield* flow.runInCompatibilityScopeEffect(flow.startEffect, pubsub, context);
}),
);
start(context: Context.Context<Requirements>): Effect.Effect<void, PubSubError | EffectConfig.ConfigError> {
return Effect.gen(function* () {
if (runtimeScope !== null) {
yield* flow.stop;
}
yield* flow.runInRuntimeScopeEffect(flow.startEffect, pubsub, context);
});
},
stop(): Promise<void> {
return compatibilityRuntime.runPromise(flow.stopEffect);
get stop() {
return flow.stopEffect;
},
stopEffect: Effect.gen(function* () {
const scope = compatibilityScope;
compatibilityScope = null;
const scope = runtimeScope;
runtimeScope = null;
if (scope !== null) {
yield* Scope.close(scope, Exit.void);
}
flow.clearResources();
}).pipe(Effect.withSpan("Flow.stopEffect")),
runInCompatibilityScopeEffect: Effect.fn("Flow.runInCompatibilityScopeEffect")(function* <A, E>(
runInRuntimeScopeEffect: Effect.fn("Flow.runInRuntimeScopeEffect")(function* <A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
context: Context.Context<Requirements>,
) {
const scope = yield* ensureCompatibilityScopeEffect();
const scope = yield* ensureRuntimeScopeEffect();
const pubsubService = makePubSubService(runtimePubsub);
const messagingConfig = yield* loadMessagingRuntimeConfig();
return yield* Effect.provide(
@ -381,13 +356,6 @@ export function makeFlow<Requirements = never>(
context,
);
}),
runInCompatibilityScope<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
context: Context.Context<Requirements>,
): Promise<A> {
return compatibilityRuntime.runPromise(flow.runInCompatibilityScopeEffect(effect, runtimePubsub, context));
},
clearResources(): void {
MutableHashMap.clear(producers);
MutableHashMap.clear(consumers);
@ -416,12 +384,12 @@ export function makeFlow<Requirements = never>(
requestorEffect,
parameterEffect,
producer,
consumer(consumerName: string): FlowConsumer {
const c = O.getOrUndefined(MutableHashMap.get(consumers, consumerName));
if (c === undefined) throw flowResourceNotFoundError(name, "consumer", consumerName);
return {
stop: () => compatibilityRuntime.runPromise(c.stop),
};
consumer(consumerName: string): Effect.Effect<FlowConsumer, FlowResourceNotFoundError> {
return flow.consumerEffect(consumerName).pipe(
Effect.map((c) => ({
stop: c.stop,
})),
);
},
requestor,
parameter,

View file

@ -7,7 +7,6 @@
import { Config as EffectConfig, Effect, Layer } from "effect";
import {
processorLifecycleError,
type FlowRuntimeError,
type ProcessorLifecycleError,
type PubSubError,
@ -83,10 +82,7 @@ export const runProcessorScoped = Effect.fn("runProcessorScoped")(function* <
const processor = make(runtimeConfig);
yield* Effect.addFinalizer(() =>
Effect.tryPromise({
try: () => processor.stop(),
catch: (error) => processorLifecycleError(config.id, "stop", error),
}).pipe(
processor.stop.pipe(
Effect.catch((error) =>
Effect.logError("[Processor] Failed to stop processor", {
error: error.message,

View file

@ -11,7 +11,7 @@ const UnknownRecord = S.Record(S.String, S.Unknown);
const MutableArray = <A extends S.Top>(schema: A) => schema.pipe(S.Array, S.mutable);
const OptionalMutableArray = <A extends S.Top>(schema: A) => schema.pipe(S.Array, S.mutable, S.optionalKey);
const StringArray = MutableArray(S.String);
const NumberArray = MutableArray(S.Number);
const NumberArray = MutableArray(S.Finite);
const NumberArrays = MutableArray(NumberArray);
// Text completion
@ -19,7 +19,7 @@ export const TextCompletionRequest = S.Struct({
system: S.String,
prompt: S.String,
model: S.optionalKey(S.String),
temperature: S.optionalKey(S.Number),
temperature: S.optionalKey(S.Finite),
streaming: S.optionalKey(S.Boolean),
});
export type TextCompletionRequest = typeof TextCompletionRequest.Type;
@ -27,8 +27,8 @@ export type TextCompletionRequest = typeof TextCompletionRequest.Type;
export const TextCompletionResponse = S.Struct({
response: S.String,
model: S.optionalKey(S.String),
inToken: S.optionalKey(S.Number),
outToken: S.optionalKey(S.Number),
inToken: S.optionalKey(S.Finite),
outToken: S.optionalKey(S.Finite),
error: S.optionalKey(TgError),
endOfStream: S.optionalKey(S.Boolean),
});
@ -51,10 +51,10 @@ export type EmbeddingsResponse = typeof EmbeddingsResponse.Type;
export const GraphRagRequest = S.Struct({
query: S.String,
collection: S.optionalKey(S.String),
entityLimit: S.optionalKey(S.Number),
tripleLimit: S.optionalKey(S.Number),
maxSubgraphSize: S.optionalKey(S.Number),
maxPathLength: S.optionalKey(S.Number),
entityLimit: S.optionalKey(S.Finite),
tripleLimit: S.optionalKey(S.Finite),
maxSubgraphSize: S.optionalKey(S.Finite),
maxPathLength: S.optionalKey(S.Finite),
streaming: S.optionalKey(S.Boolean),
});
export type GraphRagRequest = typeof GraphRagRequest.Type;
@ -126,7 +126,7 @@ export const TriplesQueryRequest = S.Struct({
p: S.optionalKey(Term),
o: S.optionalKey(Term),
collection: S.optionalKey(S.String),
limit: S.optionalKey(S.Number),
limit: S.optionalKey(S.Finite),
});
export type TriplesQueryRequest = typeof TriplesQueryRequest.Type;
@ -140,7 +140,7 @@ export type TriplesQueryResponse = typeof TriplesQueryResponse.Type;
export const GraphEmbeddingsRequest = S.Struct({
vectors: NumberArrays,
user: S.optionalKey(S.String),
limit: S.optionalKey(S.Number),
limit: S.optionalKey(S.Finite),
collection: S.optionalKey(S.String),
});
export type GraphEmbeddingsRequest = typeof GraphEmbeddingsRequest.Type;
@ -154,7 +154,7 @@ export type GraphEmbeddingsResponse = typeof GraphEmbeddingsResponse.Type;
// Document embeddings query
export const DocumentEmbeddingsRequest = S.Struct({
vectors: NumberArrays,
limit: S.optionalKey(S.Number),
limit: S.optionalKey(S.Finite),
user: S.optionalKey(S.String),
collection: S.optionalKey(S.String),
});
@ -162,7 +162,7 @@ export type DocumentEmbeddingsRequest = typeof DocumentEmbeddingsRequest.Type;
const DocumentEmbeddingChunk = S.Struct({
chunkId: S.String,
score: S.Number,
score: S.Finite,
content: S.optionalKey(S.String),
});
@ -193,7 +193,7 @@ export const ConfigRequest = S.StructWithRest(
export type ConfigRequest = typeof ConfigRequest.Type;
export const ConfigResponse = S.Struct({
version: S.optionalKey(S.Number),
version: S.optionalKey(S.Finite),
values: S.optionalKey(S.Unknown),
directory: S.optionalKey(StringArray),
config: S.optionalKey(UnknownRecord),
@ -266,7 +266,7 @@ export type Triples = typeof Triples.Type;
// Document metadata
export const DocumentMetadata = S.Struct({
id: S.String,
time: S.Number,
time: S.Finite,
kind: S.String,
title: S.String,
comments: S.String,
@ -284,7 +284,7 @@ export const ProcessingMetadata = S.Struct({
id: S.String,
documentId: S.String,
"document-id": S.optionalKey(S.String),
time: S.Number,
time: S.Finite,
flow: S.String,
user: S.String,
collection: S.String,
@ -329,10 +329,10 @@ export const LibrarianRequest = S.StructWithRest(
content: S.optionalKey(S.String),
user: S.optionalKey(S.String),
collection: S.optionalKey(S.String),
"total-size": S.optionalKey(S.Number),
"chunk-size": S.optionalKey(S.Number),
"total-size": S.optionalKey(S.Finite),
"chunk-size": S.optionalKey(S.Finite),
"upload-id": S.optionalKey(S.String),
"chunk-index": S.optionalKey(S.Number),
"chunk-index": S.optionalKey(S.Finite),
}),
[UnknownRecord],
);
@ -342,10 +342,10 @@ const UploadSessionInfo = S.Struct({
"upload-id": S.String,
"document-id": S.String,
"document-metadata-json": S.String,
"total-size": S.Number,
"chunk-size": S.Number,
"total-chunks": S.Number,
"chunks-received": S.Number,
"total-size": S.Finite,
"chunk-size": S.Finite,
"total-chunks": S.Finite,
"chunks-received": S.Finite,
"created-at": S.String,
});
@ -363,12 +363,12 @@ export const LibrarianResponse = S.StructWithRest(
"document-id": S.optionalKey(S.String),
"object-id": S.optionalKey(S.String),
"upload-id": S.optionalKey(S.String),
"chunk-size": S.optionalKey(S.Number),
"chunk-index": S.optionalKey(S.Number),
"total-chunks": S.optionalKey(S.Number),
"chunks-received": S.optionalKey(S.Number),
"bytes-received": S.optionalKey(S.Number),
"total-bytes": S.optionalKey(S.Number),
"chunk-size": S.optionalKey(S.Finite),
"chunk-index": S.optionalKey(S.Finite),
"total-chunks": S.optionalKey(S.Finite),
"chunks-received": S.optionalKey(S.Finite),
"bytes-received": S.optionalKey(S.Finite),
"total-bytes": S.optionalKey(S.Finite),
"upload-state": S.optionalKey(S.String),
"received-chunks": S.optionalKey(NumberArray),
"missing-chunks": S.optionalKey(NumberArray),

View file

@ -84,16 +84,16 @@ export type RowSchema = typeof RowSchema.Type;
export const LlmResult = S.Struct({
text: S.String,
inToken: S.Number,
outToken: S.Number,
inToken: S.Finite,
outToken: S.Finite,
model: S.String,
});
export type LlmResult = typeof LlmResult.Type;
export const LlmChunk = S.Struct({
text: S.String,
inToken: S.NullOr(S.Number),
outToken: S.NullOr(S.Number),
inToken: S.NullOr(S.Finite),
outToken: S.NullOr(S.Finite),
model: S.String,
isFinal: S.Boolean,
});

View file

@ -32,19 +32,19 @@ export class LlmServiceError extends S.TaggedErrorClass<LlmServiceError>()(
},
) {}
export interface LlmProvider {
export interface LlmProvider<ProviderError = never> {
readonly generateContent: (
system: string,
prompt: string,
model?: string,
temperature?: number,
) => Promise<LlmResult>;
) => Effect.Effect<LlmResult, ProviderError>;
readonly generateContentStream: (
system: string,
prompt: string,
model?: string,
temperature?: number,
) => AsyncGenerator<LlmChunk>;
) => Stream.Stream<LlmChunk, ProviderError>;
readonly supportsStreaming: () => boolean;
}
@ -60,7 +60,7 @@ export interface LlmServiceShape {
prompt: string,
model?: string,
temperature?: number,
) => AsyncGenerator<LlmChunk>;
) => Stream.Stream<LlmChunk, LlmServiceError>;
readonly supportsStreaming: () => boolean;
}
@ -74,24 +74,28 @@ const llmServiceError = (operation: string, cause: unknown) =>
message: errorMessage(cause),
});
export const makeLlmServiceShape = (provider: LlmProvider): LlmServiceShape => ({
export const makeLlmServiceShape = <ProviderError>(
provider: LlmProvider<ProviderError>,
): LlmServiceShape => ({
generateContent: Effect.fn("Llm.generateContent")((
system,
prompt,
model,
temperature,
) =>
Effect.tryPromise({
try: () => provider.generateContent(system, prompt, model, temperature),
catch: (cause) => llmServiceError("generate-content", cause),
}),
provider.generateContent(system, prompt, model, temperature).pipe(
Effect.mapError((cause) => llmServiceError("generate-content", cause)),
),
),
generateContentStream: (
system,
prompt,
model,
temperature,
) => provider.generateContentStream(system, prompt, model, temperature),
) =>
provider.generateContentStream(system, prompt, model, temperature).pipe(
Stream.mapError((cause) => llmServiceError("generate-content-stream", cause)),
),
supportsStreaming: () => provider.supportsStreaming(),
});
@ -137,14 +141,11 @@ const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(func
) => Effect.Effect<void, MessagingDeliveryError>;
},
) {
yield* Stream.fromAsyncIterable(
llm.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
),
(cause) => llmServiceError("generate-content-stream", cause),
yield* llm.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
).pipe(
Stream.runForEach((chunk) =>
responseProducer.send(requestId, chunkToResponse(chunk)),
@ -215,12 +216,14 @@ export const makeLlmSpecs = (): ReadonlyArray<Spec<Llm>> => [
makeParameterSpec("temperature"),
];
export type LlmService = FlowProcessorRuntime<Llm> & LlmProvider;
export type LlmService<ProviderError = never> =
& FlowProcessorRuntime<Llm>
& LlmProvider<ProviderError>;
export function makeLlmService(
export function makeLlmService<ProviderError>(
config: ProcessorConfig,
provider: LlmProvider,
): LlmService {
provider: LlmProvider<ProviderError>,
): LlmService<ProviderError> {
const service = makeFlowProcessor(config, {
specifications: makeLlmSpecs(),
provide: (effect) =>

View file

@ -5,24 +5,17 @@
*/
import { Effect } from "effect";
import * as S from "effect/Schema";
import type { Spec } from "./types.js";
import type { SpecRuntimeRequirements } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import { type MessageHandler } from "../messaging/consumer.js";
import {
ConsumerFactory,
type EffectMessageHandler,
} from "../messaging/runtime.js";
import {
messagingHandlerError,
TooManyRequestsError,
type MessagingHandlerError,
type PubSubError,
} from "../errors.js";
const isTooManyRequestsError = S.is(TooManyRequestsError);
declare const ConsumerSpecType: unique symbol;
export interface ConsumerSpec<T, E = never, R = never> extends Spec<R> {
@ -62,26 +55,5 @@ export function makeConsumerSpec<T, E = never, R = never>(
return {
name,
addEffect,
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}
export function makeConsumerSpecFromPromise<T>(
name: string,
handler: MessageHandler<T>,
concurrency = 1,
): ConsumerSpec<T, TooManyRequestsError | MessagingHandlerError> {
return makeConsumerSpec<T, TooManyRequestsError | MessagingHandlerError>(
name,
(message, properties, flow) =>
Effect.tryPromise({
try: () => handler(message, properties, flow),
catch: (error) =>
isTooManyRequestsError(error)
? error
: messagingHandlerError(name, `${flow.id}-${flow.name}-${name}`, error),
}),
concurrency,
);
}

View file

@ -1,5 +1,5 @@
export type { Spec, SpecRuntimeError, SpecRuntimeRequirements } from "./types.js";
export { makeConsumerSpec, makeConsumerSpecFromPromise, type ConsumerSpec } from "./consumer-spec.js";
export { makeConsumerSpec, type ConsumerSpec } from "./consumer-spec.js";
export { makeProducerSpec, type ProducerSpec } from "./producer-spec.js";
export { makeParameterSpec, type ParameterSpec } from "./parameter-spec.js";
export { makeRequestResponseSpec, type RequestResponseSpec } from "./request-response-spec.js";

View file

@ -4,9 +4,8 @@
* Python reference: trustgraph-base/trustgraph/base/parameter_spec.py
*/
import { Effect, type Context } from "effect";
import { Effect } from "effect";
import * as S from "effect/Schema";
import type { PubSubBackend } from "../backend/types.js";
import type { SpecRuntimeRequirements } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import type { PubSubError } from "../errors.js";
@ -23,12 +22,6 @@ export interface ParameterSpec<T = unknown> {
flow: Flow<Requirements>,
definition: FlowDefinition,
) => Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
readonly add: <Requirements = never>(
flow: Flow<Requirements>,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<Requirements>,
) => Promise<void>;
}
export function makeParameterSpec(name: string): ParameterSpec<unknown>;
@ -51,12 +44,5 @@ export function makeParameterSpec<T>(
name,
schema: parameterSchema,
addEffect,
add: <Requirements = never>(
flow: Flow<Requirements>,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<Requirements>,
) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

@ -4,10 +4,9 @@
* Python reference: trustgraph-base/trustgraph/base/producer_spec.py
*/
import { Effect, type Context } from "effect";
import { Effect } from "effect";
import type { SpecRuntimeRequirements } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import type { PubSubBackend } from "../backend/types.js";
import {
flowResourceNotFoundError,
type FlowResourceNotFoundError,
@ -27,12 +26,6 @@ export interface ProducerSpec<T> {
flow: Flow<Requirements>,
definition: FlowDefinition,
) => Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
readonly add: <Requirements = never>(
flow: Flow<Requirements>,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<Requirements>,
) => Promise<void>;
readonly producerEffect: <Requirements = never>(
flow: Flow<Requirements>,
) => Effect.Effect<EffectProducer<T>, FlowResourceNotFoundError>;
@ -84,7 +77,5 @@ export function makeProducerSpec<T>(name: string): ProducerSpec<T> {
name,
producerEffect,
addEffect,
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

@ -7,10 +7,9 @@
* Python reference: trustgraph-base/trustgraph/base/prompt_client_spec.py
*/
import { Effect, type Context } from "effect";
import { Effect } from "effect";
import type { SpecRuntimeRequirements } from "./types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import type { PubSubBackend } from "../backend/types.js";
import {
flowResourceNotFoundError,
type FlowResourceNotFoundError,
@ -33,12 +32,6 @@ export interface RequestResponseSpec<TReq, TRes> {
flow: Flow<Requirements>,
definition: FlowDefinition,
) => Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements>;
readonly add: <Requirements = never>(
flow: Flow<Requirements>,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<Requirements>,
) => Promise<void>;
readonly requestorEffect: <Requirements = never>(
flow: Flow<Requirements>,
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, FlowResourceNotFoundError>;
@ -99,7 +92,5 @@ export function makeRequestResponseSpec<TReq, TRes>(
name,
requestorEffect,
addEffect,
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

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

View file

@ -12,21 +12,20 @@
"test": "bunx --bun vitest run --passWithNoTests --exclude=dist/**"
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"commander": "^13.1.0",
"@effect/ai-anthropic": "4.0.0-beta.75",
"@effect/ai-openai": "4.0.0-beta.75",
"@effect/ai-openrouter": "4.0.0-beta.75",
"@effect/atom-react": "4.0.0-beta.75",
"@effect/openapi-generator": "4.0.0-beta.75",
"@effect/opentelemetry": "4.0.0-beta.75",
"@effect/platform-browser": "4.0.0-beta.75",
"@effect/platform-bun": "4.0.0-beta.75",
"ws": "^8.18.0"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.75",
"@effect/vitest": "4.0.0-beta.78",
"@types/ws": "^8.5.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6"

View file

@ -4,21 +4,19 @@
* Python reference: trustgraph-cli/trustgraph/cli/invoke_agent.py
*/
import type { Command } from "commander";
import { Effect } from "effect";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import { cliCommandError, withSocket } from "./util.js";
export function registerAgentCommands(program: Command): void {
program
.command("agent")
.description("Ask the TrustGraph agent a question")
.argument("<question>", "Question to ask")
.action((question: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
export const agentCommand = Command.make("agent", {
question: Argument.string("question").pipe(Argument.withDescription("Question to ask")),
}, ({ question }) =>
withSocket((socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
yield* Effect.callback<void, ReturnType<typeof cliCommandError>>((resume) => {
yield* Effect.callback<void, ReturnType<typeof cliCommandError>>((resume) => {
flow.agent(
question,
(chunk) => {
@ -40,7 +38,6 @@ export function registerAgentCommands(program: Command): void {
(err) => resume(Effect.fail(cliCommandError("agent", err))),
);
});
}),
)),
);
}
}),
),
).pipe(Command.withDescription("Ask the TrustGraph agent a question"));

View file

@ -4,38 +4,29 @@
* Python reference: trustgraph-cli/trustgraph/cli/show_config.py etc.
*/
import type { Command } from "commander";
import { Effect } from "effect";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerConfigCommands(program: Command): void {
const config = program
.command("config")
.description("Configuration management");
config
.command("show")
.description("Show current configuration")
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const show = Command.make("show", {}, () =>
withSocket((socket) =>
Effect.gen(function* () {
const cfg = socket.config();
const resp = yield* Effect.tryPromise({
try: () => cfg.getConfigAll(),
catch: (error) => cliCommandError("config.show", error),
});
yield* writeJson(resp);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () => cfg.getConfigAll(),
catch: (error) => cliCommandError("config.show", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Show current configuration"));
config
.command("get")
.description("Get a configuration value")
.argument("<key>", "Config key (format: type/key)")
.action((key: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const get = Command.make("get", {
key: Argument.string("key").pipe(Argument.withDescription("Config key (format: type/key)")),
}, ({ key }) =>
withSocket((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("/");
@ -43,74 +34,75 @@ export function registerConfigCommands(program: Command): void {
parts.length >= 2
? { type: parts[0], key: parts.slice(1).join("/") }
: { type: "config", key };
const resp = yield* Effect.tryPromise({
try: () => cfg.getConfig([configKey]),
catch: (error) => cliCommandError("config.get", error),
});
yield* writeJson(resp);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () => cfg.getConfig([configKey]),
catch: (error) => cliCommandError("config.get", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Get a configuration value"));
config
.command("set")
.description("Set a configuration value")
.argument("<key>", "Config key (format: type/key)")
.argument("<value>", "Config value (JSON)")
.action((key: string, value: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const set = Command.make("set", {
key: Argument.string("key").pipe(Argument.withDescription("Config key (format: type/key)")),
value: Argument.string("value").pipe(Argument.withDescription("Config value (JSON)")),
}, ({ key, value }) =>
withSocket((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 = yield* Effect.tryPromise({
try: () => cfg.putConfig([configEntry]),
catch: (error) => cliCommandError("config.set", error),
});
yield* writeJson(resp);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () => cfg.putConfig([configEntry]),
catch: (error) => cliCommandError("config.set", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Set a configuration value"));
config
.command("list")
.description("List configuration keys for a type")
.argument("[type]", "Config type to list", "config")
.action((type: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const list = Command.make("list", {
type: Argument.string("type").pipe(
Argument.withDescription("Config type to list"),
Argument.withDefault("config"),
),
}, ({ type }) =>
withSocket((socket) =>
Effect.gen(function* () {
const cfg = socket.config();
const resp = yield* Effect.tryPromise({
try: () => cfg.list(type),
catch: (error) => cliCommandError("config.list", error),
});
yield* writeJson(resp);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () => cfg.list(type),
catch: (error) => cliCommandError("config.list", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("List configuration keys for a type"));
config
.command("delete")
.description("Delete a configuration entry")
.argument("<key>", "Config key (format: type/key)")
.action((key: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const deleteCommand = Command.make("delete", {
key: Argument.string("key").pipe(Argument.withDescription("Config key (format: type/key)")),
}, ({ key }) =>
withSocket((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 = yield* Effect.tryPromise({
try: () => cfg.deleteConfig(configKey),
catch: (error) => cliCommandError("config.delete", error),
});
yield* writeJson(resp);
}),
)),
);
}
const resp = yield* Effect.tryPromise({
try: () => cfg.deleteConfig(configKey),
catch: (error) => cliCommandError("config.delete", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Delete a configuration entry"));
export const configCommand = Command.make("config").pipe(
Command.withDescription("Configuration management"),
Command.withSubcommands([show, get, set, list, deleteCommand]),
);

View file

@ -4,25 +4,25 @@
* Generate text embeddings using the configured embedding model.
*/
import type { Command } from "commander";
import { Effect } from "effect";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
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((texts: string[], _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
export const embeddingsCommand = Command.make("embeddings", {
texts: Argument.string("text").pipe(
Argument.withDescription("Text(s) to embed"),
Argument.variadic({ min: 1 }),
),
}, ({ texts }) =>
withSocket((socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
const vectors = yield* Effect.tryPromise({
try: () => flow.embeddings(texts),
catch: (error) => cliCommandError("embeddings", error),
});
yield* writeJson(vectors);
}),
)),
);
}
const vectors = yield* Effect.tryPromise({
try: () => flow.embeddings(Array.from(texts)),
catch: (error) => cliCommandError("embeddings", error),
});
yield* writeJson(vectors);
}),
),
).pipe(Command.withDescription("Generate text embeddings"));

View file

@ -4,96 +4,99 @@
* Python reference: trustgraph-cli/trustgraph/cli/start_flow.py, stop_flow.py, etc.
*/
import type { Command } from "commander";
import { Effect } from "effect";
import * as S from "effect/Schema";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerFlowCommands(program: Command): void {
const flow = program
.command("flow")
.description("Flow management");
flow
.command("list")
.description("List active flows")
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const list = Command.make("list", {}, () =>
withSocket((socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const ids = yield* Effect.tryPromise({
try: () => flows.getFlows(),
catch: (error) => cliCommandError("flow.list", error),
});
yield* writeJson(ids);
}),
)),
);
const ids = yield* Effect.tryPromise({
try: () => flows.getFlows(),
catch: (error) => cliCommandError("flow.list", error),
});
yield* writeJson(ids);
}),
),
).pipe(Command.withDescription("List active flows"));
flow
.command("get")
.description("Get a flow definition")
.argument("<id>", "Flow ID")
.action((id: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const get = Command.make("get", {
id: Argument.string("id").pipe(Argument.withDescription("Flow ID")),
}, ({ id }) =>
withSocket((socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const def = yield* Effect.tryPromise({
try: () => flows.getFlow(id),
catch: (error) => cliCommandError("flow.get", error),
});
yield* writeJson(def);
}),
)),
);
const def = yield* Effect.tryPromise({
try: () => flows.getFlow(id),
catch: (error) => cliCommandError("flow.get", error),
});
yield* writeJson(def);
}),
),
).pipe(Command.withDescription("Get a flow definition"));
flow
.command("start")
.description("Start a flow")
.argument("<id>", "Flow ID")
.requiredOption("-b, --blueprint <name>", "Blueprint name")
.option("-d, --description <text>", "Flow description", "")
.option("-p, --parameters <json>", "Parameters as JSON")
.action((id: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const start = Command.make("start", {
id: Argument.string("id").pipe(Argument.withDescription("Flow ID")),
blueprint: Flag.string("blueprint").pipe(
Flag.withAlias("b"),
Flag.withDescription("Blueprint name"),
),
description: Flag.string("description").pipe(
Flag.withAlias("d"),
Flag.withDescription("Flow description"),
Flag.withDefault(""),
),
parameters: Flag.string("parameters").pipe(
Flag.withAlias("p"),
Flag.withDescription("Parameters as JSON"),
Flag.optional,
),
}, ({ id, blueprint, description, parameters }) =>
withSocket((socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const rawParameters = cmdOpts.parameters as string | undefined;
const params = rawParameters !== undefined && rawParameters.length > 0
const rawParameters = parameters._tag === "Some" ? parameters.value : undefined;
const params = rawParameters !== undefined && rawParameters.length > 0
? 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 = 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);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () =>
flows.startFlow(
id,
blueprint,
description,
params,
),
catch: (error) => cliCommandError("flow.start", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Start a flow"));
flow
.command("stop")
.description("Stop a flow")
.argument("<id>", "Flow ID")
.action((id: string, _opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const stop = Command.make("stop", {
id: Argument.string("id").pipe(Argument.withDescription("Flow ID")),
}, ({ id }) =>
withSocket((socket) =>
Effect.gen(function* () {
const flows = socket.flows();
const resp = yield* Effect.tryPromise({
try: () => flows.stopFlow(id),
catch: (error) => cliCommandError("flow.stop", error),
});
yield* writeJson(resp);
}),
)),
);
}
const resp = yield* Effect.tryPromise({
try: () => flows.stopFlow(id),
catch: (error) => cliCommandError("flow.stop", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Stop a flow"));
export const flowCommand = Command.make("flow").pipe(
Command.withDescription("Flow management"),
Command.withSubcommands([list, get, start, stop]),
);

View file

@ -4,65 +4,72 @@
* Python reference: trustgraph-cli/trustgraph/cli/invoke_graph_rag.py
*/
import type { Command } from "commander";
import { Effect } from "effect";
import * as O from "effect/Option";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { cliCommandError, withSocket, writeLine } from "./util.js";
export function registerGraphRagCommands(program: Command): void {
program
.command("graph-rag")
.description("Query the knowledge graph using RAG")
.argument("<query>", "Natural language query")
.option("--entity-limit <n>", "Max entities", "50")
.option("--triple-limit <n>", "Max triples per entity", "30")
.option("--collection <name>", "Collection name")
.action((query: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
export const graphRagCommand = Command.make("graph-rag", {
query: Argument.string("query").pipe(Argument.withDescription("Natural language query")),
entityLimit: Flag.integer("entity-limit").pipe(
Flag.withDescription("Max entities"),
Flag.withDefault(50),
),
tripleLimit: Flag.integer("triple-limit").pipe(
Flag.withDescription("Max triples per entity"),
Flag.withDefault(30),
),
collection: Flag.string("collection").pipe(
Flag.withDescription("Collection name"),
Flag.optional,
),
}, ({ query, entityLimit, tripleLimit, collection }) =>
withSocket((socket, opts) =>
Effect.gen(function* () {
const flow = socket.flow(opts.flow);
const collection = cmdOpts.collection as string | undefined;
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);
}),
)),
);
const response = yield* Effect.tryPromise({
try: () =>
flow.graphRag(
query,
{
entityLimit,
tripleLimit,
},
O.getOrUndefined(collection),
),
catch: (error) => cliCommandError("graph-rag", error),
});
yield* writeLine(response);
}),
),
).pipe(Command.withDescription("Query the knowledge graph using RAG"));
program
.command("document-rag")
.description("Query documents using RAG")
.argument("<query>", "Natural language query")
.option("--doc-limit <n>", "Max documents", "20")
.option("--collection <name>", "Collection name")
.action((query: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
export const documentRagCommand = Command.make("document-rag", {
query: Argument.string("query").pipe(Argument.withDescription("Natural language query")),
docLimit: Flag.integer("doc-limit").pipe(
Flag.withDescription("Max documents"),
Flag.withDefault(20),
),
collection: Flag.string("collection").pipe(
Flag.withDescription("Collection name"),
Flag.optional,
),
}, ({ query, docLimit, collection }) =>
withSocket((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 = 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);
}),
)),
);
}
const response = yield* Effect.tryPromise({
try: () =>
flow.documentRag(
query,
docLimit,
O.getOrUndefined(collection),
),
catch: (error) => cliCommandError("document-rag", error),
});
yield* writeLine(response);
}),
),
).pipe(Command.withDescription("Query documents using RAG"));

View file

@ -4,8 +4,11 @@
* Manages documents stored in the TrustGraph library.
*/
import type { Command } from "commander";
import { Effect, Match } from "effect";
import * as O from "effect/Option";
import * as Argument from "effect/unstable/cli/Argument";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { cliCommandError, withSocket, writeJson } from "./util.js";
function basenamePath(filepath: string): string {
@ -30,98 +33,106 @@ export function guessMimeType(filepath: string): string {
);
}
export function registerLibraryCommands(program: Command): void {
const library = program
.command("library")
.description("Document library management");
library
.command("list")
.description("List documents in the library")
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const list = Command.make("list", {}, () =>
withSocket((socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const docs = yield* Effect.tryPromise({
try: () => lib.getDocuments(),
catch: (error) => cliCommandError("library.list", error),
});
yield* writeJson(docs);
}),
)),
);
const docs = yield* Effect.tryPromise({
try: () => lib.getDocuments(),
catch: (error) => cliCommandError("library.list", error),
});
yield* writeJson(docs);
}),
),
).pipe(Command.withDescription("List documents in the library"));
library
.command("load")
.description("Load a document into the library")
.argument("<file>", "Path to the file to load")
.option("-t, --title <title>", "Document title")
.option("-m, --mime-type <type>", "MIME type (auto-detected if omitted)")
.option("-c, --comments <text>", "Comments", "")
.option("--tags <tags...>", "Document tags")
.option("--id <id>", "Optional document ID")
.action((file: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const load = Command.make("load", {
file: Argument.string("file").pipe(Argument.withDescription("Path to the file to load")),
title: Flag.string("title").pipe(
Flag.withAlias("t"),
Flag.withDescription("Document title"),
Flag.optional,
),
mimeType: Flag.string("mime-type").pipe(
Flag.withAlias("m"),
Flag.withDescription("MIME type (auto-detected if omitted)"),
Flag.optional,
),
comments: Flag.string("comments").pipe(
Flag.withAlias("c"),
Flag.withDescription("Comments"),
Flag.withDefault(""),
),
tags: Flag.string("tags").pipe(
Flag.withDescription("Document tags"),
Flag.atMost(Number.MAX_SAFE_INTEGER),
),
id: Flag.string("id").pipe(
Flag.withDescription("Optional document ID"),
Flag.optional,
),
}, ({ file, title, mimeType, comments, tags, id }) =>
withSocket((socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
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 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 resolvedMimeType = O.getOrUndefined(mimeType) ?? guessMimeType(file);
const resolvedTitle = O.getOrUndefined(title) ?? basenamePath(file);
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);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () =>
lib.loadDocument(
b64,
resolvedMimeType,
resolvedTitle,
comments,
Array.from(tags),
O.getOrUndefined(id),
),
catch: (error) => cliCommandError("library.load", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Load a document into the library"));
library
.command("remove")
.description("Remove a document from the library")
.argument("<id>", "Document ID to remove")
.option("--collection <name>", "Collection name")
.action((id: string, cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const remove = Command.make("remove", {
id: Argument.string("id").pipe(Argument.withDescription("Document ID to remove")),
collection: Flag.string("collection").pipe(
Flag.withDescription("Collection name"),
Flag.optional,
),
}, ({ id, collection }) =>
withSocket((socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const resp = yield* Effect.tryPromise({
try: () => lib.removeDocument(id, cmdOpts.collection as string | undefined),
catch: (error) => cliCommandError("library.remove", error),
});
yield* writeJson(resp);
}),
)),
);
const resp = yield* Effect.tryPromise({
try: () => lib.removeDocument(id, O.getOrUndefined(collection)),
catch: (error) => cliCommandError("library.remove", error),
});
yield* writeJson(resp);
}),
),
).pipe(Command.withDescription("Remove a document from the library"));
library
.command("processing")
.description("List documents currently being processed")
.action((_opts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket) =>
Effect.gen(function* () {
const processing = Command.make("processing", {}, () =>
withSocket((socket) =>
Effect.gen(function* () {
const lib = socket.librarian();
const items = yield* Effect.tryPromise({
try: () => lib.getProcessing(),
catch: (error) => cliCommandError("library.processing", error),
});
yield* writeJson(items);
}),
)),
);
}
const items = yield* Effect.tryPromise({
try: () => lib.getProcessing(),
catch: (error) => cliCommandError("library.processing", error),
});
yield* writeJson(items);
}),
),
).pipe(Command.withDescription("List documents currently being processed"));
export const libraryCommand = Command.make("library").pipe(
Command.withDescription("Document library management"),
Command.withSubcommands([list, load, remove, processing]),
);

View file

@ -4,50 +4,67 @@
* Query the knowledge graph for subject-predicate-object triples.
*/
import type { Command } from "commander";
import type { Term } from "@trustgraph/client";
import { Effect } from "effect";
import * as O from "effect/Option";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { cliCommandError, withSocket, writeJson } from "./util.js";
export function registerTriplesCommands(program: Command): void {
program
.command("triples")
.description("Query knowledge graph triples")
.option("-s, --subject <iri>", "Subject IRI")
.option("-p, --predicate <iri>", "Predicate IRI")
.option("-o, --object <iri>", "Object IRI or literal")
.option("-l, --limit <n>", "Max results", "20")
.option("--collection <name>", "Collection name")
.action((cmdOpts, cmd) =>
Effect.runPromise(withSocket(cmd, (socket, opts) =>
Effect.gen(function* () {
export const triplesCommand = Command.make("triples", {
subject: Flag.string("subject").pipe(
Flag.withAlias("s"),
Flag.withDescription("Subject IRI"),
Flag.optional,
),
predicate: Flag.string("predicate").pipe(
Flag.withAlias("p"),
Flag.withDescription("Predicate IRI"),
Flag.optional,
),
object: Flag.string("object").pipe(
Flag.withAlias("o"),
Flag.withDescription("Object IRI or literal"),
Flag.optional,
),
limit: Flag.integer("limit").pipe(
Flag.withAlias("l"),
Flag.withDescription("Max results"),
Flag.withDefault(20),
),
collection: Flag.string("collection").pipe(
Flag.withDescription("Collection name"),
Flag.optional,
),
}, ({ subject, predicate, object, limit, collection }) =>
withSocket((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;
const object = cmdOpts.object as string | undefined;
const s: Term | undefined = subject !== undefined && subject.length > 0
? { t: "i", i: subject }
const subjectValue = O.getOrUndefined(subject);
const predicateValue = O.getOrUndefined(predicate);
const objectValue = O.getOrUndefined(object);
const s: Term | undefined = subjectValue !== undefined && subjectValue.length > 0
? { t: "i", i: subjectValue }
: undefined;
const p: Term | undefined = predicate !== undefined && predicate.length > 0
? { t: "i", i: predicate }
const p: Term | undefined = predicateValue !== undefined && predicateValue.length > 0
? { t: "i", i: predicateValue }
: undefined;
const o: Term | undefined = object !== undefined && object.length > 0
? { t: "i", i: object }
const o: Term | undefined = objectValue !== undefined && objectValue.length > 0
? { t: "i", i: objectValue }
: undefined;
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);
}),
)),
);
}
const triples = yield* Effect.tryPromise({
try: () =>
flow.triplesQuery(
s,
p,
o,
limit,
O.getOrUndefined(collection),
),
catch: (error) => cliCommandError("triples", error),
});
yield* writeJson(triples);
}),
),
).pipe(Command.withDescription("Query knowledge graph triples"));

View file

@ -2,10 +2,12 @@
* Shared CLI utilities.
*/
import type { Command } from "commander";
import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client";
import { Duration, Effect } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
export interface CliOpts {
gateway: string;
@ -14,12 +16,42 @@ export interface CliOpts {
flow: string;
}
export function getOpts(cmd: Command): CliOpts {
// Walk up to root command to get global options
let root = cmd;
while (root.parent !== null) root = root.parent;
return root.opts() as CliOpts;
}
export const rootCommand = Command.make("tg").pipe(
Command.withDescription("TrustGraph CLI - interact with TrustGraph services"),
Command.withSharedFlags({
gateway: Flag.string("gateway").pipe(
Flag.withAlias("g"),
Flag.withDescription("Gateway WebSocket URL"),
Flag.withDefault("ws://localhost:8088/api/v1/rpc"),
),
user: Flag.string("user").pipe(
Flag.withAlias("u"),
Flag.withDescription("User identifier"),
Flag.withDefault("cli"),
),
token: Flag.string("token").pipe(
Flag.withAlias("t"),
Flag.withDescription("Authentication token"),
Flag.optional,
),
flow: Flag.string("flow").pipe(
Flag.withAlias("f"),
Flag.withDescription("Flow ID"),
Flag.withDefault("default"),
),
}),
);
export const getOpts = Effect.gen(function* () {
const opts = yield* rootCommand;
const base = {
gateway: opts.gateway,
user: opts.user,
flow: opts.flow,
};
const token = O.getOrUndefined(opts.token);
return token === undefined ? base : { ...base, token } satisfies CliOpts;
});
export class CliCommandError extends S.TaggedErrorClass<CliCommandError>()(
"CliCommandError",
@ -78,19 +110,16 @@ export function createSocketEffect(opts: CliOpts): Effect.Effect<BaseApi, CliCom
);
}
export function createSocket(opts: CliOpts): Promise<BaseApi> {
return Effect.runPromise(createSocketEffect(opts));
}
export const withSocket = <A, E, R>(
cmd: Command,
export const withSocket = Effect.fn("withSocket")(function* <A, E, R>(
use: (socket: BaseApi, opts: CliOpts) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
createSocketEffect(getOpts(cmd)),
(socket) => use(socket, getOpts(cmd)),
) {
const opts = yield* getOpts;
return yield* Effect.acquireUseRelease(
createSocketEffect(opts),
(socket) => use(socket, opts),
(socket) =>
Effect.sync(() => {
socket.close();
}),
);
});

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
/** @effect-diagnostics strictEffectProvide:skip-file */
/**
* Unified TrustGraph CLI.
*
@ -9,32 +11,33 @@
* Python reference: trustgraph-cli/trustgraph/cli/
*/
import { Command } from "commander";
import { registerAgentCommands } from "./commands/agent.js";
import { registerGraphRagCommands } from "./commands/graph-rag.js";
import { registerConfigCommands } from "./commands/config.js";
import { registerFlowCommands } from "./commands/flow.js";
import { registerLibraryCommands } from "./commands/library.js";
import { registerTriplesCommands } from "./commands/triples.js";
import { registerEmbeddingsCommands } from "./commands/embeddings.js";
import { BunRuntime, BunServices } from "@effect/platform-bun";
import { Effect } from "effect";
import * as Command from "effect/unstable/cli/Command";
import { agentCommand } from "./commands/agent.js";
import { configCommand } from "./commands/config.js";
import { embeddingsCommand } from "./commands/embeddings.js";
import { flowCommand } from "./commands/flow.js";
import { graphRagCommand, documentRagCommand } from "./commands/graph-rag.js";
import { libraryCommand } from "./commands/library.js";
import { triplesCommand } from "./commands/triples.js";
import { rootCommand } from "./commands/util.js";
const program = new Command();
export const cli = rootCommand.pipe(
Command.withSubcommands([
agentCommand,
graphRagCommand,
documentRagCommand,
configCommand,
flowCommand,
libraryCommand,
triplesCommand,
embeddingsCommand,
]),
);
program
.name("tg")
.description("TrustGraph CLI — interact with TrustGraph services")
.version("0.1.0")
.option("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/rpc")
.option("-u, --user <id>", "User identifier", "cli")
.option("-t, --token <token>", "Authentication token")
.option("-f, --flow <id>", "Flow ID", "default");
export const program = Command.run(cli, { version: "0.1.0" }).pipe(
Effect.provide(BunServices.layer),
);
registerAgentCommands(program);
registerGraphRagCommands(program);
registerConfigCommands(program);
registerFlowCommands(program);
registerLibraryCommands(program);
registerTriplesCommands(program);
registerEmbeddingsCommands(program);
program.parse();
BunRuntime.runMain(program);

View file

@ -12,7 +12,7 @@
"test": "bunx --bun vitest run"
},
"dependencies": {
"effect": "4.0.0-beta.75"
"effect": "4.0.0-beta.78"
},
"peerDependencies": {
"ws": "^8.0.0"
@ -23,7 +23,7 @@
}
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.75",
"@effect/vitest": "4.0.0-beta.78",
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",

View file

@ -11,32 +11,30 @@
"test": "bunx --bun vitest run"
},
"dependencies": {
"@effect/ai-anthropic": "4.0.0-beta.75",
"@effect/ai-openai": "4.0.0-beta.75",
"@effect/ai-openrouter": "4.0.0-beta.75",
"@effect/atom-react": "4.0.0-beta.75",
"@effect/openapi-generator": "4.0.0-beta.75",
"@effect/opentelemetry": "4.0.0-beta.75",
"@effect/platform-browser": "4.0.0-beta.75",
"@effect/platform-bun": "4.0.0-beta.75",
"@effect/platform-node": "4.0.0-beta.75",
"@effect/platform-node-shared": "4.0.0-beta.75",
"@effect/tsgo": "0.13.0",
"@effect/vitest": "4.0.0-beta.75",
"@fastify/websocket": "^11.0.0",
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78",
"@mistralai/mistralai": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"@qdrant/js-client-rest": "^1.13.0",
"@trustgraph/base": "workspace:*",
"effect": "4.0.0-beta.75",
"effect": "4.0.0-beta.78",
"falkordb": "^5.0.0",
"fastify": "^5.2.0",
"ollama": "^0.6.3",
"openai": "^4.85.0",
"pdfjs-dist": "^5.6.205"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.75",
"@effect/vitest": "4.0.0-beta.78",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6"

View file

@ -59,17 +59,19 @@ class RecordingProducer<T> implements BackendProducer<T> {
closeCount = 0;
flushCount = 0;
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class PushConsumer<T> implements BackendConsumer<T> {
@ -89,33 +91,39 @@ class PushConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return message ?? null;
}
return await new Promise((resolve) => {
this.waiters.push(resolve);
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return Promise.resolve(message ?? null);
}
return new Promise<Message<T> | null>((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
});
}
class ChunkingBackend implements PubSubBackend {
@ -126,26 +134,30 @@ class ChunkingBackend implements PubSubBackend {
readonly consumerOptions: Array<CreateConsumerOptions> = [];
closeCount = 0;
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions.push(options);
const producer = new RecordingProducer<unknown>();
this.producersByTopic.set(options.topic, producer);
return producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions.push(options);
const producer = new RecordingProducer<unknown>();
this.producersByTopic.set(options.topic, producer);
return producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions.push(options);
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
const consumer = new PushConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
return consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions.push(options);
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
const consumer = new PushConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
return consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
pushConfig(): void {
this.configConsumer.push(

View file

@ -20,29 +20,29 @@ import type {
class NoopPubSub implements PubSubBackend {
readonly sentByTopic = new Map<string, Array<unknown>>();
async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
return {
send: async (message) => {
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.succeed({
send: (message) => Effect.sync(() => {
const sent = this.sentByTopic.get(options.topic) ?? [];
sent.push(message);
this.sentByTopic.set(options.topic, sent);
},
flush: async () => undefined,
close: async () => undefined,
};
}),
flush: Effect.void,
close: Effect.void,
});
}
async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
return {
receive: async () => null,
acknowledge: async () => undefined,
negativeAcknowledge: async () => undefined,
unsubscribe: async () => undefined,
close: async () => undefined,
};
createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.succeed({
receive: () => Effect.succeed(null),
acknowledge: () => Effect.void,
negativeAcknowledge: () => Effect.void,
unsubscribe: Effect.void,
close: Effect.void,
});
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
const makeService = (persistPath?: string) =>
@ -59,9 +59,9 @@ describe("ConfigService operations", () => {
const putRequest: ConfigRequest = { operation: "put" };
const deleteRequest: ConfigRequest = { operation: "delete" };
const putError = await service.handlePut(putRequest)
const putError = await Effect.runPromise(service.handlePutEffect(putRequest))
.catch((caught: unknown) => caught);
const deleteError = await service.handleDelete(deleteRequest)
const deleteError = await Effect.runPromise(service.handleDeleteEffect(deleteRequest))
.catch((caught: unknown) => caught);
expect(putError).toBeInstanceOf(ConfigServiceError);
@ -81,7 +81,7 @@ describe("ConfigService operations", () => {
],
};
await service.handlePut(putRequest);
await Effect.runPromise(service.handlePutEffect(putRequest));
const persisted = await Bun.file(persistPath).json();
await rm(dir, { recursive: true, force: true });
@ -107,7 +107,7 @@ describe("ConfigService operations", () => {
);
const service = makeService(persistPath);
await service.loadFromDisk();
await Effect.runPromise(service.loadFromDiskEffect);
const getRequest: ConfigRequest = {
operation: "get",
keys: ["prompt", "system"],
@ -131,7 +131,10 @@ describe("ConfigService operations", () => {
{ operation: "put", values: [{ workspace: "beta", type: "prompt", key: "c", value: "three" }] },
];
await Promise.all(requests.map((request) => service.handlePut(request)));
await Effect.runPromise(Effect.all(requests.map((request) => service.handlePutEffect(request)), {
concurrency: "unbounded",
discard: true,
}));
expect(service.handleGet({ operation: "get", keys: ["prompt"] })).toEqual({
version: 3,
@ -150,53 +153,53 @@ describe("ConfigService operations", () => {
it("dispatches all config operations through the Match-backed handler", async () => {
const service = makeService();
await expect(service.handleOperation({ operation: "put" })).rejects.toMatchObject({
await expect(Effect.runPromise(service.handleOperationEffect({ operation: "put" }))).rejects.toMatchObject({
_tag: "ConfigServiceError",
operation: "put",
});
await expect(service.handleOperation({ operation: "delete" })).rejects.toMatchObject({
await expect(Effect.runPromise(service.handleOperationEffect({ operation: "delete" }))).rejects.toMatchObject({
_tag: "ConfigServiceError",
operation: "delete",
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "put",
values: [{ type: "prompt", key: "system", value: "hello" }],
})).resolves.toEqual({ version: 1 });
}))).resolves.toEqual({ version: 1 });
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "get",
keys: ["prompt", "system"],
})).resolves.toEqual({
}))).resolves.toEqual({
version: 1,
values: { system: "hello" },
});
await expect(service.handleOperation({ operation: "list" })).resolves.toEqual({
await expect(Effect.runPromise(service.handleOperationEffect({ operation: "list" }))).resolves.toEqual({
version: 1,
directory: ["prompt"],
});
await expect(service.handleOperation({ operation: "config" })).resolves.toEqual({
await expect(Effect.runPromise(service.handleOperationEffect({ operation: "config" }))).resolves.toEqual({
version: 1,
config: { prompt: { system: "hello" } },
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "getvalues",
type: "prompt",
})).resolves.toEqual({
}))).resolves.toEqual({
version: 1,
values: [{ type: "prompt", key: "system", value: "hello" }],
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "getvalues-all-ws",
type: "prompt",
})).resolves.toEqual({
}))).resolves.toEqual({
version: 1,
values: [{ workspace: "default", type: "prompt", key: "system", value: "hello" }],
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "delete",
keys: ["prompt", "system"],
})).resolves.toEqual({ version: 2 });
}))).resolves.toEqual({ version: 2 });
});
it("pushes config from the stored producer handle", async () => {
@ -206,10 +209,10 @@ describe("ConfigService operations", () => {
manageProcessSignals: false,
pubsub: backend,
});
const pushProducer = await backend.createProducer<{
const pushProducer = await Effect.runPromise(backend.createProducer<{
readonly version: number;
readonly config: Record<string, unknown>;
}>({ topic: topics.configPush });
}>({ topic: topics.configPush }));
await Effect.runPromise(
SynchronizedRef.update(service.state, (state) => ({
@ -217,11 +220,11 @@ describe("ConfigService operations", () => {
pushProducer,
})),
);
await service.pushConfig();
await service.handlePut({
await Effect.runPromise(service.pushConfigEffect);
await Effect.runPromise(service.handlePutEffect({
operation: "put",
values: [{ type: "prompt", key: "system", value: "hello" }],
});
}));
expect(backend.sentByTopic.get(topics.configPush)).toEqual([
{ version: 0, config: {} },

View file

@ -17,30 +17,34 @@ class FakeFalkorDBClient implements FalkorDBStoreClient, FalkorDBQueryClient {
connectCount = 0;
disconnectCount = 0;
async connect(): Promise<void> {
readonly connect: Effect.Effect<void> = Effect.sync(() => {
this.connectCount += 1;
}
});
async disconnect(): Promise<void> {
readonly disconnect: Effect.Effect<void> = Effect.sync(() => {
this.disconnectCount += 1;
}
});
}
class FakeStoreGraph implements FalkorDBStoreGraph {
readonly queries: string[] = [];
async query<T = unknown>(query: string): Promise<{ readonly data?: Array<T> }> {
this.queries.push(query);
return {};
query<T = unknown>(query: string): Effect.Effect<{ readonly data?: Array<T> }> {
return Effect.sync(() => {
this.queries.push(query);
return {};
});
}
}
class FakeQueryGraph implements FalkorDBQueryGraph {
readonly queries: string[] = [];
async query<T = unknown>(query: string): Promise<{ readonly data?: Array<T> }> {
this.queries.push(query);
return {};
query<T = unknown>(query: string): Effect.Effect<{ readonly data?: Array<T> }> {
return Effect.sync(() => {
this.queries.push(query);
return {};
});
}
}

View file

@ -19,29 +19,29 @@ import {FlowManagerError, makeFlowManagerService} from "../flow-manager/service.
class NoopPubSub implements PubSubBackend {
readonly sentByTopic = new Map<string, Array<unknown>>();
async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
return {
send: async (message) => {
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.succeed({
send: (message) => Effect.sync(() => {
const sent = this.sentByTopic.get(options.topic) ?? [];
sent.push(message);
this.sentByTopic.set(options.topic, sent);
},
flush: async () => undefined,
close: async () => undefined,
};
}),
flush: Effect.void,
close: Effect.void,
});
}
async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
return {
receive: async () => null,
acknowledge: async () => undefined,
negativeAcknowledge: async () => undefined,
unsubscribe: async () => undefined,
close: async () => undefined,
};
createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.succeed({
receive: () => Effect.succeed(null),
acknowledge: () => Effect.void,
negativeAcknowledge: () => Effect.void,
unsubscribe: Effect.void,
close: Effect.void,
});
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
class RecordingConfigClient implements RequestResponse<ConfigRequest, ConfigResponse> {
@ -53,25 +53,27 @@ class RecordingConfigClient implements RequestResponse<ConfigRequest, ConfigResp
private readonly legacyFlows: Array<{readonly key: string; readonly value: unknown}> = [],
) {}
async start(): Promise<void> {}
readonly start: Effect.Effect<void> = Effect.void;
async stop(): Promise<void> {}
readonly stop: Effect.Effect<void> = Effect.void;
async request(request: ConfigRequest): Promise<ConfigResponse> {
this.requests.push(request);
if (request.operation !== "getvalues") return {};
request(request: ConfigRequest): Effect.Effect<ConfigResponse> {
return Effect.sync(() => {
this.requests.push(request);
if (request.operation !== "getvalues") return {};
if (request.type === "flow-blueprint") {
return {values: this.blueprints};
}
if (request.type === "flow") {
return {values: this.flows};
}
if (request.type === "flows") {
return {values: this.legacyFlows};
}
if (request.type === "flow-blueprint") {
return {values: this.blueprints};
}
if (request.type === "flow") {
return {values: this.flows};
}
if (request.type === "flows") {
return {values: this.legacyFlows};
}
return {values: []};
return {values: []};
});
}
}
@ -97,9 +99,9 @@ const seedResponseProducer = async (
backend: NoopPubSub,
service: ReturnType<typeof makeFlowManagerService>,
) => {
const responseProducer = await backend.createProducer<FlowResponse>({
const responseProducer = await Effect.runPromise(backend.createProducer<FlowResponse>({
topic: topics.flowResponse,
});
}));
await Effect.runPromise(
SynchronizedRef.update(service.state, (state) => ({
...state,
@ -127,43 +129,43 @@ describe("FlowManagerService operations", () => {
const service = makeService();
await seedConfigClient(service, configClient);
await expect(service.handleOperation({operation: "list-blueprints"})).resolves.toEqual({
await expect(Effect.runPromise(service.handleOperationEffect({operation: "list-blueprints"}))).resolves.toEqual({
"blueprint-names": ["custom", "default"],
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "get-blueprint",
"blueprint-name": "custom",
})).resolves.toMatchObject({
}))).resolves.toMatchObject({
"blueprint-definition": "{\"description\":\"Custom\",\"topics\":{\"input\":\"topic.in\"}}",
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "put-blueprint",
"blueprint-name": "added",
"blueprint-definition": {description: "Added", topics: {input: "topic.added"}},
})).resolves.toEqual({});
await expect(service.handleOperation({
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "delete-blueprint",
"blueprint-name": "custom",
})).resolves.toEqual({});
await expect(service.handleOperation({operation: "list-flows"})).resolves.toEqual({
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({operation: "list-flows"}))).resolves.toEqual({
"flow-ids": ["flow-a"],
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "get-flow",
"flow-id": "flow-a",
})).resolves.toEqual({
}))).resolves.toEqual({
flow: "{\"blueprint-name\":\"custom\",\"description\":\"Alpha\",\"parameters\":{\"limit\":3}}",
});
await expect(service.handleOperation({
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "start-flow",
"flow-id": "flow-b",
"blueprint-name": "custom",
})).resolves.toEqual({});
await expect(service.handleOperation({
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({
operation: "stop-flow",
"flow-id": "flow-a",
})).resolves.toEqual({});
await expect(service.handleOperation({operation: "unknown-flow"})).rejects.toMatchObject({
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleOperationEffect({operation: "unknown-flow"}))).rejects.toMatchObject({
_tag: "FlowManagerError",
operation: "operation",
message: "Unknown flow operation: unknown-flow",
@ -180,9 +182,9 @@ describe("FlowManagerService operations", () => {
it("uses tagged errors for invalid flow mutations", async () => {
const service = makeService();
const startError = await service.handleStartFlow({operation: "start-flow"})
const startError = await Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow"}))
.catch((caught: unknown) => caught);
const stopError = await service.handleStopFlow({operation: "stop-flow"})
const stopError = await Effect.runPromise(service.handleStopFlowEffect({operation: "stop-flow"}))
.catch((caught: unknown) => caught);
expect(startError).toBeInstanceOf(FlowManagerError);
@ -196,12 +198,12 @@ describe("FlowManagerService operations", () => {
const service = makeService();
await seedConfigClient(service, configClient);
await service.handleStartFlow({
await Effect.runPromise(service.handleStartFlowEffect({
operation: "start-flow",
"flow-id": "flow-a",
description: "alpha",
parameters: {limit: 3},
});
}));
let state = await Effect.runPromise(SynchronizedRef.get(service.state));
expect(Option.getOrUndefined(HashMap.get(state.flows, "flow-a"))).toMatchObject({
id: "flow-a",
@ -211,10 +213,10 @@ describe("FlowManagerService operations", () => {
status: "running",
});
await service.handleStopFlow({
await Effect.runPromise(service.handleStopFlowEffect({
operation: "stop-flow",
"flow-id": "flow-a",
});
}));
state = await Effect.runPromise(SynchronizedRef.get(service.state));
expect(HashMap.has(state.flows, "flow-a")).toBe(false);
@ -245,7 +247,7 @@ describe("FlowManagerService operations", () => {
const service = makeService();
await seedConfigClient(service, configClient);
await service.refreshBlueprintsFromConfig();
await Effect.runPromise(service.refreshBlueprintsFromConfigEffect);
const state = await Effect.runPromise(SynchronizedRef.get(service.state));
expect(Option.getOrUndefined(HashMap.get(state.blueprints, "custom"))).toMatchObject({
@ -263,8 +265,8 @@ describe("FlowManagerService operations", () => {
await seedConfigClient(service, configClient);
const results = await Promise.allSettled([
service.handleStartFlow({operation: "start-flow", "flow-id": "flow-a"}),
service.handleStartFlow({operation: "start-flow", "flow-id": "flow-a"}),
Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow", "flow-id": "flow-a"})),
Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow", "flow-id": "flow-a"})),
]);
const state = await Effect.runPromise(SynchronizedRef.get(service.state));

View file

@ -18,6 +18,7 @@ import type {
Message,
PubSubBackend,
} from "@trustgraph/base";
import { pubSubError } from "@trustgraph/base";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
@ -44,32 +45,38 @@ class TopicConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) return message ?? null;
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || this.closed) return Promise.resolve(message ?? null);
return await new Promise((resolve) => {
this.waiters.push(resolve);
return new Promise((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
negativeAcknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.nacked.push(message);
});
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
});
}
class RecordingProducer<T> implements BackendProducer<T> {
@ -82,18 +89,23 @@ class RecordingProducer<T> implements BackendProducer<T> {
private readonly onSend: (topic: string, message: T, properties?: Record<string, string>) => void,
) {}
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend(this.topic, message, properties);
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.try({
try: () => {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend(this.topic, message, properties);
},
catch: (error) => pubSubError(`send:${this.topic}`, error),
});
}
async flush(): Promise<void> {
readonly flush: Effect.Effect<void> = Effect.sync(() => {
this.flushCount += 1;
}
});
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
}
class DispatchBackend implements PubSubBackend {
@ -104,31 +116,35 @@ class DispatchBackend implements PubSubBackend {
readonly consumersByTopic = new Map<string, TopicConsumer<unknown>>();
readonly failSendTopics = new Set<string>();
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions.push(options);
let producer = this.producersByTopic.get(options.topic);
if (producer === undefined) {
producer = new RecordingProducer<unknown>(options.topic, (topic, message, properties) => {
this.handleSend(topic, message, properties);
});
this.producersByTopic.set(options.topic, producer);
}
return producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
this.producerOptions.push(options);
let producer = this.producersByTopic.get(options.topic);
if (producer === undefined) {
producer = new RecordingProducer<unknown>(options.topic, (topic, message, properties) => {
this.handleSend(topic, message, properties);
});
this.producersByTopic.set(options.topic, producer);
}
return producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions.push(options);
let consumer = this.consumersByTopic.get(options.topic);
if (consumer === undefined) {
consumer = new TopicConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
}
return consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
this.consumerOptions.push(options);
let consumer = this.consumersByTopic.get(options.topic);
if (consumer === undefined) {
consumer = new TopicConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
}
return consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closeCount += 1;
}
});
private handleSend(topic: string, message: unknown, properties?: Record<string, string>): void {
if (this.failSendTopics.has(topic)) {
@ -230,10 +246,10 @@ describe("gateway dispatcher manager", () => {
pubsub: backend,
});
await manager.start();
const first = await manager.dispatchGlobalService("config", { operation: "get" });
const second = await manager.dispatchGlobalService("config", { operation: "list" });
await manager.stop();
await Effect.runPromise(manager.start);
const first = await Effect.runPromise(manager.dispatchGlobalService("config", { operation: "get" }));
const second = await Effect.runPromise(manager.dispatchGlobalService("config", { operation: "list" }));
await Effect.runPromise(manager.stop);
expect(first).toEqual({ ok: true, echo: { operation: "get" } });
expect(second).toEqual({ ok: true, echo: { operation: "list" } });
@ -252,12 +268,12 @@ describe("gateway dispatcher manager", () => {
pubsub: backend,
});
await manager.start();
const [first, second] = await Promise.all([
await Effect.runPromise(manager.start);
const [first, second] = await Effect.runPromise(Effect.all([
manager.dispatchGlobalService("config", { operation: "get" }),
manager.dispatchGlobalService("config", { operation: "list" }),
]);
await manager.stop();
], { concurrency: "unbounded" }));
await Effect.runPromise(manager.stop);
expect(first).toEqual({ ok: true, echo: { operation: "get" } });
expect(second).toEqual({ ok: true, echo: { operation: "list" } });
@ -274,12 +290,12 @@ describe("gateway dispatcher manager", () => {
});
await expect(
manager.dispatchGlobalService("knowledge", { term: { t: "t" } }),
Effect.runPromise(manager.dispatchGlobalService("knowledge", { term: { t: "t" } })),
).rejects.toMatchObject({
_tag: "DispatchSerializationError",
operation: "client-term-to-internal",
});
await manager.stop();
await Effect.runPromise(manager.stop);
expect(backend.producerOptions).toHaveLength(0);
expect(backend.consumerOptions).toHaveLength(0);
@ -296,12 +312,12 @@ describe("gateway dispatcher manager", () => {
});
await expect(
manager.publishToTopic("tg.flow.ingest", { text: "hello" }, "msg-1"),
Effect.runPromise(manager.publishToTopic("tg.flow.ingest", { text: "hello" }, "msg-1")),
).rejects.toMatchObject({
_tag: "MessagingDeliveryError",
operation: "send",
});
await manager.stop();
await Effect.runPromise(manager.stop);
expect(backend.producersByTopic.get("tg.flow.ingest")?.closeCount).toBe(1);
expect(backend.closeCount).toBe(0);
@ -316,10 +332,14 @@ describe("gateway dispatcher manager", () => {
});
const chunks: Array<{ readonly response: unknown; readonly complete: boolean }> = [];
await manager.dispatchGlobalServiceStreaming("knowledge", { query: "hello" }, async (response, complete) => {
chunks.push({ response, complete });
});
await manager.stop();
await Effect.runPromise(
manager.dispatchGlobalServiceStreaming("knowledge", { query: "hello" }, (response, complete) =>
Effect.sync(() => {
chunks.push({ response, complete });
})
),
);
await Effect.runPromise(manager.stop);
expect(chunks).toEqual([
{ response: { chunk: 1 }, complete: false },
@ -337,13 +357,13 @@ describe("gateway dispatcher manager", () => {
const chunks: Array<{ readonly response: unknown; readonly complete: boolean }> = [];
await Effect.runPromise(
manager.dispatchGlobalServiceStreamingEffect("knowledge", { query: "hello" }, (response, complete) =>
manager.dispatchGlobalServiceStreaming("knowledge", { query: "hello" }, (response, complete) =>
Effect.sync(() => {
chunks.push({ response, complete });
})
),
);
await manager.stop();
await Effect.runPromise(manager.stop);
expect(chunks).toEqual([
{ response: { chunk: 1 }, complete: false },

View file

@ -20,29 +20,29 @@ import {makeKnowledgeCoreService} from "../cores/service.js";
class NoopPubSub implements PubSubBackend {
readonly sentByTopic = new Map<string, Array<unknown>>();
async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
return {
send: async (message) => {
createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.succeed({
send: (message) => Effect.sync(() => {
const sent = this.sentByTopic.get(options.topic) ?? [];
sent.push(message);
this.sentByTopic.set(options.topic, sent);
},
flush: async () => undefined,
close: async () => undefined,
};
}),
flush: Effect.void,
close: Effect.void,
});
}
async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
return {
receive: async () => null,
acknowledge: async (_message: Message<T>) => undefined,
negativeAcknowledge: async (_message: Message<T>) => undefined,
unsubscribe: async () => undefined,
close: async () => undefined,
};
createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.succeed({
receive: () => Effect.succeed(null),
acknowledge: (_message: Message<T>) => Effect.void,
negativeAcknowledge: (_message: Message<T>) => Effect.void,
unsubscribe: Effect.void,
close: Effect.void,
});
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
const sampleTriple: Triple = {
@ -63,9 +63,9 @@ const seedResponseProducer = async (
backend: NoopPubSub,
service: ReturnType<typeof makeKnowledgeCoreService>,
) => {
const responseProducer = await backend.createProducer<KnowledgeResponse>({
const responseProducer = await Effect.runPromise(backend.createProducer<KnowledgeResponse>({
topic: topics.knowledgeResponse,
});
}));
await Effect.runPromise(
SynchronizedRef.update(service.state, (state) => ({
...state,
@ -94,15 +94,15 @@ describe("KnowledgeCoreService operations", () => {
],
};
await service.putKgCore(request, "put-1");
await Effect.runPromise(service.putKgCoreEffect(request, "put-1"));
const state = await Effect.runPromise(SynchronizedRef.get(service.state));
const core = Option.getOrUndefined(HashMap.get(state.kgCores, "alice:core-a"));
await service.getKgCore({
await Effect.runPromise(service.getKgCoreEffect({
operation: "get-kg-core",
user: "alice",
id: "core-a",
}, "get-1");
}, "get-1"));
await rm(dir, {recursive: true, force: true});
expect(core?.triples).toEqual([sampleTriple]);
@ -142,14 +142,14 @@ describe("KnowledgeCoreService operations", () => {
const service = makeService(dir, backend);
await seedResponseProducer(backend, service);
await Promise.all([
service.putKgCore({
await Effect.runPromise(Effect.all([
service.putKgCoreEffect({
operation: "put-kg-core",
user: "alice",
id: "core-b",
triples: [sampleTriple],
}, "put-a"),
service.putKgCore({
service.putKgCoreEffect({
operation: "put-kg-core",
user: "alice",
id: "core-b",
@ -161,7 +161,10 @@ describe("KnowledgeCoreService operations", () => {
},
],
}, "put-b"),
]);
], {
concurrency: "unbounded",
discard: true,
}));
const state = await Effect.runPromise(SynchronizedRef.get(service.state));
await rm(dir, {recursive: true, force: true});
@ -183,7 +186,7 @@ describe("KnowledgeCoreService operations", () => {
);
const service = makeService(dir);
await service.loadFromDisk();
await Effect.runPromise(service.loadFromDiskEffect);
const state = await Effect.runPromise(SynchronizedRef.get(service.state));
await rm(dir, {recursive: true, force: true});

View file

@ -1,6 +1,7 @@
import {mkdtemp, rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {Effect} from "effect";
import {describe, expect, it} from "vitest";
import {
type BackendConsumer,
@ -15,25 +16,25 @@ import {
import {makeLibrarianService} from "../librarian/service.js";
class NoopPubSub implements PubSubBackend {
async createProducer<T>(_options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
return {
send: async () => undefined,
flush: async () => undefined,
close: async () => undefined,
};
createProducer<T>(_options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> {
return Effect.succeed({
send: () => Effect.void,
flush: Effect.void,
close: Effect.void,
});
}
async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
return {
receive: async () => null,
acknowledge: async (_message: Message<T>) => undefined,
negativeAcknowledge: async (_message: Message<T>) => undefined,
unsubscribe: async () => undefined,
close: async () => undefined,
};
createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.succeed({
receive: () => Effect.succeed(null),
acknowledge: (_message: Message<T>) => Effect.void,
negativeAcknowledge: (_message: Message<T>) => Effect.void,
unsubscribe: Effect.void,
close: Effect.void,
});
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
const sampleTriple: Triple = {
@ -66,40 +67,40 @@ describe("LibrarianService schema-backed boundaries", () => {
const service = makeService(dir);
try {
await expect(service.handleLibrarianOperation({
await expect(Effect.runPromise(service.handleLibrarianOperation({
operation: "list-documents",
user: "alice",
})).resolves.toEqual({
}))).resolves.toEqual({
documents: [],
"document-metadatas": [],
});
const upload = await service.handleLibrarianOperation({
const upload = await Effect.runPromise(service.handleLibrarianOperation({
operation: "begin-upload",
documentMetadata: sampleDocument,
"document-metadata": sampleDocument,
"total-size": 12,
"chunk-size": 4,
});
await expect(service.handleLibrarianOperation({
}));
await expect(Effect.runPromise(service.handleLibrarianOperation({
operation: "get-upload-status",
"upload-id": upload["upload-id"],
})).resolves.toMatchObject({
}))).resolves.toMatchObject({
"upload-id": upload["upload-id"],
"upload-state": "in-progress",
"missing-chunks": [0, 1, 2],
});
await expect(service.handleLibrarianOperation({
await expect(Effect.runPromise(service.handleLibrarianOperation({
operation: "stream-document",
"document-id": "doc-a",
})).rejects.toMatchObject({
}))).rejects.toMatchObject({
_tag: "LibrarianServiceError",
operation: "stream-document",
message: "stream-document must be handled as a streaming operation",
});
await expect(service.handleLibrarianOperation(JSON.parse(`{"operation":"unknown-librarian"}`))).rejects.toMatchObject({
await expect(Effect.runPromise(service.handleLibrarianOperation(JSON.parse(`{"operation":"unknown-librarian"}`)))).rejects.toMatchObject({
_tag: "LibrarianServiceError",
operation: "operation",
message: "Unknown librarian operation: unknown-librarian",
@ -114,14 +115,14 @@ describe("LibrarianService schema-backed boundaries", () => {
const service = makeService(dir);
try {
await expect(service.handleCollectionOperation({
await expect(Effect.runPromise(service.handleCollectionOperation({
operation: "update-collection",
user: "alice",
collection: "docs",
name: "Docs",
description: "Documentation",
tags: ["reference"],
})).resolves.toEqual({
}))).resolves.toEqual({
collections: [{
user: "alice",
collection: "docs",
@ -131,10 +132,10 @@ describe("LibrarianService schema-backed boundaries", () => {
}],
});
await expect(service.handleCollectionOperation({
await expect(Effect.runPromise(service.handleCollectionOperation({
operation: "list-collections",
user: "alice",
})).resolves.toEqual({
}))).resolves.toEqual({
collections: [{
user: "alice",
collection: "docs",
@ -144,17 +145,17 @@ describe("LibrarianService schema-backed boundaries", () => {
}],
});
await expect(service.handleCollectionOperation({
await expect(Effect.runPromise(service.handleCollectionOperation({
operation: "delete-collection",
user: "alice",
collection: "docs",
})).resolves.toEqual({});
await expect(service.handleCollectionOperation({
}))).resolves.toEqual({});
await expect(Effect.runPromise(service.handleCollectionOperation({
operation: "list-collections",
user: "alice",
})).resolves.toEqual({collections: []});
}))).resolves.toEqual({collections: []});
await expect(service.handleCollectionOperation(JSON.parse(`{"operation":"unknown-collection"}`))).rejects.toMatchObject({
await expect(Effect.runPromise(service.handleCollectionOperation(JSON.parse(`{"operation":"unknown-collection"}`)))).rejects.toMatchObject({
_tag: "LibrarianServiceError",
operation: "collection-operation",
message: "Unknown collection operation: unknown-collection",
@ -168,18 +169,18 @@ describe("LibrarianService schema-backed boundaries", () => {
const dir = await mkdtemp(join(tmpdir(), "trustgraph-librarian-service-"));
const service = makeService(dir);
const response = await service.beginUpload({
const response = await Effect.runPromise(service.beginUpload({
operation: "begin-upload",
documentMetadata: sampleDocument,
"document-metadata": sampleDocument,
"total-size": 12,
"chunk-size": 4,
});
}));
const uploadId = response["upload-id"];
const status = await service.getUploadStatus({
const status = await Effect.runPromise(service.getUploadStatus({
operation: "get-upload-status",
"upload-id": uploadId,
});
}));
await rm(dir, {recursive: true, force: true});
expect(uploadId).toEqual(expect.any(String));
@ -202,8 +203,8 @@ describe("LibrarianService schema-backed boundaries", () => {
);
const service = makeService(dir);
await service.loadFromDisk();
const documents = service.listDocuments({operation: "list-documents", user: "alice"}).documents;
await Effect.runPromise(service.loadFromDisk);
const documents = (await Effect.runPromise(service.listDocuments({operation: "list-documents", user: "alice"}))).documents;
await rm(dir, {recursive: true, force: true});
expect(documents).toEqual([{
@ -217,15 +218,15 @@ describe("LibrarianService schema-backed boundaries", () => {
const dir = await mkdtemp(join(tmpdir(), "trustgraph-librarian-service-"));
const service = makeService(dir);
const valid = await service.normaliseDocumentMetadata({
const valid = await Effect.runPromise(service.normaliseDocumentMetadata({
...sampleDocument,
metadata: [sampleTriple],
});
const invalid = await service.normaliseDocumentMetadata({
}));
const invalid = await Effect.runPromise(service.normaliseDocumentMetadata({
...sampleDocument,
id: "doc-b",
metadata: [{not: "a triple"}],
});
}));
await rm(dir, {recursive: true, force: true});
expect(valid.metadata).toEqual([sampleTriple]);

View file

@ -55,13 +55,15 @@ const waitFor = (condition: () => boolean, label: string) =>
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
send(message: T, properties?: Record<string, string>): Effect.Effect<void> {
return Effect.sync(() => {
this.sent.push(properties === undefined ? { message } : { message, properties });
});
}
async flush(): Promise<void> {}
readonly flush: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
}
class PushConsumer<T> implements BackendConsumer<T> {
@ -79,30 +81,36 @@ class PushConsumer<T> implements BackendConsumer<T> {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return message ?? null;
}
return await new Promise((resolve) => {
this.waiters.push(resolve);
receive(): Effect.Effect<Message<T> | null> {
return Effect.promise(() => {
const message = this.messages.shift();
if (message !== undefined || this.closed) {
return Promise.resolve(message ?? null);
}
return new Promise<Message<T> | null>((resolve) => {
this.waiters.push(resolve);
});
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
acknowledge(message: Message<T>): Effect.Effect<void> {
return Effect.sync(() => {
this.acknowledged.push(message);
});
}
async negativeAcknowledge(): Promise<void> {}
negativeAcknowledge(): Effect.Effect<void> {
return Effect.void;
}
async unsubscribe(): Promise<void> {}
readonly unsubscribe: Effect.Effect<void> = Effect.void;
async close(): Promise<void> {
readonly close: Effect.Effect<void> = Effect.sync(() => {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
}
});
}
class PromptBackend implements PubSubBackend {
@ -110,22 +118,26 @@ class PromptBackend implements PubSubBackend {
readonly consumersByTopic = new Map<string, PushConsumer<unknown>>();
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
const producer = new RecordingProducer<unknown>();
this.producersByTopic.set(options.topic, producer);
return producer as BackendProducer<T>;
createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> {
return Effect.sync(() => {
const producer = new RecordingProducer<unknown>();
this.producersByTopic.set(options.topic, producer);
return producer as BackendProducer<T>;
});
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
const consumer = new PushConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
return consumer as BackendConsumer<T>;
createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> {
return Effect.sync(() => {
if (options.topic === topics.configPush) {
return this.configConsumer as unknown as BackendConsumer<T>;
}
const consumer = new PushConsumer<unknown>();
this.consumersByTopic.set(options.topic, consumer);
return consumer as BackendConsumer<T>;
});
}
async close(): Promise<void> {}
readonly close: Effect.Effect<void> = Effect.void;
pushPromptConfig(): void {
this.configConsumer.push(createMessage({

View file

@ -33,44 +33,51 @@ class FakeQdrantClient implements QdrantClientLike {
readonly deletedCollections: string[] = [];
searchResults: ReadonlyArray<QdrantScoredPoint> = [];
async collectionExists(collectionName: string): Promise<{ readonly exists: boolean }> {
this.collectionExistsCalls.push(collectionName);
return { exists: this.collections.has(collectionName) };
collectionExists(collectionName: string): Effect.Effect<{ readonly exists: boolean }> {
return Effect.sync(() => {
this.collectionExistsCalls.push(collectionName);
return { exists: this.collections.has(collectionName) };
});
}
async createCollection(
createCollection(
collectionName: string,
options: { readonly vectors: { readonly size: number; readonly distance: "Cosine" } },
): Promise<void> {
this.collections.add(collectionName);
this.createdCollections.push({ name: collectionName, size: options.vectors.size });
): Effect.Effect<void> {
return Effect.sync(() => {
this.collections.add(collectionName);
this.createdCollections.push({ name: collectionName, size: options.vectors.size });
});
}
async upsert(
upsert(
collectionName: string,
options: { readonly points: ReadonlyArray<FakePoint> },
): Promise<void> {
this.upserts.push({ collectionName, points: options.points });
): Effect.Effect<void> {
return Effect.sync(() => {
this.upserts.push({ collectionName, points: options.points });
});
}
async getCollections(): Promise<{ readonly collections: ReadonlyArray<{ readonly name: string }> }> {
return { collections: Array.from(this.collections, (name) => ({ name })) };
readonly getCollections: Effect.Effect<{ readonly collections: ReadonlyArray<{ readonly name: string }> }> =
Effect.sync(() => ({ collections: Array.from(this.collections, (name) => ({ name })) }));
deleteCollection(collectionName: string): Effect.Effect<void> {
return Effect.sync(() => {
this.collections.delete(collectionName);
this.deletedCollections.push(collectionName);
});
}
async deleteCollection(collectionName: string): Promise<void> {
this.collections.delete(collectionName);
this.deletedCollections.push(collectionName);
}
async search(
search(
_collectionName: string,
_options: {
readonly vector: ReadonlyArray<number>;
readonly limit: number;
readonly with_payload: boolean;
},
): Promise<ReadonlyArray<QdrantScoredPoint>> {
return this.searchResults;
): Effect.Effect<ReadonlyArray<QdrantScoredPoint>> {
return Effect.sync(() => this.searchResults);
}
}
@ -206,22 +213,22 @@ describe("Qdrant embeddings", () => {
});
await Effect.runPromise(
store.storeEffect({
store.store({
user: "alice",
collection: "docs",
chunks: [{ chunkId: "chunk-a", vector: [1, 2], content: "alpha" }],
}),
);
await Effect.runPromise(
store.storeEffect({
store.store({
user: "alice",
collection: "docs",
chunks: [{ chunkId: "chunk-b", vector: [2, 1], content: "beta" }],
}),
);
await Effect.runPromise(store.deleteCollectionEffect("alice", "docs"));
await Effect.runPromise(store.deleteCollection("alice", "docs"));
await Effect.runPromise(
store.storeEffect({
store.store({
user: "alice",
collection: "docs",
chunks: [{ chunkId: "chunk-c", vector: [1, 1], content: "gamma" }],

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import type { LlmChunk } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Stream } from "effect";
import { Context, Effect, Stream } from "effect";
import { AiError, LanguageModel, Response } from "effect/unstable/ai";
import {
llmStreamPart,
@ -9,10 +8,9 @@ import {
providerStatusError,
streamTextCompletionChunks,
textFromContent,
toAsyncGenerator,
} from "../model/text-completion/common.js";
const languageModelRuntime = ManagedRuntime.make(Layer.empty);
const languageModelContext = Context.empty();
const usage = (inputTokens: number, outputTokens: number) => ({
inputTokens: {
@ -42,12 +40,6 @@ const aiError = (reason: AiError.AiErrorReason) =>
reason,
});
const emptyChunkIterator = (): AsyncIterable<LlmChunk> => ({
[Symbol.asyncIterator]: () => ({
next: () => Promise.resolve({ done: true, value: undefined }),
}),
});
describe("text completion common helpers", () => {
it("maps provider rate-limit status fields to tagged retry errors", () => {
expect(providerStatusError("test-provider", { status: 429 })).toMatchObject({
@ -61,19 +53,6 @@ describe("text completion common helpers", () => {
});
});
it("maps fallback generator throw failures into tagged provider errors", async () => {
const generator = toAsyncGenerator(
emptyChunkIterator(),
(error) => providerRuntimeError("test-provider", error),
);
await expect(generator.throw("provider failed")).rejects.toMatchObject({
_tag: "TextCompletionProviderError",
provider: "test-provider",
message: "provider failed",
});
});
it.effect(
"builds streaming chunks from async iterables with final token totals",
Effect.fnUntraced(function* () {
@ -117,107 +96,129 @@ describe("text completion common helpers", () => {
expect(textFromContent([{ text: 1 }])).toBe("");
});
it("adapts Effect LanguageModel generateText responses to LlmProvider results", async () => {
const provider = makeLanguageModelProvider({
provider: "FakeLanguageModel",
defaultModel: "fake-model",
defaultTemperature: 0.1,
runtime: languageModelRuntime,
makeLanguageModel: ({ model, temperature }) =>
LanguageModel.make({
generateText: () =>
Effect.succeed([
{ type: "text", text: `model=${model};temperature=${temperature}` },
finishPart(11, 7),
]),
streamText: () => Stream.empty,
}),
});
await expect(provider.generateContent("system", "prompt", "override-model", 0.4)).resolves.toEqual({
text: "model=override-model;temperature=0.4",
inToken: 11,
outToken: 7,
model: "override-model",
});
});
it("adapts Effect LanguageModel stream parts to TrustGraph chunks", async () => {
const provider = makeLanguageModelProvider({
provider: "FakeLanguageModel",
defaultModel: "fake-stream-model",
defaultTemperature: 0,
runtime: languageModelRuntime,
makeLanguageModel: () =>
LanguageModel.make({
generateText: () =>
Effect.succeed([
{ type: "text", text: "unused" },
finishPart(1, 1),
]),
streamText: () =>
Stream.fromArray([
Response.makePart("text-start", { id: "part-1" }),
{ type: "text-delta", id: "part-1", delta: "hel" },
{ type: "text-delta", id: "part-1", delta: "lo" },
finishPart(13, 8),
]),
}),
});
const chunks: Array<LlmChunk> = [];
for await (const chunk of provider.generateContentStream("system", "prompt")) {
chunks.push(chunk);
}
expect(chunks).toEqual([
{
text: "hel",
inToken: null,
outToken: null,
model: "fake-stream-model",
isFinal: false,
},
{
text: "lo",
inToken: null,
outToken: null,
model: "fake-stream-model",
isFinal: false,
},
{
text: "",
inToken: 13,
outToken: 8,
model: "fake-stream-model",
isFinal: true,
},
]);
});
it("maps Effect AI rate and quota failures to TrustGraph retry errors", async () => {
const reasons = [
new AiError.RateLimitError({}),
new AiError.QuotaExhaustedError({}),
];
for (const reason of reasons) {
it.effect(
"adapts Effect LanguageModel generateText responses to LlmProvider results",
Effect.fnUntraced(function* () {
const provider = makeLanguageModelProvider({
provider: "FakeLanguageModel",
defaultModel: "fake-model",
defaultTemperature: 0,
runtime: languageModelRuntime,
makeLanguageModel: () =>
defaultTemperature: 0.1,
context: languageModelContext,
makeLanguageModel: ({ model, temperature }) =>
LanguageModel.make({
generateText: () => Effect.fail(aiError(reason)),
streamText: () => Stream.fail(aiError(reason)),
generateText: () =>
Effect.succeed([
{ type: "text", text: `model=${model};temperature=${temperature}` },
finishPart(11, 7),
]),
streamText: () => Stream.empty,
}),
});
await expect(provider.generateContent("system", "prompt")).rejects.toMatchObject({
_tag: "TooManyRequestsError",
message: "Rate limit exceeded",
const result = yield* provider.generateContent("system", "prompt", "override-model", 0.4);
expect(result).toEqual({
text: "model=override-model;temperature=0.4",
inToken: 11,
outToken: 7,
model: "override-model",
});
}
});
}),
);
it.effect(
"adapts Effect LanguageModel stream parts to TrustGraph chunks",
Effect.fnUntraced(function* () {
const provider = makeLanguageModelProvider({
provider: "FakeLanguageModel",
defaultModel: "fake-stream-model",
defaultTemperature: 0,
context: languageModelContext,
makeLanguageModel: () =>
LanguageModel.make({
generateText: () =>
Effect.succeed([
{ type: "text", text: "unused" },
finishPart(1, 1),
]),
streamText: () =>
Stream.fromArray([
Response.makePart("text-start", { id: "part-1" }),
{ type: "text-delta", id: "part-1", delta: "hel" },
{ type: "text-delta", id: "part-1", delta: "lo" },
finishPart(13, 8),
]),
}),
});
const chunks = yield* Stream.runCollect(
provider.generateContentStream("system", "prompt"),
);
expect(Array.from(chunks)).toEqual([
{
text: "hel",
inToken: null,
outToken: null,
model: "fake-stream-model",
isFinal: false,
},
{
text: "lo",
inToken: null,
outToken: null,
model: "fake-stream-model",
isFinal: false,
},
{
text: "",
inToken: 13,
outToken: 8,
model: "fake-stream-model",
isFinal: true,
},
]);
}),
);
it.effect(
"maps Effect AI rate and quota failures to TrustGraph retry errors",
Effect.fnUntraced(function* () {
const reasons = [
new AiError.RateLimitError({}),
new AiError.QuotaExhaustedError({}),
];
for (const reason of reasons) {
const provider = makeLanguageModelProvider({
provider: "FakeLanguageModel",
defaultModel: "fake-model",
defaultTemperature: 0,
context: languageModelContext,
makeLanguageModel: () =>
LanguageModel.make({
generateText: () => Effect.fail(aiError(reason)),
streamText: () => Stream.fail(aiError(reason)),
}),
});
const generateError = yield* provider.generateContent("system", "prompt").pipe(
Effect.flip,
);
expect(generateError).toMatchObject({
_tag: "TooManyRequestsError",
message: "Rate limit exceeded",
});
const streamError = yield* Stream.runCollect(
provider.generateContentStream("system", "prompt"),
).pipe(
Effect.flip,
);
expect(streamError).toMatchObject({
_tag: "TooManyRequestsError",
message: "Rate limit exceeded",
});
}
}),
);
});

View file

@ -1 +1 @@
export { McpToolService, run, runMain } from "./service.js";
export { McpToolService, program, runMain } from "./service.js";

View file

@ -31,7 +31,7 @@ import {
type MessagingDeliveryError,
type Spec,
} from "@trustgraph/base";
import { Context, Effect, Layer, ManagedRuntime, Ref } from "effect";
import { Context, Effect, Layer, Ref } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
@ -342,9 +342,9 @@ export function makeMcpToolService(config: ProcessorConfig): McpToolService {
provide: (effect) => effect.pipe(Effect.provideService(McpToolRuntime, runtime)),
});
service.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(onMcpConfig(pushedConfig, version).pipe(
onMcpConfig(pushedConfig, version).pipe(
Effect.provideService(McpToolRuntime, runtime),
)),
),
);
return service;
}
@ -358,12 +358,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolR
layer: () => McpToolRuntimeLive,
});
const mcpToolRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return mcpToolRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -46,7 +46,7 @@ import {
type MessagingDeliveryError,
type Spec,
} from "@trustgraph/base";
import {Context, Effect, Layer, ManagedRuntime, Match, Ref} from "effect";
import {Context, Effect, Layer, Match, Ref} from "effect";
import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
@ -64,13 +64,6 @@ import type { AgentTool, ToolArg } from "./types.js";
const MAX_ITERATIONS = 10;
class AgentToolExecutionError extends S.TaggedErrorClass<AgentToolExecutionError>()(
"AgentToolExecutionError",
{
message: S.String,
},
) {}
const AgentResponseProducer = makeProducerSpec<AgentResponse>("agent-response");
const AgentLlmClient = makeRequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm",
@ -157,7 +150,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi
: "Query the knowledge graph for information about entities and their relationships.",
args: [{ name: "question", type: "string", description: "The question to ask" }],
config,
execute: () => Promise.resolve(""),
execute: () => Effect.succeed(""),
})
),
@ -170,7 +163,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config,
execute: () => Promise.resolve(""),
execute: () => Effect.succeed(""),
})
),
@ -187,7 +180,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi
{ name: "object", type: "string", description: "Object entity (optional)" },
],
config,
execute: () => Promise.resolve(""),
execute: () => Effect.succeed(""),
})
),
@ -203,7 +196,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi
description,
args,
config,
execute: () => Promise.resolve(""),
execute: () => Effect.succeed(""),
});
}),
@ -355,12 +348,9 @@ const executeTool = (
tool: AgentTool,
input: string,
): Effect.Effect<string> =>
Effect.tryPromise({
try: () => tool.execute(input),
catch: (cause) => AgentToolExecutionError.make({ message: errorMessage(cause) }),
}).pipe(
Effect.catch((error: AgentToolExecutionError) =>
Effect.succeed(`Error executing tool: ${error.message}`),
tool.execute(input).pipe(
Effect.catch((cause) =>
Effect.succeed(`Error executing tool: ${errorMessage(cause)}`),
),
);
@ -520,9 +510,9 @@ export function makeAgentService(config: ProcessorConfig): AgentService {
provide: (effect) => effect.pipe(Effect.provideService(AgentRuntime, runtime)),
});
service.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(onToolsConfig(pushedConfig, version).pipe(
onToolsConfig(pushedConfig, version).pipe(
Effect.provideService(AgentRuntime, runtime),
)),
),
);
Effect.runSync(Effect.log("[AgentService] Service initialized"));
return service;
@ -616,12 +606,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, AgentRun
layer: () => AgentRuntimeLive,
});
const agentRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return agentRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -24,7 +24,7 @@ import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import type { AgentTool, ToolArg } from "./types.js";
import { agentToolError, type AgentTool, type ToolArg } from "./types.js";
const decodeJsonUnknown = S.decodeUnknownOption(S.UnknownFromJsonString);
const decodeTerm = S.decodeUnknownOption(TermSchema);
@ -88,14 +88,16 @@ export function createKnowledgeQueryTool(
description: "The question to ask the knowledge graph",
},
],
execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
execute: Effect.fn("KnowledgeQuery.execute")(function* (input: string) {
const question = parseQuestion(input);
yield* Effect.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
const request: GraphRagRequest = {
query: question,
...(collection !== undefined ? { collection } : {}),
};
const res = yield* client.request(request);
const res = yield* client.request(request).pipe(
Effect.mapError((cause) => agentToolError("knowledge-query", cause)),
);
yield* Effect.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
const explainTriples = res.explain_triples;
@ -108,7 +110,7 @@ export function createKnowledgeQueryTool(
if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response;
})),
}),
};
}
@ -130,16 +132,18 @@ export function createDocumentQueryTool(
description: "The question to search documents for",
},
],
execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
execute: Effect.fn("DocumentQuery.execute")(function* (input: string) {
const question = parseQuestion(input);
const request: DocumentRagRequest = {
query: question,
...(collection !== undefined ? { collection } : {}),
};
const res = yield* client.request(request);
const res = yield* client.request(request).pipe(
Effect.mapError((cause) => agentToolError("document-query", cause)),
);
if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response;
})),
}),
};
}
@ -221,7 +225,7 @@ export function createTriplesQueryTool(
description: "The object entity to search for (optional)",
},
],
execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
execute: Effect.fn("TriplesQuery.execute")(function* (input: string) {
const { s, p, o, limit } = parseTriplesInput(input);
const request: TriplesQueryRequest = {
limit: limit ?? 20,
@ -230,7 +234,9 @@ export function createTriplesQueryTool(
...(o !== undefined ? { o } : {}),
...(collection !== undefined ? { collection } : {}),
};
const res = yield* client.request(request);
const res = yield* client.request(request).pipe(
Effect.mapError((cause) => agentToolError("triples-query", cause)),
);
if (res.error !== undefined) return `Error: ${res.error.message}`;
@ -243,7 +249,7 @@ export function createTriplesQueryTool(
`(${termToString(t.s)}) -[${termToString(t.p)}]-> (${termToString(t.o)})`,
);
return lines.join("\n");
})),
}),
};
}
@ -263,12 +269,14 @@ export function createMcpTool(
name: toolName,
description,
args,
execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
const res = yield* client.request({ name: toolName, parameters: input });
execute: Effect.fn("McpTool.execute")(function* (input: string) {
const res = yield* client.request({ name: toolName, parameters: input }).pipe(
Effect.mapError((cause) => agentToolError("mcp-tool", cause)),
);
if (res.error !== undefined) return `Error: ${res.error.message}`;
if (res.text !== undefined) return res.text;
if (res.object !== undefined) return res.object;
return "No content";
})),
}),
};
}

View file

@ -2,6 +2,24 @@
* Types for the ReAct agent service.
*/
import type { Effect } from "effect";
import * as S from "effect/Schema";
import { errorMessage } from "@trustgraph/base";
export class AgentToolError extends S.TaggedErrorClass<AgentToolError>()(
"AgentToolError",
{
message: S.String,
operation: S.String,
},
) {}
export const agentToolError = (operation: string, cause: unknown): AgentToolError =>
AgentToolError.make({
operation,
message: errorMessage(cause),
});
export interface ToolArg {
name: string;
type: string;
@ -12,7 +30,7 @@ export interface AgentTool {
name: string;
description: string;
args: ToolArg[];
execute: (input: string) => Promise<string>;
execute: (input: string) => Effect.Effect<string, AgentToolError>;
/** Full tool config from config-push (used by tool filtering). */
config?: Record<string, unknown>;
}
@ -30,6 +48,6 @@ export interface ParsedEvent {
content: string;
}
export type OnThought = (text: string, isFinal: boolean) => Promise<void>;
export type OnObservation = (text: string, isFinal: boolean) => Promise<void>;
export type OnAnswer = (text: string) => Promise<void>;
export type OnThought = (text: string, isFinal: boolean) => Effect.Effect<void, AgentToolError>;
export type OnObservation = (text: string, isFinal: boolean) => Effect.Effect<void, AgentToolError>;
export type OnAnswer = (text: string) => Effect.Effect<void, AgentToolError>;

View file

@ -26,14 +26,14 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import * as S from "effect/Schema";
import { recursiveSplit } from "./recursive-splitter.js";
const DEFAULT_CHUNK_SIZE = 2000;
const DEFAULT_CHUNK_OVERLAP = 100;
const ChunkSizeParameter = makeParameterSpec("chunk-size", S.Number);
const ChunkOverlapParameter = makeParameterSpec("chunk-overlap", S.Number);
const ChunkSizeParameter = makeParameterSpec("chunk-size", S.Finite);
const ChunkOverlapParameter = makeParameterSpec("chunk-overlap", S.Finite);
const ChunkOutputProducer = makeProducerSpec<Chunk>("chunk-output");
const ChunkTriplesProducer = makeProducerSpec<Triples>("chunk-triples");
@ -108,12 +108,6 @@ export const program = makeFlowProcessorProgram({
specs: () => makeChunkingSpecs(),
});
const chunkingRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return chunkingRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -5,7 +5,7 @@
*/
import {NodeRuntime} from "@effect/platform-node";
import {Duration, Effect, HashMap, Layer, ManagedRuntime, Match, Option, SynchronizedRef} from "effect";
import {Duration, Effect, HashMap, Match, Option, SynchronizedRef} from "effect";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import {
@ -16,6 +16,7 @@ import {
makeAsyncProcessor,
makeProcessorProgram,
optionalStringConfig,
processorLifecycleError,
topics,
type AsyncProcessorRuntime,
type BackendConsumer,
@ -26,7 +27,7 @@ import {
type Message,
type ProcessorConfig,
} from "@trustgraph/base";
import {readTextFile, writeTextFile} from "../runtime/effect-files.js";
import {readTextFileEffect, writeTextFileEffect} from "../runtime/effect-files.js";
export interface ConfigServiceConfig extends ProcessorConfig {
readonly persistPath?: string;
@ -38,7 +39,7 @@ interface ConfigPush {
}
const ConfigPushSchema = S.Struct({
version: S.Number,
version: S.Finite,
config: S.Record(S.String, S.Unknown),
});
@ -84,7 +85,7 @@ interface ConfigServiceState {
}
const PersistedConfigSchema = S.Struct({
version: S.optionalKey(S.Number),
version: S.optionalKey(S.Finite),
data: S.optionalKey(S.Record(S.String, S.Record(S.String, S.Unknown))),
workspaces: S.optionalKey(S.Record(S.String, S.Record(S.String, S.Record(S.String, S.Unknown)))),
});
@ -94,24 +95,17 @@ type PersistedConfig = typeof PersistedConfigSchema.Type;
export interface ConfigService extends AsyncProcessorRuntime<ConfigServiceError> {
readonly state: SynchronizedRef.SynchronizedRef<ConfigServiceState>;
readonly persistPath: string | null;
readonly handleMessage: (msg: Message<ConfigRequest>) => Promise<void>;
readonly handleMessageEffect: (msg: Message<ConfigRequest>) => Effect.Effect<void, ConfigServiceError>;
readonly handleOperation: (request: ConfigRequest) => Promise<ConfigResponse>;
readonly handleOperationEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, ConfigServiceError>;
readonly handleGet: (request: ConfigRequest) => ConfigResponse;
readonly handlePut: (request: ConfigRequest) => Promise<ConfigResponse>;
readonly handlePutEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, ConfigServiceError>;
readonly handleDelete: (request: ConfigRequest) => Promise<ConfigResponse>;
readonly handleDeleteEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, ConfigServiceError>;
readonly handleList: (request: ConfigRequest) => ConfigResponse;
readonly handleGetValues: (request: ConfigRequest) => ConfigResponse;
readonly handleGetValuesAllWorkspaces: (request: ConfigRequest) => ConfigResponse;
readonly handleConfigDump: (request: ConfigRequest) => ConfigResponse;
readonly pushConfig: () => Promise<void>;
readonly pushConfigEffect: Effect.Effect<void, ConfigServiceError>;
readonly persist: () => Promise<void>;
readonly persistEffect: Effect.Effect<void>;
readonly loadFromDisk: () => Promise<void>;
readonly loadFromDiskEffect: Effect.Effect<void>;
}
@ -325,10 +319,9 @@ const persistStateEffect = Effect.fn("ConfigService.persistState")(
Effect.mapError((cause) => configServiceError("persist-encode", cause)),
);
yield* Effect.tryPromise({
try: () => writeTextFile(persistPath, json),
catch: (cause) => configServiceError("persist-write", cause),
});
yield* writeTextFileEffect(persistPath, json).pipe(
Effect.mapError((cause) => configServiceError("persist-write", cause)),
);
},
(effect) =>
effect.pipe(
@ -344,24 +337,21 @@ const pushConfigWithStateEffect = Effect.fn("ConfigService.pushConfigWithState")
const pushProducer = state.pushProducer;
if (pushProducer === null) return;
yield* Effect.tryPromise({
try: () =>
pushProducer.send({
version: state.version,
config: configDumpForState(state),
}),
catch: (cause) => configServiceError("push-config", cause),
});
yield* pushProducer.send({
version: state.version,
config: configDumpForState(state),
}).pipe(
Effect.mapError((cause) => configServiceError("push-config", cause)),
);
yield* Effect.log(`[ConfigService] Pushed configuration version ${state.version}`);
});
const readPersistedConfigEffect = Effect.fn("ConfigService.readPersistedConfig")(
function* (persistPath: string) {
const raw = yield* Effect.tryPromise({
try: () => readTextFile(persistPath),
catch: (cause) => configServiceError("persist-read", cause),
});
const raw = yield* readTextFileEffect(persistPath).pipe(
Effect.mapError((cause) => configServiceError("persist-read", cause)),
);
return yield* S.decodeUnknownEffect(PersistedConfigJsonSchema)(raw).pipe(
Effect.mapError((cause) => configServiceError("persist-decode", cause)),
);
@ -644,24 +634,21 @@ const closeConfigResourcesEffect = Effect.fn("ConfigService.closeResources")(fun
const consumer = state.consumer;
if (consumer !== null) {
yield* Effect.tryPromise({
try: () => consumer.close(),
catch: (cause) => configServiceError("close-consumer", cause),
});
yield* consumer.close.pipe(
Effect.mapError((cause) => configServiceError("close-consumer", cause)),
);
}
const responseProducer = state.responseProducer;
if (responseProducer !== null) {
yield* Effect.tryPromise({
try: () => responseProducer.close(),
catch: (cause) => configServiceError("close-response-producer", cause),
});
yield* responseProducer.close.pipe(
Effect.mapError((cause) => configServiceError("close-response-producer", cause)),
);
}
const pushProducer = state.pushProducer;
if (pushProducer !== null) {
yield* Effect.tryPromise({
try: () => pushProducer.close(),
catch: (cause) => configServiceError("close-push-producer", cause),
});
yield* pushProducer.close.pipe(
Effect.mapError((cause) => configServiceError("close-push-producer", cause)),
);
}
yield* updateHandles(stateRef, {
@ -680,17 +667,15 @@ const consumeOnceEffect = Effect.fnUntraced(function* (
return yield* configServiceError("consume", "Config consumer not started");
}
const msg = yield* Effect.tryPromise({
try: () => consumer.receive(2000),
catch: (cause) => configServiceError("consume-receive", cause),
});
const msg = yield* consumer.receive(2000).pipe(
Effect.mapError((cause) => configServiceError("consume-receive", cause)),
);
if (msg === null) return;
yield* service.handleMessageEffect(msg);
yield* Effect.tryPromise({
try: () => consumer.acknowledge(msg),
catch: (cause) => configServiceError("consume-acknowledge", cause),
});
yield* consumer.acknowledge(msg).pipe(
Effect.mapError((cause) => configServiceError("consume-acknowledge", cause)),
);
});
const runConfigServiceEffect = Effect.fn("ConfigService.run")(function* (
@ -698,35 +683,29 @@ const runConfigServiceEffect = Effect.fn("ConfigService.run")(function* (
) {
yield* service.loadFromDiskEffect;
const responseProducer = yield* Effect.tryPromise({
try: () =>
service.pubsub.createProducer<ConfigResponse>({
topic: topics.configResponse,
schema: ConfigResponseSchema,
}),
catch: (cause) => configServiceError("response-producer", cause),
});
const responseProducer = yield* service.pubsub.createProducer<ConfigResponse>({
topic: topics.configResponse,
schema: ConfigResponseSchema,
}).pipe(
Effect.mapError((cause) => configServiceError("response-producer", cause)),
);
yield* updateHandles(service.state, {responseProducer});
const pushProducer = yield* Effect.tryPromise({
try: () =>
service.pubsub.createProducer<ConfigPush>({
topic: topics.configPush,
schema: ConfigPushSchema,
}),
catch: (cause) => configServiceError("push-producer", cause),
});
const pushProducer = yield* service.pubsub.createProducer<ConfigPush>({
topic: topics.configPush,
schema: ConfigPushSchema,
}).pipe(
Effect.mapError((cause) => configServiceError("push-producer", cause)),
);
yield* updateHandles(service.state, {pushProducer});
const consumer = yield* Effect.tryPromise({
try: () =>
service.pubsub.createConsumer<ConfigRequest>({
topic: topics.configRequest,
subscription: `${service.config.id}-config-request`,
schema: ConfigRequestSchema,
}),
catch: (cause) => configServiceError("consumer", cause),
});
const consumer = yield* service.pubsub.createConsumer<ConfigRequest>({
topic: topics.configRequest,
subscription: `${service.config.id}-config-request`,
schema: ConfigRequestSchema,
}).pipe(
Effect.mapError((cause) => configServiceError("consumer", cause)),
);
const state = yield* updateHandles(service.state, {consumer});
yield* pushConfigWithStateEffect(state);
@ -762,7 +741,6 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
const base = makeAsyncProcessor<ConfigServiceError>(config, {
runEffect: () => getService.pipe(Effect.flatMap(runConfigServiceEffect)),
});
const baseStop = base.stop;
const persistPath = config.persistPath ?? null;
const handleOperationEffect = Effect.fn("ConfigService.handleOperation")(function* (
@ -800,10 +778,9 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
if (responseProducer === null) {
return yield* configServiceError("respond", "Config response producer not started");
}
yield* Effect.tryPromise({
try: () => responseProducer.send(response, {id: requestId}),
catch: (cause) => configServiceError("respond", cause),
});
yield* responseProducer.send(response, {id: requestId}).pipe(
Effect.mapError((cause) => configServiceError("respond", cause)),
);
});
yield* handleOperationEffect(request).pipe(
@ -830,40 +807,42 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
yield* Effect.log(`[ConfigService] Loaded persisted config (version=${next.version}, workspaces=${HashMap.size(next.store)})`);
});
service = Object.assign(base, {
const serviceStopEffect = closeConfigResourcesEffect(state).pipe(
Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)),
Effect.flatMap(() => base.stop),
);
const serviceBase = Object.create(base, {
stop: {
value: serviceStopEffect,
writable: true,
enumerable: true,
configurable: true,
},
stopEffect: {
value: serviceStopEffect,
writable: true,
enumerable: true,
configurable: true,
},
});
service = Object.assign(serviceBase, {
state,
persistPath,
handleMessage: (msg: Message<ConfigRequest>) => Effect.runPromise(handleMessageEffect(msg)),
handleMessageEffect,
handleOperation: (request: ConfigRequest) => Effect.runPromise(handleOperationEffect(request)),
handleOperationEffect,
handleGet: (request: ConfigRequest) => handleGetWithState(stateSnapshot(state), request),
handlePut: (request: ConfigRequest) => Effect.runPromise(handlePutEffect(state, persistPath, request)),
handlePutEffect: (request: ConfigRequest) => handlePutEffect(state, persistPath, request),
handleDelete: (request: ConfigRequest) => Effect.runPromise(handleDeleteEffect(state, persistPath, request)),
handleDeleteEffect: (request: ConfigRequest) => handleDeleteEffect(state, persistPath, request),
handleList: (request: ConfigRequest) => handleListWithState(stateSnapshot(state), request),
handleGetValues: (request: ConfigRequest) => handleGetValuesWithState(stateSnapshot(state), request),
handleGetValuesAllWorkspaces: (request: ConfigRequest) => handleGetValuesAllWorkspacesWithState(stateSnapshot(state), request),
handleConfigDump: (request: ConfigRequest) => handleConfigDumpWithState(stateSnapshot(state), request),
pushConfig: () => Effect.runPromise(SynchronizedRef.get(state).pipe(Effect.flatMap(pushConfigWithStateEffect))),
pushConfigEffect: SynchronizedRef.get(state).pipe(Effect.flatMap(pushConfigWithStateEffect)),
persist: () => Effect.runPromise(SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current)))),
persistEffect: SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current))),
loadFromDisk: () => Effect.runPromise(loadFromDiskEffect()),
loadFromDiskEffect: loadFromDiskEffect(),
stop: () =>
Effect.runPromise(
closeConfigResourcesEffect(state).pipe(
Effect.flatMap(() =>
Effect.tryPromise({
try: () => baseStop(),
catch: (cause) => configServiceError("stop", cause),
})
),
),
),
});
}) as ConfigService;
return service;
}
@ -887,12 +866,6 @@ export const program = makeProcessorProgram({
make: (config) => makeConfigService(config),
});
const configServiceRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return configServiceRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -15,6 +15,7 @@ import {
makeAsyncProcessor,
makeProcessorProgram,
optionalStringConfig,
processorLifecycleError,
topics,
type AsyncProcessorRuntime,
type BackendConsumer,
@ -24,17 +25,18 @@ import {
type KnowledgeResponse,
type Message,
type ProcessorConfig,
type PubSubError,
} from "@trustgraph/base";
import {Duration, Effect, HashMap, Layer, ManagedRuntime, Match, SynchronizedRef} from "effect";
import {Duration, Effect, HashMap, Match, SynchronizedRef} from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import {ensureDirectory, joinPath, readTextFile, writeTextFile} from "../runtime/effect-files.js";
import {ensureDirectoryEffect, joinPath, readTextFileEffect, writeTextFileEffect} from "../runtime/effect-files.js";
export interface KnowledgeCoreServiceConfig extends ProcessorConfig {
readonly dataDir?: string;
}
const NumberArray = S.Array(S.Number).pipe(S.mutable);
const NumberArray = S.Array(S.Finite).pipe(S.mutable);
const NumberArrays = S.Array(NumberArray).pipe(S.mutable);
const GraphEmbeddingSchema = S.Struct({
@ -98,35 +100,20 @@ export interface KnowledgeCoreService extends AsyncProcessorRuntime<KnowledgeCor
readonly coreKey: (user: string, id: string) => string;
readonly graphEmbeddings: (request: KnowledgeRequest) => ReadonlyArray<GraphEmbedding>;
readonly documentEmbeddings: (request: KnowledgeRequest) => DocumentEmbeddingsCore | undefined;
readonly handleMessage: (msg: Message<KnowledgeRequest>) => Promise<void>;
readonly handleMessageEffect: (msg: Message<KnowledgeRequest>) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly handleOperation: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly handleOperationEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly listKgCores: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly listKgCoresEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly getKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly getKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly deleteKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly deleteKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly putKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly putKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly loadKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly loadKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly unloadKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly unloadKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly listDeCores: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly listDeCoresEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly getDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly getDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly deleteDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly deleteDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly putDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly putDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly loadDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>;
readonly loadDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>;
readonly persist: () => Promise<void>;
readonly persistEffect: Effect.Effect<void, never>;
readonly loadFromDisk: () => Promise<void>;
readonly loadFromDiskEffect: Effect.Effect<void, never>;
}
@ -204,20 +191,12 @@ const updateHandles = (
responseProducer: handles.responseProducer === undefined ? state.responseProducer : handles.responseProducer,
}));
const tryPromise = <A>(
operation: string,
evaluate: () => Promise<A>,
): Effect.Effect<A, KnowledgeCoreServiceError> =>
Effect.tryPromise({
try: evaluate,
catch: (cause) => knowledgeCoreServiceError(operation, cause),
});
const closeResource = (
resource: {readonly close: () => Promise<void>},
resource: {readonly close: Effect.Effect<void, PubSubError>},
operation: string,
): Effect.Effect<void> =>
tryPromise(operation, () => resource.close()).pipe(
resource.close.pipe(
Effect.mapError((cause) => knowledgeCoreServiceError(operation, cause)),
Effect.catch((error) =>
Effect.logError("[KnowledgeCoreService] Failed to close resource", {
error: error.message,
@ -237,12 +216,16 @@ const sendResponse = Effect.fnUntraced(function* (
return yield* knowledgeCoreServiceError(operation, "Knowledge response producer not started");
}
yield* tryPromise(operation, () => responseProducer.send(response, {id: requestId}));
yield* responseProducer.send(response, {id: requestId}).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError(operation, cause)),
);
});
const readPersistedKnowledgeEffect = Effect.fn("KnowledgeCoreService.readPersistedKnowledge")(
function* (persistPath: string) {
const raw = yield* tryPromise("load-read", () => readTextFile(persistPath));
const raw = yield* readTextFileEffect(persistPath).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("load-read", cause)),
);
const current = S.decodeUnknownOption(PersistedKnowledgeSnapshotJsonSchema)(raw);
if (O.isSome(current)) {
return {
@ -282,7 +265,9 @@ const persistStateEffect = Effect.fn("KnowledgeCoreService.persistState")(
const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(snapshot).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("persist-encode", cause)),
);
yield* tryPromise("persist-write", () => writeTextFile(persistPath, json));
yield* writeTextFileEffect(persistPath, json).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("persist-write", cause)),
);
},
(effect) =>
effect.pipe(
@ -317,12 +302,16 @@ const closeKnowledgeResourcesEffect = Effect.fn("KnowledgeCoreService.closeResou
const consumer = state.consumer;
if (consumer !== null) {
yield* tryPromise("close-consumer", () => consumer.close());
yield* consumer.close.pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("close-consumer", cause)),
);
}
const responseProducer = state.responseProducer;
if (responseProducer !== null) {
yield* tryPromise("close-response-producer", () => responseProducer.close());
yield* responseProducer.close.pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("close-response-producer", cause)),
);
}
yield* updateHandles(stateRef, {
@ -339,33 +328,39 @@ const consumeOnceEffect = Effect.fnUntraced(function* (
return yield* knowledgeCoreServiceError("consume", "Knowledge request consumer not started");
}
const msg = yield* tryPromise("consume-receive", () => consumer.receive(2000));
const msg = yield* consumer.receive(2000).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("consume-receive", cause)),
);
if (msg === null) return;
yield* service.handleMessageEffect(msg);
yield* tryPromise("consume-acknowledge", () => consumer.acknowledge(msg));
yield* consumer.acknowledge(msg).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("consume-acknowledge", cause)),
);
});
const runKnowledgeCoreServiceEffect = Effect.fn("KnowledgeCoreService.run")(function* (
service: KnowledgeCoreService,
) {
yield* tryPromise("ensure-directory", () => ensureDirectory(service.dataDir));
yield* ensureDirectoryEffect(service.dataDir).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("ensure-directory", cause)),
);
yield* service.loadFromDiskEffect;
const responseProducer = yield* tryPromise("response-producer", () =>
service.pubsub.createProducer<KnowledgeResponse>({
topic: topics.knowledgeResponse,
schema: KnowledgeResponseSchema,
}),
const responseProducer = yield* service.pubsub.createProducer<KnowledgeResponse>({
topic: topics.knowledgeResponse,
schema: KnowledgeResponseSchema,
}).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("response-producer", cause)),
);
yield* updateHandles(service.state, {responseProducer});
const consumer = yield* tryPromise("consumer", () =>
service.pubsub.createConsumer<KnowledgeRequest>({
topic: topics.knowledgeRequest,
subscription: `${service.config.id}-knowledge-request`,
schema: KnowledgeRequestSchema,
}),
const consumer = yield* service.pubsub.createConsumer<KnowledgeRequest>({
topic: topics.knowledgeRequest,
subscription: `${service.config.id}-knowledge-request`,
schema: KnowledgeRequestSchema,
}).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("consumer", cause)),
);
yield* updateHandles(service.state, {consumer});
@ -504,12 +499,11 @@ const loadKgCoreEffect = Effect.fn("loadKgCoreEffect")(function* (
if (core.triples.length > 0) {
yield* Effect.acquireUseRelease(
tryPromise("triples-producer", () =>
service.pubsub.createProducer<unknown>({topic: "tg.flow.triples"}),
service.pubsub.createProducer<unknown>({topic: "tg.flow.triples"}).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("triples-producer", cause)),
),
(producer) =>
tryPromise("send-triples", () =>
producer.send({
producer.send({
metadata: {
id: coreId,
root: coreId,
@ -517,8 +511,9 @@ const loadKgCoreEffect = Effect.fn("loadKgCoreEffect")(function* (
collection: request.collection ?? "default",
},
triples: core.triples,
}),
),
}).pipe(
Effect.mapError((cause) => knowledgeCoreServiceError("send-triples", cause)),
),
(producer) => closeResource(producer, "close-triples-producer"),
);
}
@ -637,7 +632,6 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
const base = makeAsyncProcessor<KnowledgeCoreServiceError>(config, {
runEffect: () => getService.pipe(Effect.flatMap(runKnowledgeCoreServiceEffect)),
});
const baseStop = base.stop;
const handleOperationEffect = Effect.fn("KnowledgeCoreService.handleOperation")(function* (
request: KnowledgeRequest,
@ -699,54 +693,50 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
yield* Effect.log(`[KnowledgeCoreService] Loaded persisted state (kg=${HashMap.size(next.kgCores)}, de=${HashMap.size(next.deCores)})`);
});
service = Object.assign(base, {
const serviceStopEffect = closeKnowledgeResourcesEffect(state).pipe(
Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)),
Effect.flatMap(() => base.stop),
);
const serviceBase = Object.create(base, {
stop: {
value: serviceStopEffect,
writable: true,
enumerable: true,
configurable: true,
},
stopEffect: {
value: serviceStopEffect,
writable: true,
enumerable: true,
configurable: true,
},
});
service = Object.assign(serviceBase, {
state,
dataDir,
persistPath,
coreKey,
graphEmbeddings: graphEmbeddingsFor,
documentEmbeddings: documentEmbeddingsFor,
handleMessage: (msg: Message<KnowledgeRequest>) => Effect.runPromise(handleMessageEffect(msg)),
handleMessageEffect,
handleOperation: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(handleOperationEffect(request, requestId)),
handleOperationEffect,
listKgCores: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(listKgCoresEffect(state, request, requestId)),
listKgCoresEffect: (request: KnowledgeRequest, requestId: string) => listKgCoresEffect(state, request, requestId),
getKgCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(getKgCoreEffect(state, request, requestId)),
getKgCoreEffect: (request: KnowledgeRequest, requestId: string) => getKgCoreEffect(state, request, requestId),
deleteKgCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(deleteKgCoreEffect(state, persistPath, request, requestId)),
deleteKgCoreEffect: (request: KnowledgeRequest, requestId: string) => deleteKgCoreEffect(state, persistPath, request, requestId),
putKgCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(putKgCoreEffect(state, persistPath, request, requestId)),
putKgCoreEffect: (request: KnowledgeRequest, requestId: string) => putKgCoreEffect(state, persistPath, request, requestId),
loadKgCore: (request: KnowledgeRequest, requestId: string) =>
Effect.runPromise(getService.pipe(Effect.flatMap((current) => loadKgCoreEffect(state, current, request, requestId)))),
loadKgCoreEffect: (request: KnowledgeRequest, requestId: string) =>
getService.pipe(Effect.flatMap((current) => loadKgCoreEffect(state, current, request, requestId))),
unloadKgCore: (_request: KnowledgeRequest, requestId: string) => Effect.runPromise(sendResponse(state, {}, requestId)),
unloadKgCoreEffect: (_request: KnowledgeRequest, requestId: string) => sendResponse(state, {}, requestId),
listDeCores: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(listDeCoresEffect(state, request, requestId)),
listDeCoresEffect: (request: KnowledgeRequest, requestId: string) => listDeCoresEffect(state, request, requestId),
getDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(getDeCoreEffect(state, request, requestId)),
getDeCoreEffect: (request: KnowledgeRequest, requestId: string) => getDeCoreEffect(state, request, requestId),
deleteDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(deleteDeCoreEffect(state, persistPath, request, requestId)),
deleteDeCoreEffect: (request: KnowledgeRequest, requestId: string) => deleteDeCoreEffect(state, persistPath, request, requestId),
putDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(putDeCoreEffect(state, persistPath, request, requestId)),
putDeCoreEffect: (request: KnowledgeRequest, requestId: string) => putDeCoreEffect(state, persistPath, request, requestId),
loadDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(loadDeCoreEffect(state, request, requestId)),
loadDeCoreEffect: (request: KnowledgeRequest, requestId: string) => loadDeCoreEffect(state, request, requestId),
persist: () => Effect.runPromise(SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current)))),
persistEffect: SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current))),
loadFromDisk: () => Effect.runPromise(loadFromDiskEffect()),
loadFromDiskEffect: loadFromDiskEffect(),
stop: () =>
Effect.runPromise(
closeKnowledgeResourcesEffect(state).pipe(
Effect.flatMap(() =>
tryPromise("base-stop", () => baseStop())
),
),
),
});
}) as KnowledgeCoreService;
return service;
}
@ -770,12 +760,6 @@ export const program = makeProcessorProgram({
make: (config) => makeKnowledgeCoreService(config),
});
const knowledgeCoreRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return knowledgeCoreRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -39,7 +39,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Clock, Effect, Layer, ManagedRuntime } from "effect";
import { Clock, Effect } from "effect";
import * as S from "effect/Schema";
export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()(
@ -48,7 +48,7 @@ export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()(
message: S.String,
operation: S.String,
documentId: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -257,12 +257,6 @@ export const program = makeFlowProcessorProgram({
specs: () => makePdfDecoderSpecs(),
});
const pdfDecoderRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return pdfDecoderRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -5,7 +5,7 @@
*/
import { NodeRuntime } from "@effect/platform-node";
import { Config, Effect, Layer, ManagedRuntime } from "effect";
import { Config, Effect, Layer } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import {
@ -25,7 +25,7 @@ export interface OllamaEmbeddingsConfig extends ProcessorConfig {
fetch?: typeof fetch;
}
const EmbeddingVector = S.Array(S.Number);
const EmbeddingVector = S.Array(S.Finite);
const OllamaEmbedResponse = S.Struct({
embeddings: S.Array(EmbeddingVector),
@ -71,9 +71,6 @@ const loadOllamaEmbeddingsConfig = Effect.fn("OllamaEmbeddings.loadConfig")(func
} satisfies ResolvedOllamaEmbeddingsConfig;
});
const responseJson = (response: Response): Promise<unknown> =>
response.json();
const makeOllamaEmbeddingsFromConfig = ({
defaultModel,
ollamaHost,
@ -116,7 +113,7 @@ const makeOllamaEmbeddingsFromConfig = ({
}
const data = yield* Effect.tryPromise({
try: () => responseJson(response),
try: () => response.json(),
catch: (error) => ollamaEmbeddingsError("ollama.response-json", error),
});
const decoded = yield* S.decodeUnknownEffect(OllamaEmbedResponse)(data).pipe(
@ -166,12 +163,6 @@ export const program = makeFlowProcessorProgram<OllamaEmbeddingsConfig, Embeddin
layer: (config) => OllamaEmbeddingsLive(config),
});
const ollamaEmbeddingsRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return ollamaEmbeddingsRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -35,7 +35,7 @@ import {
type Spec,
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
@ -392,12 +392,6 @@ export const program = makeFlowProcessorProgram({
specs: () => makeKnowledgeExtractSpecs(),
});
const knowledgeExtractRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return knowledgeExtractRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -30,23 +30,45 @@ import {
type FlowRequest,
type FlowResponse,
errorMessage,
processorLifecycleError,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type { Message } from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { Duration, Effect, HashMap, Layer, ManagedRuntime, Match, Option, SynchronizedRef } from "effect";
import { Duration, Effect, HashMap, Match, Option, SynchronizedRef } from "effect";
import * as S from "effect/Schema";
// ---------- Internal state types ----------
interface FlowInstance {
id: string;
blueprintName: string;
description: string;
parameters: Record<string, unknown>;
status: "running" | "stopped";
class FlowInstanceRunning extends S.Class<FlowInstanceRunning>("FlowInstanceRunning")({
id: S.String,
blueprintName: S.String,
description: S.optionalKey(S.String),
parameters: S.Record(S.String, S.Unknown),
status: S.tag("running")
}) {}
class FlowInstanceStopped extends S.Class<FlowInstanceStopped>("FlowInstanceStopped")({
id: S.String,
blueprintName: S.String,
description: S.optionalKey(S.String),
parameters: S.Record(S.String, S.Unknown),
status: S.tag("stopped")
}) {
}
export const FlowInstance = S.Union(
[
FlowInstanceRunning,
FlowInstanceStopped
]
).pipe(
S.toTaggedUnion("status")
);
export type FlowInstance = typeof FlowInstance.Type;
interface Blueprint {
description: string;
topics: Record<string, string>;
@ -175,35 +197,21 @@ interface FlowManagerServiceState {
export interface FlowManagerService extends AsyncProcessorRuntime<FlowManagerError> {
readonly state: SynchronizedRef.SynchronizedRef<FlowManagerServiceState>;
readonly handleMessage: (msg: Message<FlowRequest>) => Promise<void>;
readonly handleMessageEffect: (msg: Message<FlowRequest>) => Effect.Effect<void, FlowManagerError>;
readonly configRequest: (request: ConfigRequest) => Promise<ConfigResponse>;
readonly configRequestEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, FlowManagerError>;
readonly ensureDefaultBlueprint: () => Promise<void>;
readonly ensureDefaultBlueprintEffect: Effect.Effect<void, FlowManagerError>;
readonly refreshBlueprintsFromConfig: () => Promise<void>;
readonly refreshBlueprintsFromConfigEffect: Effect.Effect<void, FlowManagerError>;
readonly refreshFlowsFromConfig: () => Promise<void>;
readonly refreshFlowsFromConfigEffect: Effect.Effect<void, FlowManagerError>;
readonly handleOperation: (request: FlowRequest) => Promise<FlowResponse>;
readonly handleOperationEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly handleListBlueprints: () => FlowResponse;
readonly handleGetBlueprint: (request: FlowRequest) => Promise<FlowResponse>;
readonly handleGetBlueprintEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly handlePutBlueprint: (request: FlowRequest) => Promise<FlowResponse>;
readonly handlePutBlueprintEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly handleDeleteBlueprint: (request: FlowRequest) => Promise<FlowResponse>;
readonly handleDeleteBlueprintEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly handleListFlows: () => FlowResponse;
readonly handleGetFlow: (request: FlowRequest) => Promise<FlowResponse>;
readonly handleGetFlowEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly handleStartFlow: (request: FlowRequest) => Promise<FlowResponse>;
readonly handleStartFlowEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly handleStopFlow: (request: FlowRequest) => Promise<FlowResponse>;
readonly handleStopFlowEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>;
readonly pushFlowsConfig: () => Promise<void>;
readonly pushFlowsConfigEffect: Effect.Effect<void>;
readonly deleteFlowConfig: (id: string) => Promise<void>;
readonly deleteFlowConfigEffect: (id: string) => Effect.Effect<void, FlowManagerError>;
}
@ -259,13 +267,13 @@ function blueprintFromConfig(value: unknown): Blueprint | undefined {
function flowFromConfig(id: string, value: unknown): FlowInstance | undefined {
const parsed = parseConfigRecord(value);
if (parsed === undefined) return undefined;
return {
return FlowInstanceRunning.make({
id,
blueprintName: optionalString(parsed["blueprint-name"]) ?? optionalString(parsed.blueprintName) ?? "default",
description: optionalString(parsed.description) ?? "",
parameters: isRecord(parsed.parameters) ? parsed.parameters : {},
status: "running",
};
});
}
const updateHandles = (
@ -291,10 +299,9 @@ const configRequestEffect = Effect.fn("FlowManager.configRequest")(function* (
if (configClient === null) {
return yield* flowManagerError("config-request", "Config client not started");
}
return yield* Effect.tryPromise({
try: () => configClient.request(request),
catch: (cause) => flowManagerError("config-request", cause),
});
return yield* configClient.request(request).pipe(
Effect.mapError((cause) => flowManagerError("config-request", cause)),
);
});
const ensureDefaultBlueprintEffect = Effect.fn("FlowManager.ensureDefaultBlueprint")(function* (
@ -571,24 +578,20 @@ const pushFlowsConfigEffect = Effect.fn("FlowManager.pushFlowsConfig")(
}
}
yield* Effect.tryPromise({
try: () =>
configClient.request({
operation: "put",
keys: ["flows"],
values: flowsConfig,
}),
catch: (cause) => flowManagerError("put-flows-config", cause),
});
yield* Effect.tryPromise({
try: () =>
configClient.request({
operation: "put",
keys: ["flow"],
values: flowRecords,
}),
catch: (cause) => flowManagerError("put-flow-records", cause),
});
yield* configClient.request({
operation: "put",
keys: ["flows"],
values: flowsConfig,
}).pipe(
Effect.mapError((cause) => flowManagerError("put-flows-config", cause)),
);
yield* configClient.request({
operation: "put",
keys: ["flow"],
values: flowRecords,
}).pipe(
Effect.mapError((cause) => flowManagerError("put-flow-records", cause)),
);
yield* Effect.log(`[FlowManager] Pushed flows config (${HashMap.size(state.flows)} active flows)`);
},
(effect) =>
@ -605,22 +608,18 @@ const deleteFlowConfigEffect = Effect.fn("FlowManager.deleteFlowConfig")(functio
) {
const configClient = (yield* SynchronizedRef.get(stateRef)).configClient;
if (configClient === null) return;
yield* Effect.tryPromise({
try: () =>
configClient.request({
operation: "delete",
keys: ["flows", id],
}),
catch: (cause) => flowManagerError("delete-flows-config", cause),
});
yield* Effect.tryPromise({
try: () =>
configClient.request({
operation: "delete",
keys: ["flow", id],
}),
catch: (cause) => flowManagerError("delete-flow-record", cause),
});
yield* configClient.request({
operation: "delete",
keys: ["flows", id],
}).pipe(
Effect.mapError((cause) => flowManagerError("delete-flows-config", cause)),
);
yield* configClient.request({
operation: "delete",
keys: ["flow", id],
}).pipe(
Effect.mapError((cause) => flowManagerError("delete-flow-record", cause)),
);
});
const closeFlowManagerResourcesEffect = Effect.fn("FlowManager.closeResources")(function* (
@ -630,24 +629,19 @@ const closeFlowManagerResourcesEffect = Effect.fn("FlowManager.closeResources")(
const consumer = state.consumer;
if (consumer !== null) {
yield* Effect.tryPromise({
try: () => consumer.close(),
catch: (cause) => flowManagerError("consumer-close", cause),
});
yield* consumer.close.pipe(
Effect.mapError((cause) => flowManagerError("consumer-close", cause)),
);
}
const responseProducer = state.responseProducer;
if (responseProducer !== null) {
yield* Effect.tryPromise({
try: () => responseProducer.close(),
catch: (cause) => flowManagerError("response-producer-close", cause),
});
yield* responseProducer.close.pipe(
Effect.mapError((cause) => flowManagerError("response-producer-close", cause)),
);
}
const configClient = state.configClient;
if (configClient !== null) {
yield* Effect.tryPromise({
try: () => configClient.stop(),
catch: (cause) => flowManagerError("config-client-stop", cause),
});
yield* configClient.stop;
}
yield* updateHandles(stateRef, {
@ -665,17 +659,15 @@ const consumeOnceEffect = Effect.fnUntraced(function* (
return yield* flowManagerError("consume", "Flow request consumer not started");
}
const msg = yield* Effect.tryPromise({
try: () => consumer.receive(2000),
catch: (cause) => flowManagerError("consume-receive", cause),
});
const msg = yield* consumer.receive(2000).pipe(
Effect.mapError((cause) => flowManagerError("consume-receive", cause)),
);
if (msg === null) return;
yield* service.handleMessageEffect(msg);
yield* Effect.tryPromise({
try: () => consumer.acknowledge(msg),
catch: (cause) => flowManagerError("consume-acknowledge", cause),
});
yield* consumer.acknowledge(msg).pipe(
Effect.mapError((cause) => flowManagerError("consume-acknowledge", cause)),
);
});
const runFlowManagerServiceEffect = Effect.fn("FlowManager.runService")(function* (
@ -688,32 +680,27 @@ const runFlowManagerServiceEffect = Effect.fn("FlowManager.runService")(function
subscription: `${service.config.id}-config-client`,
});
yield* updateHandles(service.state, { configClient });
yield* Effect.tryPromise({
try: () => configClient.start(),
catch: (cause) => flowManagerError("config-client-start", cause),
});
yield* configClient.start.pipe(
Effect.mapError((cause) => flowManagerError("config-client-start", cause)),
);
yield* ensureDefaultBlueprintEffect(service.state);
yield* refreshBlueprintsFromConfigEffect(service.state);
const responseProducer = yield* Effect.tryPromise({
try: () =>
service.pubsub.createProducer<FlowResponse>({
topic: topics.flowResponse,
schema: FlowResponseSchema,
}),
catch: (cause) => flowManagerError("response-producer", cause),
});
const responseProducer = yield* service.pubsub.createProducer<FlowResponse>({
topic: topics.flowResponse,
schema: FlowResponseSchema,
}).pipe(
Effect.mapError((cause) => flowManagerError("response-producer", cause)),
);
yield* updateHandles(service.state, { responseProducer });
const consumer = yield* Effect.tryPromise({
try: () =>
service.pubsub.createConsumer<FlowRequest>({
topic: topics.flowRequest,
subscription: `${service.config.id}-flow-request`,
schema: FlowRequestSchema,
}),
catch: (cause) => flowManagerError("consumer", cause),
});
const consumer = yield* service.pubsub.createConsumer<FlowRequest>({
topic: topics.flowRequest,
subscription: `${service.config.id}-flow-request`,
schema: FlowRequestSchema,
}).pipe(
Effect.mapError((cause) => flowManagerError("consumer", cause)),
);
yield* updateHandles(service.state, { consumer });
yield* Effect.log(`[FlowManager] Listening on ${topics.flowRequest}`);
@ -748,7 +735,6 @@ export function makeFlowManagerService(config: ProcessorConfig): FlowManagerServ
const base = makeAsyncProcessor<FlowManagerError>(config, {
runEffect: () => getService.pipe(Effect.flatMap(runFlowManagerServiceEffect)),
});
const baseStop = base.stop;
const handleOperationEffect = Effect.fn("FlowManager.handleOperation")(function* (request: FlowRequest) {
const op = optionalString(request.operation);
@ -784,10 +770,9 @@ export function makeFlowManagerService(config: ProcessorConfig): FlowManagerServ
if (responseProducer === null) {
return yield* flowManagerError("respond", "Flow response producer not started");
}
yield* Effect.tryPromise({
try: () => responseProducer.send(response, { id: requestId }),
catch: (cause) => flowManagerError("respond", cause),
});
yield* responseProducer.send(response, { id: requestId }).pipe(
Effect.mapError((cause) => flowManagerError("respond", cause)),
);
});
yield* handleOperationEffect(request).pipe(
@ -800,50 +785,45 @@ export function makeFlowManagerService(config: ProcessorConfig): FlowManagerServ
);
});
const flowManagerService: FlowManagerService = Object.assign(base, {
const serviceStopEffect = closeFlowManagerResourcesEffect(state).pipe(
Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)),
Effect.flatMap(() => base.stop),
);
const serviceBase = Object.create(base, {
stop: {
value: serviceStopEffect,
writable: true,
enumerable: true,
configurable: true,
},
stopEffect: {
value: serviceStopEffect,
writable: true,
enumerable: true,
configurable: true,
},
});
const flowManagerService = Object.assign(serviceBase, {
state,
handleMessage: (msg: Message<FlowRequest>) => Effect.runPromise(handleMessageEffect(msg)),
handleMessageEffect,
configRequest: (request: ConfigRequest) => Effect.runPromise(configRequestEffect(state, request)),
configRequestEffect: (request: ConfigRequest) => configRequestEffect(state, request),
ensureDefaultBlueprint: () => Effect.runPromise(ensureDefaultBlueprintEffect(state)),
ensureDefaultBlueprintEffect: ensureDefaultBlueprintEffect(state),
refreshBlueprintsFromConfig: () => Effect.runPromise(refreshBlueprintsFromConfigEffect(state)),
refreshBlueprintsFromConfigEffect: refreshBlueprintsFromConfigEffect(state),
refreshFlowsFromConfig: () => Effect.runPromise(refreshFlowsFromConfigEffect(state)),
refreshFlowsFromConfigEffect: refreshFlowsFromConfigEffect(state),
handleOperation: (request: FlowRequest) => Effect.runPromise(handleOperationEffect(request)),
handleOperationEffect,
handleListBlueprints: () => handleListBlueprintsWithState(state.pipe(stateSnapshot)),
handleGetBlueprint: (request: FlowRequest) => Effect.runPromise(handleGetBlueprintEffect(state, request)),
handleGetBlueprintEffect: (request: FlowRequest) => handleGetBlueprintEffect(state, request),
handlePutBlueprint: (request: FlowRequest) => Effect.runPromise(handlePutBlueprintEffect(state, request)),
handlePutBlueprintEffect: (request: FlowRequest) => handlePutBlueprintEffect(state, request),
handleDeleteBlueprint: (request: FlowRequest) => Effect.runPromise(handleDeleteBlueprintEffect(state, request)),
handleDeleteBlueprintEffect: (request: FlowRequest) => handleDeleteBlueprintEffect(state, request),
handleListFlows: () => handleListFlowsWithState(state.pipe(stateSnapshot)),
handleGetFlow: (request: FlowRequest) => Effect.runPromise(handleGetFlowEffect(state, request)),
handleGetFlowEffect: (request: FlowRequest) => handleGetFlowEffect(state, request),
handleStartFlow: (request: FlowRequest) => Effect.runPromise(handleStartFlowEffect(state, request)),
handleStartFlowEffect: (request: FlowRequest) => handleStartFlowEffect(state, request),
handleStopFlow: (request: FlowRequest) => Effect.runPromise(handleStopFlowEffect(state, request)),
handleStopFlowEffect: (request: FlowRequest) => handleStopFlowEffect(state, request),
pushFlowsConfig: () => Effect.runPromise(pushFlowsConfigEffect(state)),
pushFlowsConfigEffect: pushFlowsConfigEffect(state),
deleteFlowConfig: (id: string) => Effect.runPromise(deleteFlowConfigEffect(state, id)),
deleteFlowConfigEffect: (id: string) => deleteFlowConfigEffect(state, id),
stop: () =>
Effect.runPromise(
closeFlowManagerResourcesEffect(state).pipe(
Effect.flatMap(() =>
Effect.tryPromise({
try: () => baseStop(),
catch: (cause) => flowManagerError("base-stop", cause),
})
),
),
),
});
}) as FlowManagerService;
service = flowManagerService;
return flowManagerService;
@ -856,12 +836,6 @@ export const program = makeProcessorProgram({
make: (config) => makeFlowManagerService(config),
});
const flowManagerRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return flowManagerRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -31,7 +31,6 @@ import {
type DispatchSerializationError,
} from "./serialize.js";
export type Responder = (response: unknown, complete: boolean) => Promise<void>;
export type EffectResponder<E = never, R = never> = (
response: unknown,
complete: boolean,
@ -106,18 +105,13 @@ function topicName(name: string): string {
// ---------- Manager ----------
export interface DispatcherManager {
readonly start: () => Promise<void>;
readonly stop: () => Promise<void>;
readonly start: Effect.Effect<void, MessagingLifecycleError>;
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
readonly dispatchGlobalService: (
kind: string,
request: Record<string, unknown>,
) => Promise<unknown>;
readonly dispatchGlobalServiceStreaming: (
kind: string,
request: Record<string, unknown>,
responder: Responder,
) => Promise<void>;
readonly dispatchGlobalServiceStreamingEffect: <E = never, R = never>(
) => Effect.Effect<unknown, DispatcherStreamError>;
readonly dispatchGlobalServiceStreaming: <E = never, R = never>(
kind: string,
request: Record<string, unknown>,
responder: EffectResponder<E, R>,
@ -126,14 +120,8 @@ export interface DispatcherManager {
flow: string,
kind: string,
request: Record<string, unknown>,
) => Promise<unknown>;
readonly dispatchFlowServiceStreaming: (
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
) => Promise<void>;
readonly dispatchFlowServiceStreamingEffect: <E = never, R = never>(
) => Effect.Effect<unknown, DispatcherStreamError>;
readonly dispatchFlowServiceStreaming: <E = never, R = never>(
flow: string,
kind: string,
request: Record<string, unknown>,
@ -143,7 +131,7 @@ export interface DispatcherManager {
topic: string,
message: unknown,
id?: string,
) => Promise<void>;
) => Effect.Effect<void, MessagingDeliveryError>;
}
export const dispatcherManagerFlowServiceNames = (): readonly string[] => [
@ -214,8 +202,6 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
runtime = nextRuntime;
});
const start = (): Promise<void> => Effect.runPromise(startEffect());
const stopEffect = Effect.fn("DispatcherManager.stop")(function* () {
const current = runtime;
runtime = null;
@ -225,15 +211,12 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
}
if (ownsPubSub) {
yield* Effect.tryPromise({
try: () => pubsub.close(),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause),
});
yield* pubsub.close.pipe(
Effect.mapError((cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause)),
);
}
});
const stop = (): Promise<void> => Effect.runPromise(stopEffect());
// ---------- Internal helpers ----------
const ensureRuntimeEffect = Effect.fn("DispatcherManager.ensureRuntime")(function* () {
@ -303,13 +286,7 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
// ---------- Global service dispatch ----------
const dispatchGlobalService = (
kind: string,
request: Record<string, unknown>,
): Promise<unknown> =>
Effect.runPromise(dispatchGlobalServiceEffect(kind, request));
const dispatchGlobalServiceEffect = Effect.fn("DispatcherManager.dispatchGlobalService")(function* (
const dispatchGlobalService = Effect.fn("DispatcherManager.dispatchGlobalService")(function* (
kind: string,
request: Record<string, unknown>,
) {
@ -321,7 +298,7 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
return yield* translateResponseEffect(kind, response);
});
const dispatchGlobalServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* <
const dispatchGlobalServiceStreaming = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* <
E,
R,
>(
@ -342,34 +319,9 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
});
});
const dispatchGlobalServiceStreaming = (
kind: string,
request: Record<string, unknown>,
responder: Responder,
): Promise<void> =>
Effect.runPromise(
dispatchGlobalServiceStreamingEffect(kind, request, (response, complete) =>
Effect.tryPromise({
try: () => responder(response, complete),
catch: (error) => messagingDeliveryError(
resolveGlobalTopics(kind).responseTopic,
"stream-responder",
error,
),
})
),
);
// ---------- Flow-scoped service dispatch ----------
const dispatchFlowService = (
flow: string,
kind: string,
request: Record<string, unknown>,
): Promise<unknown> =>
Effect.runPromise(dispatchFlowServiceEffect(flow, kind, request));
const dispatchFlowServiceEffect = Effect.fn("DispatcherManager.dispatchFlowService")(function* (
const dispatchFlowService = Effect.fn("DispatcherManager.dispatchFlowService")(function* (
flow: string,
kind: string,
request: Record<string, unknown>,
@ -386,7 +338,7 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
return yield* translateResponseEffect(kind, response);
});
const dispatchFlowServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* <
const dispatchFlowServiceStreaming = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* <
E,
R,
>(
@ -412,65 +364,40 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
});
});
const dispatchFlowServiceStreaming = (
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
): Promise<void> =>
Effect.runPromise(
dispatchFlowServiceStreamingEffect(flow, kind, request, (response, complete) =>
Effect.tryPromise({
try: () => responder(response, complete),
catch: (error) => messagingDeliveryError(
resolveFlowTopics(kind).responseTopic,
"stream-responder",
error,
),
})
),
);
// ---------- Fire-and-forget publish ----------
/**
* Publish a single message to an arbitrary topic (no request/response).
* Used for injecting documents into the processing pipeline.
*/
const publishToTopic = (topic: string, message: unknown, id?: string): Promise<void> =>
Effect.runPromise(
Effect.acquireUseRelease(
Effect.tryPromise({
try: () => pubsub.createProducer<unknown>({ topic }),
catch: (cause) => messagingDeliveryError(topic, "create-producer", cause),
}),
(producer) =>
Effect.gen(function* () {
const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`;
yield* Effect.tryPromise({
try: () => producer.send(message, { id: messageId }),
catch: (cause) => messagingDeliveryError(topic, "send", cause),
});
}),
(producer) => Effect.tryPromise({
try: () => producer.close(),
catch: (cause) => messagingDeliveryError(topic, "close-producer", cause),
}),
const publishToTopic = (topic: string, message: unknown, id?: string) =>
Effect.acquireUseRelease(
pubsub.createProducer<unknown>({ topic }).pipe(
Effect.mapError((cause) => messagingDeliveryError(topic, "create-producer", cause)),
),
(producer) =>
Effect.gen(function* () {
const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`;
yield* producer.send(message, { id: messageId }).pipe(
Effect.mapError((cause) => messagingDeliveryError(topic, "send", cause)),
);
}),
(producer) =>
producer.close.pipe(
Effect.mapError((cause) => messagingDeliveryError(topic, "close-producer", cause)),
),
);
return {
start,
stop,
start: startEffect(),
stop: stopEffect(),
dispatchGlobalService,
dispatchGlobalServiceStreaming,
dispatchGlobalServiceStreamingEffect,
dispatchFlowService,
dispatchFlowServiceStreaming,
dispatchFlowServiceStreamingEffect,
publishToTopic,
};
}

View file

@ -1,4 +1,4 @@
export { createGateway, run, type GatewayConfig } from "./server.js";
export { createGateway, program, runMain, type GatewayConfig } from "./server.js";
export {
dispatcherManagerFlowServiceNames,
dispatcherManagerGlobalServiceNames,

View file

@ -40,10 +40,9 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
TrustGraphRpcs.toLayer(Effect.succeed(
TrustGraphRpcs.of({
Dispatch: (payload) =>
Effect.tryPromise({
try: () => dispatchOne(dispatcher, payload),
catch: (cause) => DispatchError.make({ message: errorMessage(cause) }),
}),
dispatchOne(dispatcher, payload).pipe(
Effect.mapError((cause) => DispatchError.make({ message: errorMessage(cause) })),
),
DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) {
const queue = yield* Queue.bounded<DispatchStreamChunk, DispatchError | Cause.Done>(16);
yield* Effect.addFinalizer(() => Queue.shutdown(queue));
@ -64,7 +63,7 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
function dispatchOne(
dispatcher: DispatcherManager,
payload: DispatchPayload,
): Promise<unknown> {
): Effect.Effect<unknown, DispatcherStreamError> {
if (payload.scope === "flow") {
return dispatcher.dispatchFlowService(
payload.flow ?? "default",
@ -81,7 +80,7 @@ function dispatchStreamEffect(
responder: (response: unknown, complete: boolean) => Effect.Effect<void>,
): Effect.Effect<void, DispatcherStreamError> {
if (payload.scope === "flow") {
return dispatcher.dispatchFlowServiceStreamingEffect(
return dispatcher.dispatchFlowServiceStreaming(
payload.flow ?? "default",
payload.service,
payload.request,
@ -89,7 +88,7 @@ function dispatchStreamEffect(
);
}
return dispatcher.dispatchGlobalServiceStreamingEffect(
return dispatcher.dispatchGlobalServiceStreaming(
payload.service,
payload.request,
responder,

View file

@ -1,19 +1,16 @@
/** @effect-diagnostics nodeBuiltinImport:skip-file effectFnOpportunity:skip-file catchToOrElseSucceed:skip-file */
/**
* API Gateway HTTP + WebSocket server.
*
* Replaces the Python aiohttp gateway with Fastify.
* Uses Effect RPC over WebSocket for streaming client requests.
* API Gateway -- Effect HTTP + RPC server.
*
* Python reference: trustgraph-flow/trustgraph/gateway/service.py
*/
import Fastify, { type FastifyReply } from "fastify";
import websocketPlugin from "@fastify/websocket";
import { NodeRuntime } from "@effect/platform-node";
import { Cause, Clock, Config, Effect, Exit, Layer, ManagedRuntime, Random, Scope } from "effect";
import { createServer } from "node:http";
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Clock, Config, Effect, Exit, Layer, Random, Scope } from "effect";
import * as O from "effect/Option";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import * as EffectSocket from "effect/unstable/socket/Socket";
import {
formatPrometheusMetrics,
messagingLifecycleError,
@ -22,8 +19,8 @@ import {
toTgError,
type PubSubBackend,
} from "@trustgraph/base";
import { makeDispatcherManager } from "./dispatch/manager.js";
import { makeGatewayRpcServer } from "./rpc-server.js";
import { makeDispatcherManager, type DispatcherManager } from "./dispatch/manager.js";
import { makeGatewayRpcServer, type GatewayRpcServer } from "./rpc-server.js";
export interface GatewayConfig {
port: number;
@ -33,231 +30,253 @@ export interface GatewayConfig {
pubsub?: PubSubBackend;
}
export function createGateway(config: GatewayConfig) {
const app = Fastify({ logger: true });
const dispatcher = makeDispatcherManager(config);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const sendDispatchResult = (reply: FastifyReply, result: unknown): unknown => {
const err = (result as Record<string, unknown>)?.error as { type?: string; message?: string } | undefined;
if (err !== undefined) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
return result;
};
const json = (body: unknown, status = 200) =>
HttpServerResponse.jsonUnsafe(body, { status });
const sendDispatchError = (reply: FastifyReply, error: unknown): unknown =>
reply.code(500).send({ error: toTgError(error) });
const badRequest = (message: string) =>
json({ error: { type: "bad-request", message } }, 400);
return Effect.runPromise(
const dispatchError = (error: unknown) =>
json({ error: toTgError(error) }, 500);
const dispatchResult = (result: unknown) => {
const err = isRecord(result) && isRecord(result.error)
? result.error as { readonly type?: string; readonly message?: string }
: undefined;
if (err !== undefined) {
return json(result, err.type === "not-found" ? 404 : 400);
}
return json(result);
};
const readJsonRecord = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const body = yield* request.json;
return isRecord(body) ? body : {};
});
const bearerAuthResponse = (config: GatewayConfig) =>
Effect.gen(function* () {
if (config.secret === undefined || config.secret.length === 0) return null;
const request = yield* HttpServerRequest.HttpServerRequest;
const auth = request.headers.authorization;
return auth === `Bearer ${config.secret}`
? null
: json({ error: "Unauthorized" }, 401);
});
type RouteRequirements =
| HttpServerRequest.HttpServerRequest
| HttpRouter.RouteContext;
const withBearerAuth = (
config: GatewayConfig,
handler: Effect.Effect<HttpServerResponse.HttpServerResponse, never, RouteRequirements>,
) =>
Effect.gen(function* () {
const denied = yield* bearerAuthResponse(config);
if (denied !== null) return denied;
return yield* handler;
});
const withDispatchError = <A, E>(
effect: Effect.Effect<A, E>,
operation: string,
): Effect.Effect<HttpServerResponse.HttpServerResponse> =>
effect.pipe(
Effect.mapError((cause) => messagingLifecycleError("gateway", operation, cause)),
Effect.map(dispatchResult),
Effect.catch((error) => Effect.succeed(dispatchError(error))),
);
const workbenchDispatch = (
config: GatewayConfig,
dispatcher: DispatcherManager,
) =>
withBearerAuth(
config,
Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => app.register(websocketPlugin),
catch: (cause) => messagingLifecycleError("gateway", "register-websocket", cause),
});
const body = yield* readJsonRecord.pipe(
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
);
const service = typeof body.service === "string" ? body.service : undefined;
const payload = isRecord(body.request) ? body.request : undefined;
if (service === undefined || service.length === 0 || payload === undefined) {
return badRequest("service and request are required");
}
yield* Effect.tryPromise({
try: () => dispatcher.start(),
catch: (cause) => messagingLifecycleError("gateway", "dispatcher-start", cause),
});
const dispatch = body.scope === "flow"
? dispatcher.dispatchFlowService(
typeof body.flow === "string" ? body.flow : "default",
service,
payload,
)
: dispatcher.dispatchGlobalService(service, payload);
return yield* withDispatchError(dispatch, "workbench-dispatch");
}),
);
const globalDispatch = (
config: GatewayConfig,
dispatcher: DispatcherManager,
) =>
withBearerAuth(
config,
Effect.gen(function* () {
const params = yield* HttpRouter.params;
const body = yield* readJsonRecord.pipe(
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
);
return yield* withDispatchError(
dispatcher.dispatchGlobalService(params.kind ?? "", body),
"global-dispatch",
);
}),
);
const flowDispatch = (
config: GatewayConfig,
dispatcher: DispatcherManager,
) =>
withBearerAuth(
config,
Effect.gen(function* () {
const params = yield* HttpRouter.params;
const body = yield* readJsonRecord.pipe(
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
);
return yield* withDispatchError(
dispatcher.dispatchFlowService(params.flow ?? "default", params.kind ?? "", body),
"flow-dispatch",
);
}),
);
const flowLoad = (
config: GatewayConfig,
dispatcher: DispatcherManager,
) =>
withBearerAuth(
config,
Effect.gen(function* () {
const params = yield* HttpRouter.params;
const body = yield* readJsonRecord.pipe(
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
);
const documentId = typeof body.documentId === "string" ? body.documentId : undefined;
if (documentId === undefined || documentId.length === 0) {
return badRequest("documentId is required");
}
const user = typeof body.user === "string" ? body.user : "default";
const collection = typeof body.collection === "string" ? body.collection : "default";
const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
const metadata = {
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
root: documentId,
user,
collection,
};
yield* dispatcher.publishToTopic("tg.flow.document", { metadata, documentId }).pipe(
Effect.mapError((cause) => messagingLifecycleError("gateway", "publish-load", cause)),
);
return json({ status: "processing", documentId, flow: params.flow ?? "default" });
}).pipe(
Effect.catch((error) => Effect.succeed(dispatchError(error))),
),
);
const rpcRoute = (
config: GatewayConfig,
rpcServer: GatewayRpcServer,
rpcScope: Scope.Scope,
) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const url = new URL(request.url, "http://localhost");
const token = url.searchParams.get("token");
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
return json({ error: "Unauthorized" }, 401);
}
const socket = yield* request.upgrade;
yield* rpcServer.onSocket(socket, headersFrom(request.headers)).pipe(
Scope.provide(rpcScope),
);
return HttpServerResponse.empty();
}).pipe(
Effect.catch((error) => Effect.succeed(dispatchError(error))),
);
const metricsRoute =
formatPrometheusMetrics.pipe(
Effect.map((body) =>
HttpServerResponse.text(body, {
headers: { "content-type": prometheusContentType },
})
),
);
const gatewayRoutes = (
config: GatewayConfig,
dispatcher: DispatcherManager,
rpcServer: GatewayRpcServer,
rpcScope: Scope.Scope,
) =>
Layer.mergeAll(
HttpRouter.add("POST", "/api/v1/workbench/dispatch", workbenchDispatch(config, dispatcher)),
HttpRouter.add("POST", "/api/v1/:kind", globalDispatch(config, dispatcher)),
HttpRouter.add("POST", "/api/v1/flow/:flow/service/:kind", flowDispatch(config, dispatcher)),
HttpRouter.add("POST", "/api/v1/flow/:flow/load", flowLoad(config, dispatcher)),
HttpRouter.add("GET", "/api/v1/rpc", rpcRoute(config, rpcServer, rpcScope)),
HttpRouter.add("GET", "/api/v1/metrics", metricsRoute),
);
export function createGateway(config: GatewayConfig) {
return Layer.effectDiscard(
Effect.scoped(Effect.gen(function* () {
const dispatcher = makeDispatcherManager(config);
yield* dispatcher.start.pipe(
Effect.mapError((cause) => messagingLifecycleError("gateway", "dispatcher-start", cause)),
);
yield* Effect.addFinalizer(() =>
dispatcher.stop.pipe(
Effect.catch((cause) =>
Effect.logError("[Gateway] Failed to stop dispatcher", {
error: cause.message,
operation: cause.operation,
}),
),
),
);
const rpcScope = yield* Scope.make();
yield* Effect.addFinalizer(() => Scope.close(rpcScope, Exit.void));
const rpcServer = yield* makeGatewayRpcServer(dispatcher).pipe(
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
Scope.provide(rpcScope),
);
return { rpcScope, rpcServer };
}),
).then(({ rpcScope, rpcServer }) => {
// Authentication middleware
app.addHook("onRequest", (request, reply) => {
if (request.url === "/api/v1/metrics") return;
if (request.url.startsWith("/api/v1/rpc")) return; // RPC socket auth via query param
if (config.secret !== undefined && config.secret.length > 0) {
const auth = request.headers.authorization;
if (auth === undefined || auth !== `Bearer ${config.secret}`) {
reply.code(401).send({ error: "Unauthorized" });
}
}
});
app.post<{
Body: {
scope?: string;
service?: string;
flow?: string;
request?: Record<string, unknown>;
};
}>("/api/v1/workbench/dispatch", (request, reply) => {
const body = request.body;
const service = body.service;
const payload = body.request;
if (service === undefined || service.length === 0 || payload === undefined) {
return reply.code(400).send({
error: { type: "bad-request", message: "service and request are required" },
});
}
return Effect.runPromise(
Effect.tryPromise({
try: () =>
body.scope === "flow"
? dispatcher.dispatchFlowService(body.flow ?? "default", service, payload)
: dispatcher.dispatchGlobalService(service, payload),
catch: (cause) => messagingLifecycleError("gateway", "workbench-dispatch", cause),
}).pipe(
Effect.map((result) => sendDispatchResult(reply, result)),
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
});
// REST endpoint: POST /api/v1/:kind (global services)
app.post<{ Params: { kind: string } }>("/api/v1/:kind", (request, reply) => {
const { kind } = request.params;
const body = request.body as Record<string, unknown>;
return Effect.runPromise(
Effect.tryPromise({
try: () => dispatcher.dispatchGlobalService(kind, body),
catch: (cause) => messagingLifecycleError("gateway", "global-dispatch", cause),
}).pipe(
Effect.map((result) => sendDispatchResult(reply, result)),
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
});
// REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services)
app.post<{ Params: { flow: string; kind: string } }>(
"/api/v1/flow/:flow/service/:kind",
(request, reply) => {
const { flow, kind } = request.params;
const body = request.body as Record<string, unknown>;
return Effect.runPromise(
Effect.tryPromise({
try: () => dispatcher.dispatchFlowService(flow, kind, body),
catch: (cause) => messagingLifecycleError("gateway", "flow-dispatch", cause),
}).pipe(
Effect.map((result) => sendDispatchResult(reply, result)),
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
},
const serverLayer = HttpRouter.serve(
gatewayRoutes(config, dispatcher, rpcServer, rpcScope),
).pipe(
Layer.provideMerge(NodeHttpServer.layer(createServer, {
port: config.port,
host: "0.0.0.0",
})),
);
// REST endpoint: POST /api/v1/flow/:flow/load (trigger document processing)
app.post<{ Params: { flow: string } }>(
"/api/v1/flow/:flow/load",
(request, reply) => {
const { flow } = request.params;
const body = request.body as {
documentId?: string;
user?: string;
collection?: string;
};
if (body.documentId === undefined || body.documentId.length === 0) {
return reply.code(400).send({
error: { type: "bad-request", message: "documentId is required" },
});
}
return Effect.runPromise(
Effect.gen(function* () {
const user = body.user ?? "default";
const collection = body.collection ?? "default";
const documentId = body.documentId;
const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
// Publish Document message to the decode-input topic
const topic = "tg.flow.document";
const metadata = {
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
root: documentId,
user,
collection,
};
yield* Effect.tryPromise({
try: () => dispatcher.publishToTopic(topic, { metadata, documentId }),
catch: (cause) => messagingLifecycleError("gateway", "publish-load", cause),
});
return { status: "processing", documentId, flow };
}).pipe(
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
},
);
// Effect RPC WebSocket endpoint: /api/v1/rpc
app.get("/api/v1/rpc", { websocket: true }, (socket, request) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get("token");
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
socket.close(4001, "Unauthorized");
return;
}
const program = Effect.scoped(
Effect.gen(function* () {
const effectSocket = yield* EffectSocket.fromWebSocket(
Effect.succeed(socket as unknown as globalThis.WebSocket),
{ closeCodeIsError: (code) => code !== 1000 },
);
yield* rpcServer.onSocket(effectSocket, headersFrom(request.headers));
}),
);
void Effect.runPromise(
program.pipe(
Scope.provide(rpcScope),
Effect.sandbox,
Effect.catch((cause) =>
Effect.logError("[Gateway] RPC WebSocket error", { error: Cause.pretty(cause) }).pipe(
Effect.flatMap(() =>
Effect.sync(() => {
if (socket.readyState === 1) {
socket.close(1011, "Internal server error");
}
}),
),
)
),
),
);
});
// Metrics endpoint — returns Effect metrics in Prometheus exposition format.
app.get("/api/v1/metrics", (_, reply) => {
reply.header("content-type", prometheusContentType);
return Effect.runPromise(formatPrometheusMetrics);
});
return {
start: () => app.listen({ port: config.port, host: "0.0.0.0" }),
stop: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => app.close(),
catch: (cause) => messagingLifecycleError("gateway", "app-close", cause),
});
yield* Scope.close(rpcScope, Exit.void);
yield* Effect.tryPromise({
try: () => dispatcher.stop(),
catch: (cause) => messagingLifecycleError("gateway", "dispatcher-stop", cause),
});
}),
),
};
});
yield* Effect.log(`[Gateway] Listening on port ${config.port}`);
return yield* Layer.launch(serverLayer);
})),
);
}
function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> {
@ -269,10 +288,6 @@ function headersFrom(headers: Record<string, string | string[] | number | undefi
});
}
export function run(): Promise<void> {
return gatewayRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}
@ -290,22 +305,8 @@ export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () {
} satisfies GatewayConfig;
});
export const program = Effect.scoped(
Effect.gen(function* () {
const config = yield* loadGatewayConfig();
const gateway = yield* Effect.promise(() => createGateway(config)).pipe(Effect.orDie);
yield* Effect.addFinalizer(() => Effect.promise(() => gateway.stop()).pipe(Effect.orDie));
yield* Effect.promise(() => gateway.start()).pipe(
Effect.orDie,
Effect.withSpan("trustgraph.gateway.start", {
attributes: {
"trustgraph.gateway.port": config.port,
},
}),
);
yield* Effect.log(`[Gateway] Listening on port ${config.port}`);
return yield* Effect.never;
}),
);
export const gatewayProgram = (config: GatewayConfig) => Layer.launch(createGateway(config));
const gatewayRuntime = ManagedRuntime.make(Layer.empty);
export const program = loadGatewayConfig().pipe(
Effect.flatMap(gatewayProgram),
);

File diff suppressed because it is too large Load diff

View file

@ -18,9 +18,8 @@ import {
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Stream } from "effect";
import { Effect, Stream } from "effect";
import {
llmStreamPart,
makeTextCompletionLayer,
@ -28,7 +27,6 @@ import {
providerStatusError,
requiredString,
streamTextCompletionChunks,
toAsyncGenerator,
type TextCompletionConfigError,
type TextCompletionRuntimeError,
} from "./common.ts";
@ -89,7 +87,7 @@ const mapAzureOpenAIError = (error: unknown): TextCompletionRuntimeError =>
const makeAzureOpenAIProviderFromClient = (
resolved: ResolvedAzureOpenAIConfig,
client: AzureOpenAI,
): LlmProvider => {
): LlmProvider<TextCompletionRuntimeError> => {
const {
defaultModel,
defaultTemperature,
@ -102,31 +100,29 @@ const makeAzureOpenAIProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
return Effect.runPromise(
Effect.tryPromise({
try: () =>
client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
}),
catch: mapAzureOpenAIError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
return Effect.tryPromise({
try: () =>
client.chat.completions.create({
model: modelName,
})),
),
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
}),
catch: mapAzureOpenAIError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
})),
);
},
supportsStreaming: () => true,
@ -135,11 +131,11 @@ const makeAzureOpenAIProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
const stream = Stream.fromEffect(
return Stream.fromEffect(
Effect.tryPromise({
try: () =>
client.chat.completions.create({
@ -169,13 +165,13 @@ const makeAzureOpenAIProviderFromClient = (
})
),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapAzureOpenAIError);
},
} satisfies LlmProvider;
} satisfies LlmProvider<TextCompletionRuntimeError>;
};
export function makeAzureOpenAIProvider(config: AzureOpenAIProcessorConfig): LlmProvider {
export function makeAzureOpenAIProvider(
config: AzureOpenAIProcessorConfig,
): LlmProvider<TextCompletionRuntimeError> {
return Effect.runSync(makeAzureOpenAIProviderEffect(config));
}
@ -217,12 +213,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => makeTextCompletionLayer(makeAzureOpenAIProviderEffect(config)),
});
const azureOpenAITextCompletionRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return azureOpenAITextCompletionRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -14,7 +14,7 @@ import {
type LlmProvider,
type ProcessorConfig,
} from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Redacted } from "effect";
import { Effect, Layer, Redacted } from "effect";
import { FetchHttpClient } from "effect/unstable/http";
import {
makeLanguageModelProvider,
@ -55,30 +55,31 @@ const loadClaudeConfig = Effect.fn("loadClaudeConfig")(function* (config: Claude
} satisfies ResolvedClaudeConfig;
});
const makeClaudeRuntime = (apiKey: string) =>
ManagedRuntime.make(
AnthropicClient.layer({
apiKey: Redacted.make(apiKey),
}).pipe(
Layer.provide(FetchHttpClient.layer),
),
const makeClaudeLayer = (apiKey: string) =>
AnthropicClient.layer({
apiKey: Redacted.make(apiKey),
}).pipe(
Layer.provide(FetchHttpClient.layer),
);
export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider {
return Effect.runSync(makeClaudeProviderEffect(config));
export function makeClaudeProvider(
config: ClaudeProcessorConfig,
): LlmProvider<TextCompletionRuntimeError> {
return Effect.runSync(Effect.scoped(makeClaudeProviderEffect(config)));
}
export const makeClaudeProviderEffect = Effect.fn("makeClaudeProvider")(function* (
config: ClaudeProcessorConfig,
) {
const resolved = yield* loadClaudeConfig(config);
const context = yield* Layer.build(makeClaudeLayer(resolved.apiKey));
yield* Effect.log("[Claude] LLM service initialized");
return makeLanguageModelProvider({
provider: "Claude",
defaultModel: resolved.defaultModel,
defaultTemperature: resolved.defaultTemperature,
runtime: makeClaudeRuntime(resolved.apiKey),
context,
makeLanguageModel: ({ model, temperature }) =>
AnthropicLanguageModel.make({
model,
@ -110,12 +111,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => makeTextCompletionLayer(makeClaudeProviderEffect(config)),
});
const claudeTextCompletionRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return claudeTextCompletionRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -7,10 +7,11 @@ import {
type LlmResult,
type LlmProvider,
} from "@trustgraph/base";
import { Config, Effect, Layer, ManagedRuntime, Match, Ref, Result, Stream } from "effect";
import { Config, Context, Effect, Layer, Match, Ref, Result, Stream } from "effect";
import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
import type * as Scope from "effect/Scope";
import { AiError, LanguageModel, Prompt, Response } from "effect/unstable/ai";
export class TextCompletionConfigError extends S.TaggedErrorClass<TextCompletionConfigError>()(
@ -43,15 +44,15 @@ export interface LanguageModelProviderOptions<Requirements> {
readonly provider: string;
readonly defaultModel: string;
readonly defaultTemperature: number;
readonly runtime: ManagedRuntime.ManagedRuntime<Requirements, TextCompletionRuntimeError>;
readonly context: Context.Context<Requirements>;
readonly makeLanguageModel: (
request: LanguageModelProviderRequest,
) => Effect.Effect<LanguageModel.Service, TextCompletionRuntimeError, Requirements>;
}
export const makeTextCompletionLayer = <E, R>(
provider: Effect.Effect<LlmProvider, E, R>,
): Layer.Layer<Llm, E, R> =>
export const makeTextCompletionLayer = <ProviderError, E, R>(
provider: Effect.Effect<LlmProvider<ProviderError>, E, R>,
): Layer.Layer<Llm, E, Exclude<R, Scope.Scope>> =>
Layer.effect(Llm)(
provider.pipe(
Effect.map((resolvedProvider) =>
@ -279,39 +280,25 @@ const languageModelStreamChunk = (
Match.orElse(() => Effect.succeed(Result.fail(undefined))),
);
const runLanguageModelStream = <RuntimeRequirements, StreamRequirements extends RuntimeRequirements>(
runtime: ManagedRuntime.ManagedRuntime<RuntimeRequirements, TextCompletionRuntimeError>,
stream: Stream.Stream<LlmChunk, TextCompletionRuntimeError, StreamRequirements>,
): AsyncIterable<LlmChunk> => ({
[Symbol.asyncIterator]: () => {
const iterator = runtime.context().then((context) =>
Stream.toAsyncIterableWith(stream, context)[Symbol.asyncIterator]()
);
return {
next: () => iterator.then((current) => current.next()),
};
},
});
export const makeLanguageModelProvider = <Requirements>(
options: LanguageModelProviderOptions<Requirements>,
): LlmProvider => ({
): LlmProvider<TextCompletionRuntimeError> => ({
generateContent: (system, prompt, model, temperature) => {
const modelName = model ?? options.defaultModel;
const temp = temperature ?? options.defaultTemperature;
return options.runtime.runPromise(
Effect.gen(function* () {
const languageModel = yield* options.makeLanguageModel({
model: modelName,
temperature: temp,
});
const response = yield* languageModel.generateText({
prompt: languageModelPrompt(system, prompt),
}).pipe(
Effect.mapError((error) => effectAiProviderError(options.provider, error)),
);
return languageModelResult(response, modelName);
}),
return Effect.gen(function* () {
const languageModel = yield* options.makeLanguageModel({
model: modelName,
temperature: temp,
});
const response = yield* languageModel.generateText({
prompt: languageModelPrompt(system, prompt),
}).pipe(
Effect.mapError((error) => effectAiProviderError(options.provider, error)),
);
return languageModelResult(response, modelName);
}).pipe(
Effect.provideContext(options.context),
);
},
supportsStreaming: () => true,
@ -333,30 +320,9 @@ export const makeLanguageModelProvider = <Requirements>(
),
);
}),
).pipe(
Stream.provideContext(options.context),
);
return toAsyncGenerator(runLanguageModelStream(options.runtime, stream), (error) =>
effectAiProviderError(options.provider, error)
);
return stream;
},
});
export const toAsyncGenerator = (
iterable: AsyncIterable<LlmChunk>,
mapError: (error: unknown) => TextCompletionRuntimeError,
): AsyncGenerator<LlmChunk> => {
const iterator = iterable[Symbol.asyncIterator]();
let generator: AsyncGenerator<LlmChunk>;
generator = {
next: (value?: unknown) => iterator.next(value),
return: (value?: unknown) =>
iterator.return === undefined
? Promise.resolve({ done: true, value })
: iterator.return(value),
throw: (error?: unknown) =>
iterator.throw === undefined
? Promise.reject(mapError(error))
: iterator.throw(error),
[Symbol.asyncIterator]: () => generator,
};
return generator;
};

View file

@ -16,9 +16,8 @@ import {
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Stream } from "effect";
import { Effect, Stream } from "effect";
import {
llmStreamPart,
makeTextCompletionLayer,
@ -27,7 +26,6 @@ import {
requiredString,
streamTextCompletionChunks,
textFromContent,
toAsyncGenerator,
type TextCompletionConfigError,
type TextCompletionRuntimeError,
} from "./common.ts";
@ -71,7 +69,7 @@ const mapMistralError = (error: unknown): TextCompletionRuntimeError =>
const makeMistralProviderFromClient = (
resolved: ResolvedMistralConfig,
client: Mistral,
): LlmProvider => {
): LlmProvider<TextCompletionRuntimeError> => {
const {
defaultModel,
defaultTemperature,
@ -84,31 +82,29 @@ const makeMistralProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
return Effect.runPromise(
Effect.tryPromise({
try: () =>
client.chat.complete({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
maxTokens: maxOutput,
}),
catch: mapMistralError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: textFromContent(resp.choices?.[0]?.message?.content),
inToken: resp.usage?.promptTokens ?? 0,
outToken: resp.usage?.completionTokens ?? 0,
return Effect.tryPromise({
try: () =>
client.chat.complete({
model: modelName,
})),
),
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
maxTokens: maxOutput,
}),
catch: mapMistralError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: textFromContent(resp.choices?.[0]?.message?.content),
inToken: resp.usage?.promptTokens ?? 0,
outToken: resp.usage?.completionTokens ?? 0,
model: modelName,
})),
);
},
supportsStreaming: () => true,
@ -117,11 +113,11 @@ const makeMistralProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
const stream = Stream.fromEffect(
return Stream.fromEffect(
Effect.tryPromise({
try: () =>
client.chat.stream({
@ -149,13 +145,13 @@ const makeMistralProviderFromClient = (
})
),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapMistralError);
},
} satisfies LlmProvider;
} satisfies LlmProvider<TextCompletionRuntimeError>;
};
export function makeMistralProvider(config: MistralProcessorConfig): LlmProvider {
export function makeMistralProvider(
config: MistralProcessorConfig,
): LlmProvider<TextCompletionRuntimeError> {
return Effect.runSync(makeMistralProviderEffect(config));
}
@ -192,12 +188,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => makeTextCompletionLayer(makeMistralProviderEffect(config)),
});
const mistralTextCompletionRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return mistralTextCompletionRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -16,16 +16,14 @@ import {
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Stream } from "effect";
import { Effect, Stream } from "effect";
import {
llmStreamPart,
makeTextCompletionLayer,
optionalStringConfig,
providerRuntimeError,
streamTextCompletionChunks,
toAsyncGenerator,
type TextCompletionConfigError,
type TextCompletionRuntimeError,
} from "./common.ts";
@ -59,7 +57,7 @@ const mapOllamaError = (error: unknown): TextCompletionRuntimeError =>
const makeOllamaProviderFromClient = (
resolved: ResolvedOllamaConfig,
client: Ollama,
): LlmProvider => {
): LlmProvider<TextCompletionRuntimeError> => {
const { defaultModel } = resolved;
return {
@ -68,27 +66,25 @@ const makeOllamaProviderFromClient = (
prompt: string,
model?: string,
_temperature?: number,
): Promise<LlmResult> => {
) => {
const modelName = model ?? defaultModel;
const fullPrompt = system + "\n\n" + prompt;
return Effect.runPromise(
Effect.tryPromise({
try: () =>
client.generate({
model: modelName,
prompt: fullPrompt,
stream: false,
}),
catch: mapOllamaError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.response,
inToken: resp.prompt_eval_count ?? 0,
outToken: resp.eval_count ?? 0,
return Effect.tryPromise({
try: () =>
client.generate({
model: modelName,
})),
),
prompt: fullPrompt,
stream: false,
}),
catch: mapOllamaError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.response,
inToken: resp.prompt_eval_count ?? 0,
outToken: resp.eval_count ?? 0,
model: modelName,
})),
);
},
supportsStreaming: () => true,
@ -97,11 +93,11 @@ const makeOllamaProviderFromClient = (
prompt: string,
model?: string,
_temperature?: number,
): AsyncGenerator<LlmChunk> => {
) => {
const modelName = model ?? defaultModel;
const fullPrompt = system + "\n\n" + prompt;
const stream = Stream.fromEffect(
return Stream.fromEffect(
Effect.tryPromise({
try: () =>
client.generate({
@ -125,13 +121,13 @@ const makeOllamaProviderFromClient = (
})
),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOllamaError);
},
} satisfies LlmProvider;
} satisfies LlmProvider<TextCompletionRuntimeError>;
};
export function makeOllamaProvider(config: OllamaProcessorConfig): LlmProvider {
export function makeOllamaProvider(
config: OllamaProcessorConfig,
): LlmProvider<TextCompletionRuntimeError> {
return Effect.runSync(makeOllamaProviderEffect(config));
}
@ -170,12 +166,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => makeTextCompletionLayer(makeOllamaProviderEffect(config)),
});
const ollamaTextCompletionRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return ollamaTextCompletionRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -19,9 +19,8 @@ import {
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Stream } from "effect";
import { Effect, Stream } from "effect";
import {
llmStreamPart,
makeTextCompletionLayer,
@ -29,7 +28,6 @@ import {
providerStatusError,
requiredString,
streamTextCompletionChunks,
toAsyncGenerator,
type TextCompletionConfigError,
type TextCompletionRuntimeError,
} from "./common.ts";
@ -79,7 +77,7 @@ const mapOpenAICompatibleError = (error: unknown): TextCompletionRuntimeError =>
const makeOpenAICompatibleProviderFromClient = (
resolved: ResolvedOpenAICompatibleConfig,
client: OpenAI,
): LlmProvider => {
): LlmProvider<TextCompletionRuntimeError> => {
const {
defaultModel,
defaultTemperature,
@ -92,31 +90,29 @@ const makeOpenAICompatibleProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
return Effect.runPromise(
Effect.tryPromise({
try: () =>
client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_tokens: maxOutput,
}),
catch: mapOpenAICompatibleError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
return Effect.tryPromise({
try: () =>
client.chat.completions.create({
model: modelName,
})),
),
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_tokens: maxOutput,
}),
catch: mapOpenAICompatibleError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
})),
);
},
supportsStreaming: () => true,
@ -125,11 +121,11 @@ const makeOpenAICompatibleProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
const stream = Stream.fromEffect(
return Stream.fromEffect(
Effect.tryPromise({
try: () =>
client.chat.completions.create({
@ -158,15 +154,13 @@ const makeOpenAICompatibleProviderFromClient = (
})
),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOpenAICompatibleError);
},
} satisfies LlmProvider;
} satisfies LlmProvider<TextCompletionRuntimeError>;
};
export function makeOpenAICompatibleProvider(
config: OpenAICompatibleProcessorConfig,
): LlmProvider {
): LlmProvider<TextCompletionRuntimeError> {
return Effect.runSync(makeOpenAICompatibleProviderEffect(config));
}
@ -203,12 +197,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => makeTextCompletionLayer(makeOpenAICompatibleProviderEffect(config)),
});
const openAICompatibleTextCompletionRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return openAICompatibleTextCompletionRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -14,9 +14,8 @@ import {
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime, Stream } from "effect";
import { Effect, Stream } from "effect";
import {
llmStreamPart,
makeTextCompletionLayer,
@ -24,7 +23,6 @@ import {
providerStatusError,
requiredString,
streamTextCompletionChunks,
toAsyncGenerator,
type TextCompletionConfigError,
type TextCompletionRuntimeError,
} from "./common.ts";
@ -68,7 +66,7 @@ const mapOpenAIError = (error: unknown): TextCompletionRuntimeError =>
const makeOpenAIProviderFromClient = (
resolved: ResolvedOpenAIConfig,
client: OpenAI,
): LlmProvider => {
): LlmProvider<TextCompletionRuntimeError> => {
const {
defaultModel,
defaultTemperature,
@ -81,31 +79,29 @@ const makeOpenAIProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
return Effect.runPromise(
Effect.tryPromise({
try: () =>
client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
}),
catch: mapOpenAIError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
return Effect.tryPromise({
try: () =>
client.chat.completions.create({
model: modelName,
})),
),
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
}),
catch: mapOpenAIError,
}).pipe(
Effect.map((resp): LlmResult => ({
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
})),
);
},
supportsStreaming: () => true,
@ -114,11 +110,11 @@ const makeOpenAIProviderFromClient = (
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> => {
) => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
const stream = Stream.fromEffect(
return Stream.fromEffect(
Effect.tryPromise({
try: () =>
client.chat.completions.create({
@ -148,13 +144,13 @@ const makeOpenAIProviderFromClient = (
})
),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOpenAIError);
},
} satisfies LlmProvider;
} satisfies LlmProvider<TextCompletionRuntimeError>;
};
export function makeOpenAIProvider(config: OpenAIProcessorConfig): LlmProvider {
export function makeOpenAIProvider(
config: OpenAIProcessorConfig,
): LlmProvider<TextCompletionRuntimeError> {
return Effect.runSync(makeOpenAIProviderEffect(config));
}
@ -195,12 +191,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => makeTextCompletionLayer(makeOpenAIProviderEffect(config)),
});
const openAITextCompletionRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return openAITextCompletionRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -40,7 +40,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import * as MutableHashMap from "effect/MutableHashMap";
import * as O from "effect/Option";
import * as S from "effect/Schema";
@ -167,9 +167,7 @@ export function makePromptTemplateService(config: PromptTemplateConfig): PromptT
specifications: runtime.specs,
});
for (const handler of runtime.configHandlers) {
service.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(handler(pushedConfig, version)),
);
service.registerConfigHandler(handler);
}
Effect.runSync(Effect.log("[PromptTemplate] Service initialized"));
return service;
@ -199,12 +197,6 @@ export const program = makeFlowProcessorProgram({
configHandlers: (config: PromptTemplateConfig) => promptTemplateRuntime(config).configHandlers,
});
const promptRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return promptRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -1,4 +1,7 @@
import { QdrantClient, type QdrantClientParams } from "@qdrant/js-client-rest";
import { errorMessage } from "@trustgraph/base";
import { Effect } from "effect";
import * as S from "effect/Schema";
export interface QdrantCollectionStatus {
readonly exists: boolean;
@ -17,8 +20,21 @@ export interface QdrantScoredPoint {
readonly payload?: unknown;
}
export class QdrantClientError extends S.TaggedErrorClass<QdrantClientError>()("QdrantClientError", {
message: S.String,
operation: S.String,
cause: S.Defect({ includeStack: true }),
}) {}
const qdrantClientError = (operation: string, cause: unknown) =>
QdrantClientError.make({
operation,
message: errorMessage(cause),
cause,
});
export interface QdrantClientLike {
readonly collectionExists: (collectionName: string) => Promise<QdrantCollectionStatus>;
readonly collectionExists: (collectionName: string) => Effect.Effect<QdrantCollectionStatus, QdrantClientError>;
readonly createCollection: (
collectionName: string,
options: {
@ -27,7 +43,7 @@ export interface QdrantClientLike {
readonly distance: "Cosine";
};
},
) => Promise<unknown>;
) => Effect.Effect<void, QdrantClientError>;
readonly upsert: (
collectionName: string,
options: {
@ -37,9 +53,9 @@ export interface QdrantClientLike {
readonly payload?: Record<string, unknown>;
}>;
},
) => Promise<unknown>;
readonly getCollections: () => Promise<QdrantCollections>;
readonly deleteCollection: (collectionName: string) => Promise<unknown>;
) => Effect.Effect<void, QdrantClientError>;
readonly getCollections: Effect.Effect<QdrantCollections, QdrantClientError>;
readonly deleteCollection: (collectionName: string) => Effect.Effect<void, QdrantClientError>;
readonly search: (
collectionName: string,
options: {
@ -47,7 +63,7 @@ export interface QdrantClientLike {
readonly limit: number;
readonly with_payload: boolean;
},
) => Promise<ReadonlyArray<QdrantScoredPoint>>;
) => Effect.Effect<ReadonlyArray<QdrantScoredPoint>, QdrantClientError>;
}
export type QdrantClientFactory = (params: QdrantClientParams) => QdrantClientLike;
@ -61,24 +77,41 @@ export const makeQdrantClient = (
}
const client = new QdrantClient(params);
const tryQdrantPromise = <A>(operation: string, try_: () => PromiseLike<A>) =>
Effect.tryPromise({
try: try_,
catch: (cause) => qdrantClientError(operation, cause),
});
return {
collectionExists: (collectionName) => client.collectionExists(collectionName),
createCollection: (collectionName, options) => client.createCollection(collectionName, options),
collectionExists: (collectionName) =>
tryQdrantPromise("collection-exists", () => client.collectionExists(collectionName)),
createCollection: (collectionName, options) =>
tryQdrantPromise("create-collection", () => client.createCollection(collectionName, options)).pipe(
Effect.asVoid,
),
upsert: (collectionName, options) =>
client.upsert(collectionName, {
points: options.points.map((point) => ({
id: point.id,
vector: Array.from(point.vector),
...(point.payload !== undefined ? { payload: point.payload } : {}),
})),
}),
getCollections: () => client.getCollections(),
deleteCollection: (collectionName) => client.deleteCollection(collectionName),
tryQdrantPromise("upsert", () =>
client.upsert(collectionName, {
points: options.points.map((point) => ({
id: point.id,
vector: Array.from(point.vector),
...(point.payload !== undefined ? { payload: point.payload } : {}),
})),
})
).pipe(Effect.asVoid),
getCollections: tryQdrantPromise("get-collections", () => client.getCollections()),
deleteCollection: (collectionName) =>
tryQdrantPromise("delete-collection", () => client.deleteCollection(collectionName)).pipe(
Effect.asVoid,
),
search: (collectionName, options) =>
client.search(collectionName, {
vector: Array.from(options.vector),
limit: options.limit,
with_payload: options.with_payload,
}),
tryQdrantPromise("search", () =>
client.search(collectionName, {
vector: Array.from(options.vector),
limit: options.limit,
with_payload: options.with_payload,
})
),
};
};

View file

@ -24,7 +24,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import {
QdrantDocEmbeddingsQueryLive,
QdrantDocEmbeddingsQueryService,
@ -111,12 +111,10 @@ const provideQdrantDocEmbeddingsQuery = (processorId: string) =>
});
export function makeDocEmbeddingsQueryService(config: ProcessorConfig): DocEmbeddingsQueryService {
const service = makeFlowProcessor(config, {
return makeFlowProcessor(config, {
specifications: makeDocEmbeddingsQuerySpecs(),
provide: provideQdrantDocEmbeddingsQuery(config.id),
});
void Effect.runPromise(Effect.log("[DocEmbeddingsQuery] Service initialized"));
return service;
}
export const DocEmbeddingsQueryService = makeDocEmbeddingsQueryService;
@ -131,12 +129,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => QdrantDocEmbeddingsQueryLive(config),
});
const docEmbeddingsQueryRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return docEmbeddingsQueryRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -37,7 +37,7 @@ export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocE
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -73,8 +73,7 @@ const decodeDocPointPayload = (payload: unknown) =>
S.decodeUnknownEffect(DocPointPayloadSchema)(payload).pipe(Effect.option);
export interface QdrantDocEmbeddingsQuery {
readonly query: (request: DocEmbeddingsQueryRequest) => Promise<ReadonlyArray<ChunkMatch>>;
readonly queryEffect: (
readonly query: (
request: DocEmbeddingsQueryRequest,
) => Effect.Effect<ReadonlyArray<ChunkMatch>, QdrantDocEmbeddingsQueryError>;
}
@ -95,7 +94,7 @@ const makeQdrantDocEmbeddingsQueryClient = (
const makeQdrantDocEmbeddingsQueryFromClient = (
client: QdrantClientLike,
): QdrantDocEmbeddingsQueryServiceShape => {
const queryEffect = Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request: DocEmbeddingsQueryRequest) {
const queryImpl = Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request: DocEmbeddingsQueryRequest) {
const { vector, user, collection, limit } = request;
if (vector.length === 0) {
@ -106,10 +105,9 @@ const makeQdrantDocEmbeddingsQueryFromClient = (
const collectionName = `d_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(collectionName),
catch: (cause) => qdrantDocEmbeddingsQueryError("collection-exists", cause),
});
const exists = yield* client.collectionExists(collectionName).pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsQueryError("collection-exists", cause)),
);
if (!exists.exists) {
yield* Effect.log(
`[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`,
@ -117,15 +115,16 @@ const makeQdrantDocEmbeddingsQueryFromClient = (
return [];
}
const searchResult = yield* Effect.tryPromise({
try: () =>
client.search(collectionName, {
vector,
limit,
with_payload: true,
}),
catch: (cause) => qdrantDocEmbeddingsQueryError("search", cause),
});
const searchResult = yield* client.search(
collectionName,
{
vector,
limit,
with_payload: true,
},
).pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsQueryError("search", cause)),
);
const chunks: ChunkMatch[] = [];
for (const point of searchResult) {
@ -146,7 +145,7 @@ const makeQdrantDocEmbeddingsQueryFromClient = (
});
return {
query: queryEffect,
query: queryImpl,
};
};
@ -172,12 +171,8 @@ const withQdrantDocEmbeddingsQuery = <A>(
export function makeQdrantDocEmbeddingsQuery(
config: QdrantDocQueryConfig = {},
): QdrantDocEmbeddingsQuery {
const queryEffect = (request: DocEmbeddingsQueryRequest) =>
withQdrantDocEmbeddingsQuery(config, (query) => query.query(request));
return {
query: (request) => Effect.runPromise(queryEffect(request)),
queryEffect,
query: (request) => withQdrantDocEmbeddingsQuery(config, (query) => query.query(request)),
};
}

View file

@ -24,7 +24,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import {
QdrantGraphEmbeddingsQueryLive,
QdrantGraphEmbeddingsQueryService,
@ -112,12 +112,10 @@ const provideQdrantGraphEmbeddingsQuery = (processorId: string) =>
});
export function makeGraphEmbeddingsQueryService(config: ProcessorConfig): GraphEmbeddingsQueryService {
const service = makeFlowProcessor(config, {
return makeFlowProcessor(config, {
specifications: makeGraphEmbeddingsQuerySpecs(),
provide: provideQdrantGraphEmbeddingsQuery(config.id),
});
void Effect.runPromise(Effect.log("[GraphEmbeddingsQuery] Service initialized"));
return service;
}
export const GraphEmbeddingsQueryService = makeGraphEmbeddingsQueryService;
@ -132,12 +130,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => QdrantGraphEmbeddingsQueryLive(config),
});
const graphEmbeddingsQueryRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return graphEmbeddingsQueryRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -39,7 +39,7 @@ export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGr
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -81,8 +81,7 @@ const decodeGraphPointPayload = (payload: unknown) =>
S.decodeUnknownEffect(GraphPointPayloadSchema)(payload).pipe(Effect.option);
export interface QdrantGraphEmbeddingsQuery {
readonly query: (request: GraphEmbeddingsQueryRequest) => Promise<ReadonlyArray<EntityMatch>>;
readonly queryEffect: (
readonly query: (
request: GraphEmbeddingsQueryRequest,
) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>;
}
@ -104,7 +103,7 @@ const makeQdrantGraphEmbeddingsQueryFromClient = (
client: QdrantClientLike,
): QdrantGraphEmbeddingsQueryServiceShape => {
const queryEffect = Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (
const queryImpl = Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (
request: GraphEmbeddingsQueryRequest,
) {
const { vector, user, collection, limit } = request;
@ -117,10 +116,9 @@ const makeQdrantGraphEmbeddingsQueryFromClient = (
const collectionName = `t_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(collectionName),
catch: (cause) => qdrantGraphEmbeddingsQueryError("collection-exists", cause),
});
const exists = yield* client.collectionExists(collectionName).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsQueryError("collection-exists", cause)),
);
if (!exists.exists) {
yield* Effect.log(
`[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`,
@ -130,15 +128,16 @@ const makeQdrantGraphEmbeddingsQueryFromClient = (
// Query 2x the limit so we have a better chance of getting `limit`
// unique entities after deduplication (same heuristic as Python impl)
const searchResult = yield* Effect.tryPromise({
try: () =>
client.search(collectionName, {
vector,
limit: limit * 2,
with_payload: true,
}),
catch: (cause) => qdrantGraphEmbeddingsQueryError("search", cause),
});
const searchResult = yield* client.search(
collectionName,
{
vector,
limit: limit * 2,
with_payload: true,
},
).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsQueryError("search", cause)),
);
const entitySet = new Set<string>();
const entities: EntityMatch[] = [];
@ -168,7 +167,7 @@ const makeQdrantGraphEmbeddingsQueryFromClient = (
});
return {
query: queryEffect,
query: queryImpl,
};
};
@ -194,12 +193,8 @@ const withQdrantGraphEmbeddingsQuery = <A>(
export function makeQdrantGraphEmbeddingsQuery(
config: QdrantGraphQueryConfig = {},
): QdrantGraphEmbeddingsQuery {
const queryEffect = (request: GraphEmbeddingsQueryRequest) =>
withQdrantGraphEmbeddingsQuery(config, (query) => query.query(request));
return {
query: (request) => Effect.runPromise(queryEffect(request)),
queryEffect,
query: (request) => withQdrantGraphEmbeddingsQuery(config, (query) => query.query(request)),
};
}

View file

@ -24,7 +24,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import {
FalkorDBTriplesQueryLive,
FalkorDBTriplesQueryService,
@ -98,12 +98,10 @@ const provideFalkorDBTriplesQuery = (processorId: string) =>
});
export function makeTriplesQueryService(config: ProcessorConfig): TriplesQueryService {
const service = makeFlowProcessor(config, {
return makeFlowProcessor(config, {
specifications: makeTriplesQuerySpecs(),
provide: provideFalkorDBTriplesQuery(config.id),
});
void Effect.runPromise(Effect.log("[TriplesQuery] Service initialized"));
return service;
}
export const TriplesQueryService = makeTriplesQueryService;
@ -118,12 +116,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => FalkorDBTriplesQueryLive(config),
});
const triplesQueryRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return triplesQueryRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -13,8 +13,8 @@ import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
export interface FalkorDBClosableClient {
readonly connect: () => Promise<unknown>;
readonly disconnect: () => Promise<unknown>;
readonly connect: Effect.Effect<void, FalkorDBTriplesQueryError>;
readonly disconnect: Effect.Effect<void, FalkorDBTriplesQueryError>;
}
export type FalkorDBQueryOptions = Parameters<Graph["query"]>[1];
@ -23,7 +23,7 @@ export interface FalkorDBQueryGraph {
readonly query: <T = unknown>(
query: string,
options?: FalkorDBQueryOptions,
) => Promise<{ readonly data?: Array<T> }>;
) => Effect.Effect<{ readonly data?: Array<T> }, FalkorDBTriplesQueryError>;
}
export type FalkorDBQueryClientFactory = (url: string) => FalkorDBClosableClient;
@ -73,7 +73,7 @@ export interface FalkorDBTriplesQuery {
p?: Term,
o?: Term,
limit?: number,
) => Promise<Triple[]>;
) => Effect.Effect<ReadonlyArray<Triple>, FalkorDBTriplesQueryError>;
}
export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()(
@ -81,7 +81,7 @@ export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriple
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -113,6 +113,12 @@ interface FalkorDBQueryConnection {
readonly graph: FalkorDBQueryGraph;
}
const tryFalkorDBPromise = <A>(operation: string, try_: () => PromiseLike<A>) =>
Effect.tryPromise({
try: try_,
catch: (cause) => falkorDBTriplesQueryError(operation, cause),
});
const resolveFalkorDBQueryConfig = Effect.fn("FalkorDBTriplesQuery.resolveConfig")(function* (
config: FalkorDBQueryConfig,
) {
@ -149,16 +155,21 @@ const connectFalkorDBTriplesQuery = Effect.fn("FalkorDBTriplesQuery.connect")(fu
const client = clientFactory(url);
return { client, graph: graphFactory(client, database) };
}
const client = createClient({ url });
return { client, graph: new Graph(client, database) };
const sdkClient = createClient({ url });
const client: FalkorDBClosableClient = {
connect: tryFalkorDBPromise("connect", () => sdkClient.connect()).pipe(Effect.asVoid),
disconnect: tryFalkorDBPromise("disconnect", () => sdkClient.disconnect()).pipe(Effect.asVoid),
};
const sdkGraph = new Graph(sdkClient, database);
const graph: FalkorDBQueryGraph = {
query: (query, options) => tryFalkorDBPromise("graph-query", () => sdkGraph.query(query, options)),
};
return { client, graph };
},
catch: (cause) => falkorDBTriplesQueryError("create-client", cause),
});
yield* Effect.tryPromise({
try: () => client.connect(),
catch: (cause) => falkorDBTriplesQueryError("connect", cause),
}).pipe(
yield* client.connect.pipe(
Effect.tapError((error) =>
Effect.logError("[FalkorDBTriplesQuery] Connection failed", {
error: error.message,
@ -174,10 +185,7 @@ const connectFalkorDBTriplesQuery = Effect.fn("FalkorDBTriplesQuery.connect")(fu
const disconnectFalkorDBTriplesQuery = (
connection: FalkorDBQueryConnection,
): Effect.Effect<void> =>
Effect.tryPromise({
try: () => connection.client.disconnect(),
catch: (cause) => falkorDBTriplesQueryError("disconnect", cause),
}).pipe(
connection.client.disconnect.pipe(
Effect.catch((error) =>
Effect.logError("[FalkorDBTriplesQuery] Disconnect failed", {
error: error.message,
@ -201,10 +209,8 @@ const queryRows = (
query: string,
options?: FalkorDBQueryOptions,
): Effect.Effect<ReadonlyArray<unknown>, FalkorDBTriplesQueryError> =>
Effect.tryPromise({
try: () => graph.query<unknown>(query, options),
catch: (cause) => falkorDBTriplesQueryError(operation, cause),
}).pipe(
graph.query<unknown>(query, options).pipe(
Effect.mapError((cause) => falkorDBTriplesQueryError(operation, cause)),
Effect.map((result) => result.data ?? []),
);
@ -480,9 +486,7 @@ export function makeFalkorDBTriplesQuery(
): FalkorDBTriplesQuery {
return {
queryTriples: (s, p, o, limit = 100) =>
Effect.runPromise(
withFalkorDBTriplesQuery(config, (query) => query.queryTriples(s, p, o, limit)),
).then((triples) => Array.from(triples)),
withFalkorDBTriplesQuery(config, (query) => query.queryTriples(s, p, o, limit)),
};
}

View file

@ -31,7 +31,7 @@ import {
type TextCompletionRequest,
type TextCompletionResponse,
} from "@trustgraph/base";
import {Effect, Layer, ManagedRuntime} from "effect";
import {Effect} from "effect";
import {
DocumentRagEngine,
DocumentRagEngineError,
@ -139,12 +139,6 @@ export const program = makeFlowProcessorProgram({
layer: () => DocumentRagLive,
});
const documentRagRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return documentRagRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -26,7 +26,10 @@ export interface DocumentRagClients {
prompt: EffectRequestResponse<PromptRequest, PromptResponse>;
}
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
export type ChunkCallback = (
text: string,
endOfStream: boolean,
) => Effect.Effect<void, DocumentRagEngineError>;
export interface DocumentRagQueryOptions {
readonly collection?: string;
@ -39,7 +42,7 @@ export class DocumentRagEngineError extends S.TaggedErrorClass<DocumentRagEngine
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -82,14 +85,13 @@ export interface DocumentRag {
readonly query: (
queryText: string,
options?: DocumentRagQueryOptions,
) => Promise<string>;
) => Effect.Effect<string, DocumentRagEngineError>;
}
export function makeDocumentRag(clients: DocumentRagClients): DocumentRag {
const engine = makeDocumentRagEngine();
return {
query: (queryText, options) =>
Effect.runPromise(engine.query(clients, queryText, options)),
query: (queryText, options) => engine.query(clients, queryText, options),
};
}

View file

@ -33,7 +33,7 @@ import {
type TriplesQueryRequest,
type TriplesQueryResponse,
} from "@trustgraph/base";
import {Effect, Layer, ManagedRuntime} from "effect";
import {Effect} from "effect";
import {
GraphRagEngine,
GraphRagEngineError,
@ -173,12 +173,6 @@ export const program = makeFlowProcessorProgram({
layer: () => GraphRagLive,
});
const graphRagRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return graphRagRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -42,7 +42,10 @@ export interface GraphRagClients {
prompt: EffectRequestResponse<PromptRequest, PromptResponse>;
}
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
export type ChunkCallback = (
text: string,
endOfStream: boolean,
) => Effect.Effect<void, GraphRagEngineError>;
export interface GraphRagQueryOptions {
readonly collection?: string;
@ -69,7 +72,7 @@ export class GraphRagEngineError extends S.TaggedErrorClass<GraphRagEngineError>
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -135,7 +138,7 @@ export interface GraphRag {
readonly query: (
queryText: string,
options?: GraphRagQueryOptions,
) => Promise<GraphRagResult>;
) => Effect.Effect<GraphRagResult, GraphRagEngineError>;
}
export function makeGraphRag(
@ -144,8 +147,7 @@ export function makeGraphRag(
): GraphRag {
const engine = makeGraphRagEngine();
return {
query: (queryText, options) =>
Effect.runPromise(engine.query(clients, queryText, options, config)),
query: (queryText, options) => engine.query(clients, queryText, options, config),
};
}
@ -403,10 +405,9 @@ const synthesize = Effect.fn("GraphRagEngine.synthesize")(function* (
return Effect.succeed(resp.endOfStream === true);
}
fullText += resp.response;
return Effect.tryPromise({
try: () => chunkCallback(resp.response, resp.endOfStream === true).then(() => resp.endOfStream === true),
catch: (cause) => graphRagError("synthesize-stream-callback", cause),
});
return chunkCallback(resp.response, resp.endOfStream === true).pipe(
Effect.as(resp.endOfStream === true),
);
},
},
);
@ -427,7 +428,7 @@ const synthesize = Effect.fn("GraphRagEngine.synthesize")(function* (
const ScoredEdge = S.Struct({
id: S.String,
score: S.Number,
score: S.Finite,
});
const ScoredEdgesFromJson = S.Array(ScoredEdge).pipe(S.fromJsonString);
const ScoredEdgeFromJson = ScoredEdge.pipe(S.fromJsonString);

View file

@ -1,10 +1,10 @@
/** @effect-diagnostics strictEffectProvide:skip-file */
import * as BunFileSystem from "@effect/platform-bun/BunFileSystem";
import { Effect, ManagedRuntime } from "effect";
import { Effect } from "effect";
import * as FileSystem from "effect/FileSystem";
import type { PlatformError } from "effect/PlatformError";
const fileSystemRuntime = ManagedRuntime.make(BunFileSystem.layer);
export function joinPath(...segments: string[]): string {
const joined = segments
.filter((segment) => segment.length > 0)
@ -22,52 +22,33 @@ export function dirnamePath(path: string): string {
return normalized.slice(0, index);
}
export const ensureDirectoryEffect = (path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) =>
const withFileSystem = <A, E>(
effect: Effect.Effect<A, E, FileSystem.FileSystem>,
): Effect.Effect<A, E> =>
effect.pipe(Effect.provide(BunFileSystem.layer));
export const ensureDirectoryEffect = (path: string): Effect.Effect<void, PlatformError> =>
withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) =>
fs.makeDirectory(path, { recursive: true })
);
));
export function ensureDirectory(path: string): Promise<void> {
return fileSystemRuntime.runPromise(ensureDirectoryEffect(path));
}
export const readTextFileEffect = (path: string): Effect.Effect<string, PlatformError> =>
withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(path)));
export const readTextFileEffect = (path: string): Effect.Effect<string, PlatformError, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(path));
export function readTextFile(path: string): Promise<string> {
return fileSystemRuntime.runPromise(readTextFileEffect(path));
}
export const readBinaryFileEffect = (path: string): Effect.Effect<Uint8Array, PlatformError, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFile(path));
export function readBinaryFile(path: string): Promise<Uint8Array> {
return fileSystemRuntime.runPromise(readBinaryFileEffect(path));
}
export const readBinaryFileEffect = (path: string): Effect.Effect<Uint8Array, PlatformError> =>
withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFile(path)));
export const writeTextFileEffect = (
path: string,
data: string,
): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(path, data));
export function writeTextFile(path: string, data: string): Promise<void> {
return fileSystemRuntime.runPromise(writeTextFileEffect(path, data));
}
): Effect.Effect<void, PlatformError> =>
withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(path, data)));
export const writeBinaryFileEffect = (
path: string,
data: Uint8Array,
): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFile(path, data));
): Effect.Effect<void, PlatformError> =>
withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFile(path, data)));
export function writeBinaryFile(path: string, data: Uint8Array): Promise<void> {
return fileSystemRuntime.runPromise(writeBinaryFileEffect(path, data));
}
export const removePathEffect = (path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(path));
export function removePath(path: string): Promise<void> {
return fileSystemRuntime.runPromise(removePathEffect(path));
}
export const removePathEffect = (path: string): Effect.Effect<void, PlatformError> =>
withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(path)));

View file

@ -29,7 +29,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import {
QdrantGraphEmbeddingsStoreLive,
QdrantGraphEmbeddingsStoreService,
@ -113,12 +113,10 @@ const provideQdrantGraphEmbeddingsStore = (processorId: string) =>
});
export function makeGraphEmbeddingsStoreService(config: ProcessorConfig): GraphEmbeddingsStoreService {
const service = makeFlowProcessor(config, {
return makeFlowProcessor(config, {
specifications: makeGraphEmbeddingsStoreSpecs(),
provide: provideQdrantGraphEmbeddingsStore(config.id),
});
void Effect.runPromise(Effect.log("[GraphEmbeddingsStore] Service initialized"));
return service;
}
export const GraphEmbeddingsStoreService = makeGraphEmbeddingsStoreService;
@ -133,12 +131,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => QdrantGraphEmbeddingsStoreLive(config),
});
const graphEmbeddingsStoreRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return graphEmbeddingsStoreRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -38,7 +38,7 @@ export class QdrantDocEmbeddingsStoreError extends S.TaggedErrorClass<QdrantDocE
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -85,12 +85,10 @@ const randomPointId = Effect.fn("QdrantDocEmbeddings.randomPointId")(function* (
});
export interface QdrantDocEmbeddingsStore {
readonly store: (message: DocEmbeddingsMessage) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
readonly storeEffect: (
readonly store: (
message: DocEmbeddingsMessage,
) => Effect.Effect<void, QdrantDocEmbeddingsStoreError>;
readonly deleteCollectionEffect: (
readonly deleteCollection: (
user: string,
collection: string,
) => Effect.Effect<void, QdrantDocEmbeddingsStoreError>;
@ -133,25 +131,25 @@ const makeQdrantDocEmbeddingsStoreFromClient = (
) {
if (MutableHashSet.has(knownCollections, name)) return;
const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(name),
catch: (cause) => qdrantDocEmbeddingsStoreError("collection-exists", cause),
});
const exists = yield* client.collectionExists(name).pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("collection-exists", cause)),
);
if (!exists.exists) {
yield* Effect.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`);
yield* Effect.tryPromise({
try: () =>
client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
}),
catch: (cause) => qdrantDocEmbeddingsStoreError("create-collection", cause),
});
yield* client.createCollection(
name,
{
vectors: { size: dim, distance: "Cosine" },
},
).pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("create-collection", cause)),
);
}
MutableHashSet.add(knownCollections, name);
});
const storeEffect = Effect.fn("QdrantDocEmbeddings.store")(function* (message: DocEmbeddingsMessage) {
const storeImpl = Effect.fn("QdrantDocEmbeddings.store")(function* (message: DocEmbeddingsMessage) {
for (const chunk of message.chunks) {
if (chunk.chunkId.length === 0) continue;
if (chunk.vector.length === 0) continue;
@ -162,37 +160,37 @@ const makeQdrantDocEmbeddingsStoreFromClient = (
yield* ensureCollectionEffect(name, dim);
const id = yield* randomPointId();
yield* Effect.tryPromise({
try: () =>
client.upsert(name, {
points: [
{
id,
vector: chunk.vector,
payload: {
chunk_id: chunk.chunkId,
...(chunk.content !== undefined && chunk.content.length > 0
? { content: chunk.content }
: {}),
},
yield* client.upsert(
name,
{
points: [
{
id,
vector: chunk.vector,
payload: {
chunk_id: chunk.chunkId,
...(chunk.content !== undefined && chunk.content.length > 0
? { content: chunk.content }
: {}),
},
],
}),
catch: (cause) => qdrantDocEmbeddingsStoreError("upsert", cause),
});
},
],
},
).pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("upsert", cause)),
);
}
});
const deleteCollectionEffect = Effect.fn("QdrantDocEmbeddings.deleteCollection")(function* (
const deleteCollectionImpl = Effect.fn("QdrantDocEmbeddings.deleteCollection")(function* (
user: string,
collection: string,
) {
const prefix = `d_${user}_${collection}_`;
const allCollections = yield* Effect.tryPromise({
try: () => client.getCollections(),
catch: (cause) => qdrantDocEmbeddingsStoreError("get-collections", cause),
});
const allCollections = yield* client.getCollections.pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("get-collections", cause)),
);
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
@ -203,10 +201,9 @@ const makeQdrantDocEmbeddingsStoreFromClient = (
}
for (const coll of matching) {
yield* Effect.tryPromise({
try: () => client.deleteCollection(coll.name),
catch: (cause) => qdrantDocEmbeddingsStoreError("delete-collection", cause),
});
yield* client.deleteCollection(coll.name).pipe(
Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("delete-collection", cause)),
);
MutableHashSet.remove(knownCollections, coll.name);
yield* Effect.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`);
}
@ -217,8 +214,8 @@ const makeQdrantDocEmbeddingsStoreFromClient = (
});
return {
store: storeEffect,
deleteCollection: deleteCollectionEffect,
store: storeImpl,
deleteCollection: deleteCollectionImpl,
};
};
@ -244,16 +241,9 @@ const withQdrantDocEmbeddingsStore = <A>(
export function makeQdrantDocEmbeddingsStore(
config: QdrantDocEmbeddingsConfig = {},
): QdrantDocEmbeddingsStore {
const storeEffect = (message: DocEmbeddingsMessage) =>
withQdrantDocEmbeddingsStore(config, (store) => store.store(message));
const deleteCollectionEffect = (user: string, collection: string) =>
withQdrantDocEmbeddingsStore(config, (store) => store.deleteCollection(user, collection));
return {
store: (message) => Effect.runPromise(storeEffect(message)),
store: (message) => withQdrantDocEmbeddingsStore(config, (store) => store.store(message)),
deleteCollection: (user, collection) =>
Effect.runPromise(deleteCollectionEffect(user, collection)),
storeEffect,
deleteCollectionEffect,
withQdrantDocEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)),
};
}

View file

@ -38,7 +38,7 @@ export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGr
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -96,12 +96,10 @@ function getTermValue(term: Term): string | null {
}
export interface QdrantGraphEmbeddingsStore {
readonly store: (message: GraphEmbeddingsMessage) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
readonly storeEffect: (
readonly store: (
message: GraphEmbeddingsMessage,
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
readonly deleteCollectionEffect: (
readonly deleteCollection: (
user: string,
collection: string,
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
@ -134,25 +132,25 @@ const makeQdrantGraphEmbeddingsStoreFromClient = (
) {
if (MutableHashSet.has(knownCollections, name)) return;
const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(name),
catch: (cause) => qdrantGraphEmbeddingsStoreError("collection-exists", cause),
});
const exists = yield* client.collectionExists(name).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("collection-exists", cause)),
);
if (!exists.exists) {
yield* Effect.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
yield* Effect.tryPromise({
try: () =>
client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
}),
catch: (cause) => qdrantGraphEmbeddingsStoreError("create-collection", cause),
});
yield* client.createCollection(
name,
{
vectors: { size: dim, distance: "Cosine" },
},
).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("create-collection", cause)),
);
}
MutableHashSet.add(knownCollections, name);
});
const storeEffect = Effect.fn("QdrantGraphEmbeddings.store")(function* (message: GraphEmbeddingsMessage) {
const storeImpl = Effect.fn("QdrantGraphEmbeddings.store")(function* (message: GraphEmbeddingsMessage) {
for (const entry of message.entities) {
const entityValue = getTermValue(entry.entity);
if (entityValue === null || entityValue.length === 0) continue;
@ -169,32 +167,32 @@ const makeQdrantGraphEmbeddingsStoreFromClient = (
}
const id = yield* randomPointId();
yield* Effect.tryPromise({
try: () =>
client.upsert(name, {
points: [
{
id,
vector: entry.vector,
payload,
},
],
}),
catch: (cause) => qdrantGraphEmbeddingsStoreError("upsert", cause),
});
yield* client.upsert(
name,
{
points: [
{
id,
vector: entry.vector,
payload,
},
],
},
).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("upsert", cause)),
);
}
});
const deleteCollectionEffect = Effect.fn("QdrantGraphEmbeddings.deleteCollection")(function* (
const deleteCollectionImpl = Effect.fn("QdrantGraphEmbeddings.deleteCollection")(function* (
user: string,
collection: string,
) {
const prefix = `t_${user}_${collection}_`;
const allCollections = yield* Effect.tryPromise({
try: () => client.getCollections(),
catch: (cause) => qdrantGraphEmbeddingsStoreError("get-collections", cause),
});
const allCollections = yield* client.getCollections.pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("get-collections", cause)),
);
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
@ -205,10 +203,9 @@ const makeQdrantGraphEmbeddingsStoreFromClient = (
}
for (const coll of matching) {
yield* Effect.tryPromise({
try: () => client.deleteCollection(coll.name),
catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause),
});
yield* client.deleteCollection(coll.name).pipe(
Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause)),
);
MutableHashSet.remove(knownCollections, coll.name);
yield* Effect.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
}
@ -219,8 +216,8 @@ const makeQdrantGraphEmbeddingsStoreFromClient = (
});
return {
store: storeEffect,
deleteCollection: deleteCollectionEffect,
store: storeImpl,
deleteCollection: deleteCollectionImpl,
};
};
@ -246,17 +243,10 @@ const withQdrantGraphEmbeddingsStore = <A>(
export function makeQdrantGraphEmbeddingsStore(
config: QdrantGraphEmbeddingsConfig = {},
): QdrantGraphEmbeddingsStore {
const storeEffect = (message: GraphEmbeddingsMessage) =>
withQdrantGraphEmbeddingsStore(config, (store) => store.store(message));
const deleteCollectionEffect = (user: string, collection: string) =>
withQdrantGraphEmbeddingsStore(config, (store) => store.deleteCollection(user, collection));
return {
store: (message) => Effect.runPromise(storeEffect(message)),
store: (message) => withQdrantGraphEmbeddingsStore(config, (store) => store.store(message)),
deleteCollection: (user, collection) =>
Effect.runPromise(deleteCollectionEffect(user, collection)),
storeEffect,
deleteCollectionEffect,
withQdrantGraphEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)),
};
}

View file

@ -21,7 +21,7 @@ import {
} from "@trustgraph/base";
import { NodeRuntime } from "@effect/platform-node";
import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect, Layer, ManagedRuntime } from "effect";
import { Effect } from "effect";
import {
FalkorDBTriplesStoreLive,
FalkorDBTriplesStoreService,
@ -73,12 +73,10 @@ const provideFalkorDBTriplesStore = (processorId: string) =>
});
export function makeTriplesStoreService(config: ProcessorConfig): TriplesStoreService {
const service = makeFlowProcessor(config, {
return makeFlowProcessor(config, {
specifications: makeTriplesStoreSpecs(),
provide: provideFalkorDBTriplesStore(config.id),
});
void Effect.runPromise(Effect.log("[TriplesStore] Service initialized"));
return service;
}
export const TriplesStoreService = makeTriplesStoreService;
@ -93,12 +91,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => FalkorDBTriplesStoreLive(config),
});
const triplesStoreRuntime = ManagedRuntime.make(Layer.empty);
export function run(): Promise<void> {
return triplesStoreRuntime.runPromise(program);
}
export function runMain(): void {
NodeRuntime.runMain(program);
}

View file

@ -13,8 +13,8 @@ import { Config, Context, Effect, Layer, Match } from "effect";
import * as S from "effect/Schema";
export interface FalkorDBClosableClient {
readonly connect: () => Promise<unknown>;
readonly disconnect: () => Promise<unknown>;
readonly connect: Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly disconnect: Effect.Effect<void, FalkorDBTriplesStoreError>;
}
export type FalkorDBStoreQueryOptions = Parameters<Graph["query"]>[1];
@ -23,7 +23,7 @@ export interface FalkorDBStoreGraph {
readonly query: <T = unknown>(
query: string,
options?: FalkorDBStoreQueryOptions,
) => Promise<{ readonly data?: Array<T> }>;
) => Effect.Effect<{ readonly data?: Array<T> }, FalkorDBTriplesStoreError>;
}
export type FalkorDBStoreClientFactory = (url: string) => FalkorDBClosableClient;
@ -51,28 +51,39 @@ function getTermValue(term: Term): string {
}
export interface FalkorDBTriplesStore {
readonly createNode: (uri: string, user: string, collection: string) => Promise<void>;
readonly createLiteral: (value: string, user: string, collection: string) => Promise<void>;
readonly createNode: (
uri: string,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly createLiteral: (
value: string,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly relateNode: (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) => Promise<void>;
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly relateLiteral: (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) => Promise<void>;
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly storeTriples: (
triples: Triple[],
user?: string,
collection?: string,
) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly deleteCollection: (
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
}
export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()(
@ -80,7 +91,7 @@ export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriple
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
cause: S.Defect({ includeStack: true }),
},
) {}
@ -115,6 +126,12 @@ interface FalkorDBStoreConnection {
readonly graph: FalkorDBStoreGraph;
}
const tryFalkorDBPromise = <A>(operation: string, try_: () => PromiseLike<A>) =>
Effect.tryPromise({
try: try_,
catch: (cause) => falkorDBTriplesStoreError(operation, cause),
});
interface FalkorDBTriplesStoreEffectShape {
readonly createNode: (
uri: string,
@ -187,16 +204,21 @@ const connectFalkorDBTriplesStore = Effect.fn("FalkorDBTriplesStore.connect")(fu
const client = clientFactory(url);
return { client, graph: graphFactory(client, database) };
}
const client = createClient({ url });
return { client, graph: new Graph(client, database) };
const sdkClient = createClient({ url });
const client: FalkorDBClosableClient = {
connect: tryFalkorDBPromise("connect", () => sdkClient.connect()).pipe(Effect.asVoid),
disconnect: tryFalkorDBPromise("disconnect", () => sdkClient.disconnect()).pipe(Effect.asVoid),
};
const sdkGraph = new Graph(sdkClient, database);
const graph: FalkorDBStoreGraph = {
query: (query, options) => tryFalkorDBPromise("graph-query", () => sdkGraph.query(query, options)),
};
return { client, graph };
},
catch: (cause) => falkorDBTriplesStoreError("create-client", cause),
});
yield* Effect.tryPromise({
try: () => client.connect(),
catch: (cause) => falkorDBTriplesStoreError("connect", cause),
}).pipe(
yield* client.connect.pipe(
Effect.tapError((error) =>
Effect.logError("[FalkorDBTriplesStore] Connection failed", {
error: error.message,
@ -212,10 +234,7 @@ const connectFalkorDBTriplesStore = Effect.fn("FalkorDBTriplesStore.connect")(fu
const disconnectFalkorDBTriplesStore = (
connection: FalkorDBStoreConnection,
): Effect.Effect<void> =>
Effect.tryPromise({
try: () => connection.client.disconnect(),
catch: (cause) => falkorDBTriplesStoreError("disconnect", cause),
}).pipe(
connection.client.disconnect.pipe(
Effect.catch((error) =>
Effect.logError("[FalkorDBTriplesStore] Disconnect failed", {
error: error.message,
@ -239,10 +258,8 @@ const runGraphQuery = (
query: string,
options?: FalkorDBStoreQueryOptions,
): Effect.Effect<void, FalkorDBTriplesStoreError> =>
Effect.tryPromise({
try: () => graph.query(query, options),
catch: (cause) => falkorDBTriplesStoreError(operation, cause),
}).pipe(
graph.query(query, options).pipe(
Effect.mapError((cause) => falkorDBTriplesStoreError(operation, cause)),
Effect.asVoid,
);
@ -390,17 +407,17 @@ const withFalkorDBTriplesStore = <A>(
export function makeFalkorDBTriplesStore(config: FalkorDBConfig = {}): FalkorDBTriplesStore {
return {
createNode: (uri, user, collection) =>
Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.createNode(uri, user, collection))),
withFalkorDBTriplesStore(config, (store) => store.createNode(uri, user, collection)),
createLiteral: (value, user, collection) =>
Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.createLiteral(value, user, collection))),
withFalkorDBTriplesStore(config, (store) => store.createLiteral(value, user, collection)),
relateNode: (src, uri, dest, user, collection) =>
Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.relateNode(src, uri, dest, user, collection))),
withFalkorDBTriplesStore(config, (store) => store.relateNode(src, uri, dest, user, collection)),
relateLiteral: (src, uri, dest, user, collection) =>
Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.relateLiteral(src, uri, dest, user, collection))),
withFalkorDBTriplesStore(config, (store) => store.relateLiteral(src, uri, dest, user, collection)),
storeTriples: (triples, user = "default", collection = "default") =>
Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.storeTriples(triples, user, collection))),
withFalkorDBTriplesStore(config, (store) => store.storeTriples(triples, user, collection)),
deleteCollection: (user, collection) =>
Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.deleteCollection(user, collection))),
withFalkorDBTriplesStore(config, (store) => store.deleteCollection(user, collection)),
};
}

View file

@ -13,23 +13,21 @@
"dependencies": {
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"@effect/platform-node": "4.0.0-beta.75",
"@effect/platform-node-shared": "4.0.0-beta.75",
"@effect/ai-anthropic": "4.0.0-beta.75",
"@effect/ai-openai": "4.0.0-beta.75",
"@effect/ai-openrouter": "4.0.0-beta.75",
"@effect/atom-react": "4.0.0-beta.75",
"@effect/openapi-generator": "4.0.0-beta.75",
"@effect/opentelemetry": "4.0.0-beta.75",
"@effect/platform-browser": "4.0.0-beta.75",
"@effect/platform-bun": "4.0.0-beta.75",
"@effect/tsgo": "0.13.0",
"@effect/vitest": "4.0.0-beta.75",
"@modelcontextprotocol/sdk": "^1.8.0",
"zod": "^3.23.0"
"@effect/platform-node": "4.0.0-beta.78",
"@effect/platform-node-shared": "4.0.0-beta.78",
"@effect/ai-anthropic": "4.0.0-beta.78",
"@effect/ai-openai": "4.0.0-beta.78",
"@effect/ai-openrouter": "4.0.0-beta.78",
"@effect/atom-react": "4.0.0-beta.78",
"@effect/openapi-generator": "4.0.0-beta.78",
"@effect/opentelemetry": "4.0.0-beta.78",
"@effect/platform-browser": "4.0.0-beta.78",
"@effect/platform-bun": "4.0.0-beta.78",
"@effect/tsgo": "0.14.0",
"@effect/vitest": "4.0.0-beta.78"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.75",
"@effect/vitest": "4.0.0-beta.78",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6"

View file

@ -1,22 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import * as Predicate from "effect/Predicate";
import { createMcpServer } from "../server.js";
import { describe, expect, it } from "@effect/vitest";
import type { BaseApi } from "@trustgraph/client";
import { Effect, Layer, Stream } from "effect";
import * as S from "effect/Schema";
import { LanguageModel, McpServer } from "effect/unstable/ai";
import * as McpSchema from "effect/unstable/ai/McpSchema";
import { FetchHttpClient, HttpRouter } from "effect/unstable/http";
import { RpcSerialization } from "effect/unstable/rpc";
import * as RpcClient from "effect/unstable/rpc/RpcClient";
import {
makeTrustGraphMcpStdioLayer,
runStdio,
TrustGraphMcpConfig,
TrustGraphMcpToolkit,
TrustGraphMcpToolkitLive,
TrustGraphSocket,
} from "../server-effect.js";
const clientMock = vi.hoisted(() => ({
createTrustGraphSocket: vi.fn(() => ({
close: vi.fn(),
})),
}));
vi.mock("@trustgraph/client", () => ({
createTrustGraphSocket: clientMock.createTrustGraphSocket,
}));
const expectedToolNames = [
"text_completion",
"graph_rag",
@ -43,41 +42,290 @@ const expectedToolNames = [
"load_kg_core",
];
const registeredToolNames = (value: unknown): Array<string> => {
if (!Predicate.isObject(value) || !Predicate.hasProperty(value, "_registeredTools")) {
return [];
}
return Predicate.isObject(value._registeredTools)
? Object.keys(value._registeredTools)
: [];
interface FakeSocketCalls {
readonly flowIds: Array<string>;
readonly graphRag: Array<{
readonly query: string;
readonly options: unknown;
readonly collection: string | undefined;
}>;
}
interface NativeTestClientOptions {
readonly languageText?: string | undefined;
readonly graphRag?: (() => Promise<string>) | undefined;
}
const decodeJsonText = S.decodeUnknownSync(S.UnknownFromJsonString);
const makeFakeSocket = (
options: {
readonly graphRag?: (() => Promise<string>) | undefined;
} = {},
) => {
const calls: FakeSocketCalls = {
flowIds: [],
graphRag: [],
};
const socket = {
close: () => {},
flow: (flowId: string) => {
calls.flowIds.push(flowId);
return {
textCompletion: () => Promise.resolve("legacy text completion should not be used"),
graphRag: (query: string, ragOptions: unknown, collection?: string) => {
calls.graphRag.push({ query, options: ragOptions, collection });
return options.graphRag === undefined
? Promise.resolve("graph rag answer")
: options.graphRag();
},
documentRag: () => Promise.resolve("document rag answer"),
agent: (
_question: string,
_onThought: () => void,
_onObservation: () => void,
onAnswer: (chunk: string, complete: boolean) => void,
) => onAnswer("agent answer", true),
embeddings: () => Promise.resolve([[0.25, 0.75]]),
triplesQuery: () => Promise.resolve([]),
graphEmbeddingsQuery: () => Promise.resolve([]),
};
},
config: () => ({
getConfigAll: () => Promise.resolve({}),
getConfig: () => Promise.resolve({}),
putConfig: () => Promise.resolve({ ok: true }),
deleteConfig: () => Promise.resolve({ ok: true }),
getPrompts: () => Promise.resolve([]),
getPrompt: () => Promise.resolve({}),
}),
flows: () => ({
getFlows: () => Promise.resolve(["default"]),
getFlow: () => Promise.resolve({}),
startFlow: () => Promise.resolve({ ok: true }),
stopFlow: () => Promise.resolve({ ok: true }),
}),
librarian: () => ({
getDocuments: () => Promise.resolve([]),
loadDocument: () => Promise.resolve({ ok: true }),
removeDocument: () => Promise.resolve({ ok: true }),
}),
knowledge: () => ({
getKnowledgeCores: () => Promise.resolve([]),
deleteKgCore: () => Promise.resolve({ ok: true }),
loadKgCore: () => Promise.resolve({ ok: true }),
}),
} as unknown as BaseApi;
return { socket, calls };
};
const makeLanguageModelLayer = (text: string) =>
Layer.effect(
LanguageModel.LanguageModel,
LanguageModel.make({
generateText: () => Effect.succeed([{ type: "text", text }]),
streamText: () => Stream.empty,
}),
);
const testConfig = TrustGraphMcpConfig.of({
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
user: "mcp-test",
token: undefined,
flowId: "default",
name: "trustgraph",
version: "0.1.0-test",
mcpPath: "/mcp",
openAiModel: "test-model",
openAiApiKey: undefined,
port: 3000,
});
const makeNativeTestClient = (
options: NativeTestClientOptions = {},
) =>
makeNativeTestClientEffect(options);
const makeNativeTestClientEffect = Effect.fn("makeNativeTestClient")(function*(
options: NativeTestClientOptions,
) {
const { socket, calls } = makeFakeSocket({ graphRag: options.graphRag });
const serverLayer = McpServer.toolkit(TrustGraphMcpToolkit).pipe(
Layer.provide(TrustGraphMcpToolkitLive),
Layer.provide(makeLanguageModelLayer(options.languageText ?? "direct ai answer")),
Layer.provide(Layer.succeed(TrustGraphSocket, TrustGraphSocket.of(socket))),
Layer.provide(Layer.succeed(TrustGraphMcpConfig, testConfig)),
Layer.provide(McpServer.layerHttp({
name: "trustgraph",
version: "0.1.0-test",
path: "/mcp",
})),
);
const { handler, dispose } = HttpRouter.toWebHandler(serverLayer, { disableLogger: true });
yield* Effect.addFinalizer(() => Effect.promise(() => dispose()));
let sessionId: string | null = null;
const customFetch = Object.assign(
(input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
if (sessionId !== null) {
request.headers.set("Mcp-Session-Id", sessionId);
}
return handler(request).then((response) => {
sessionId = response.headers.get("Mcp-Session-Id");
return response;
});
},
{ preconnect: fetch.preconnect },
) as typeof fetch;
const clientLayer = RpcClient.layerProtocolHttp({ url: "http://localhost/mcp" }).pipe(
Layer.provideMerge([FetchHttpClient.layer, RpcSerialization.layerJsonRpc()]),
Layer.provide(Layer.succeed(FetchHttpClient.Fetch, customFetch)),
);
const client = yield* RpcClient.make(McpSchema.ClientRpcs).pipe(
// @effect-diagnostics-next-line strictEffectProvide:off
Effect.provide(clientLayer),
);
yield* client.initialize({
protocolVersion: "9999-01-01",
capabilities: {},
clientInfo: {
name: "trustgraph-mcp-test-client",
version: "0.1.0-test",
},
});
return { client, calls };
});
const textContent = (result: McpSchema.CallToolResult): string => {
const [content] = result.content;
expect(content?.type).toBe("text");
return "text" in content! ? content.text : "";
};
describe("Effect MCP server", () => {
it("keeps the canonical Effect toolkit names stable", () => {
expect(Object.keys(TrustGraphMcpToolkit.tools)).toEqual(expectedToolNames);
});
it.effect(
"keeps the canonical Effect toolkit names stable",
Effect.fnUntraced(function*() {
expect(Object.keys(TrustGraphMcpToolkit.tools)).toEqual(expectedToolNames);
}),
);
it("keeps legacy SDK stdio tools aligned with the Effect toolkit", () => {
const { server, socket } = createMcpServer({
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
user: "mcp-test",
flowId: "default",
});
it.effect(
"exposes an Effect stdio layer and process entrypoint",
Effect.fnUntraced(function*() {
expect(
makeTrustGraphMcpStdioLayer({
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
user: "mcp-test",
flowId: "default",
openAiApiKey: "test-key",
}),
).toBeDefined();
expect(registeredToolNames(server)).toEqual(Object.keys(TrustGraphMcpToolkit.tools));
expect(socket.close).toEqual(expect.any(Function));
});
expect(runStdio).toEqual(expect.any(Function));
}),
);
it("exposes an Effect stdio layer and process entrypoint", () => {
expect(
makeTrustGraphMcpStdioLayer({
gatewayUrl: "ws://localhost:8088/api/v1/rpc",
user: "mcp-test",
flowId: "default",
openAiApiKey: "test-key",
}),
).toBeDefined();
it.effect(
"lists native MCP tools through the protocol bridge",
Effect.fnUntraced(function*() {
yield* Effect.scoped(Effect.gen(function*() {
const { client } = yield* makeNativeTestClient();
expect(runStdio).toEqual(expect.any(Function));
});
const result = yield* client["tools/list"]({});
expect(result.tools.map((tool) => tool.name)).toEqual(expectedToolNames);
expect(result.tools.find((tool) => tool.name === "graph_rag")?.annotations).toMatchObject({
title: "Graph RAG",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: true,
});
}));
}),
);
it.effect(
"calls text_completion through the direct Effect language model",
Effect.fnUntraced(function*() {
yield* Effect.scoped(Effect.gen(function*() {
const { client, calls } = yield* makeNativeTestClient({
languageText: "direct model response",
});
const result = yield* client["tools/call"]({
name: "text_completion",
arguments: {
system: "You are concise.",
prompt: "Say hello.",
},
});
expect(result.isError).toBe(false);
expect(result.structuredContent).toEqual({ text: "direct model response" });
expect(decodeJsonText(textContent(result))).toEqual({ text: "direct model response" });
expect(calls.flowIds).toEqual([]);
}));
}),
);
it.effect(
"calls gateway-backed tools through the native MCP bridge",
Effect.fnUntraced(function*() {
yield* Effect.scoped(Effect.gen(function*() {
const { client, calls } = yield* makeNativeTestClient();
const result = yield* client["tools/call"]({
name: "graph_rag",
arguments: {
query: "Who knows Alice?",
entity_limit: 4,
triple_limit: 8,
collection: "qa",
},
});
expect(result.isError).toBe(false);
expect(result.structuredContent).toEqual({ text: "graph rag answer" });
expect(calls.graphRag).toEqual([
{
query: "Who knows Alice?",
options: { entityLimit: 4, tripleLimit: 8 },
collection: "qa",
},
]);
}));
}),
);
it.effect(
"returns JSON-safe structured failures for expected tool errors",
Effect.fnUntraced(function*() {
yield* Effect.scoped(Effect.gen(function*() {
const { client } = yield* makeNativeTestClient({
graphRag: () => Promise.reject(new Error("gateway unavailable")),
});
const result = yield* client["tools/call"]({
name: "graph_rag",
arguments: {
query: "Will this fail?",
},
});
expect(result.structuredContent).toEqual({
_tag: "GraphRagError",
message: "gateway unavailable",
});
expect(result.structuredContent).not.toHaveProperty("cause");
expect(decodeJsonText(textContent(result))).toEqual(result.structuredContent);
}));
}),
);
});

View file

@ -1,2 +1,2 @@
export { createMcpServer, run } from "./server.js";
export { runStdio as run } from "./server-effect.js";
export * from "./server-effect.js";

View file

@ -113,10 +113,6 @@ const TrustGraphJsonPayload = S.Json.annotateKey({
description: "JSON-safe payload returned by the TrustGraph gateway",
})
const ToolErrorCause = S.DefectWithStack.annotateKey({
description: "Original exception, schema decoding failure, or gateway error that caused the tool call to fail",
})
const ToolErrorMessage = S.String.annotateKey({
description: "Concise human-readable error message suitable for explaining the failure to a user",
})
@ -141,7 +137,6 @@ export class TextCompletionSuccess extends S.Class<TextCompletionSuccess>("TextC
export class TextCompletionError extends S.TaggedErrorClass<TextCompletionError>()(
"TextCompletionError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -165,6 +160,7 @@ export const TextCompletionTool = annotateTool(
parameters: TextCompletionParameters,
success: TextCompletionSuccess,
failure: TextCompletionError,
failureMode: "return",
}),
{
title: "Text Completion",
@ -185,7 +181,6 @@ export class GraphRagSuccess extends S.Class<GraphRagSuccess>("GraphRagSuccess")
export class GraphRagError extends S.TaggedErrorClass<GraphRagError>()(
"GraphRagError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -215,6 +210,7 @@ export const GraphRagTool = annotateTool(
parameters: GraphRagParameters,
success: GraphRagSuccess,
failure: GraphRagError,
failureMode: "return",
}),
{
title: "Graph RAG",
@ -235,7 +231,6 @@ export class DocumentRagSuccess extends S.Class<DocumentRagSuccess>("DocumentRag
export class DocumentRagError extends S.TaggedErrorClass<DocumentRagError>()(
"DocumentRagError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -263,6 +258,7 @@ export const DocumentRagTool = annotateTool(
parameters: DocumentRagParameters,
success: DocumentRagSuccess,
failure: DocumentRagError,
failureMode: "return",
}),
{
title: "Document RAG",
@ -283,7 +279,6 @@ export class AgentSuccess extends S.Class<AgentSuccess>("AgentSuccess")(
export class AgentError extends S.TaggedErrorClass<AgentError>()(
"AgentError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -303,6 +298,7 @@ export const AgentTool = annotateTool(
parameters: AgentParameters,
success: AgentSuccess,
failure: AgentError,
failureMode: "return",
description: "Ask the TrustGraph agent a question"
}),
{
@ -326,7 +322,6 @@ export class EmbeddingsSuccess extends S.Class<EmbeddingsSuccess>("EmbeddingsSuc
export class EmbeddingsError extends S.TaggedErrorClass<EmbeddingsError>()(
"EmbeddingsError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -346,6 +341,7 @@ export const EmbeddingsTool = annotateTool(
parameters: EmbeddingsParameters,
success: EmbeddingsSuccess,
failure: EmbeddingsError,
failureMode: "return",
description: "Generate text embeddings"
}),
{
@ -369,7 +365,6 @@ export class TriplesQuerySuccess extends S.Class<TriplesQuerySuccess>("TriplesQu
export class TriplesQueryError extends S.TaggedErrorClass<TriplesQueryError>()(
"TriplesQueryError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -402,6 +397,7 @@ export const TriplesQueryTool = annotateTool(
parameters: TriplesQueryParameters,
success: TriplesQuerySuccess,
failure: TriplesQueryError,
failureMode: "return",
description: "Query the knowledge graph for triples matching a pattern"
}),
{
@ -434,7 +430,6 @@ export class GraphEmbeddingsQuerySuccess extends S.Class<GraphEmbeddingsQuerySuc
export class GraphEmbeddingsQueryError extends S.TaggedErrorClass<GraphEmbeddingsQueryError>()(
"GraphEmbeddingsQueryError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -461,6 +456,7 @@ export const GraphEmbeddingsQueryTool = annotateTool(
parameters: GraphEmbeddingsQueryParameters,
success: GraphEmbeddingsQuerySuccess,
failure: GraphEmbeddingsQueryError,
failureMode: "return",
description: "Find entities similar to a text query using vector embeddings"
}),
{
@ -482,7 +478,6 @@ export class GetConfigAllSuccess extends S.Class<GetConfigAllSuccess>("GetConfig
export class GetConfigAllError extends S.TaggedErrorClass<GetConfigAllError>()(
"GetConfigAllError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -498,6 +493,7 @@ export const GetConfigAllTool = annotateTool(
parameters: GetConfigAllParameters,
success: GetConfigAllSuccess,
failure: GetConfigAllError,
failureMode: "return",
description: "Get all configuration values"
}),
{
@ -520,7 +516,6 @@ export class GetConfigSuccess extends S.Class<GetConfigSuccess>("GetConfigSucces
export class GetConfigError extends S.TaggedErrorClass<GetConfigError>()(
"GetConfigError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -549,6 +544,7 @@ export const GetConfigTool = annotateTool(
parameters: GetConfigParameters,
success: GetConfigSuccess,
failure: GetConfigError,
failureMode: "return",
description: "Get specific configuration values"
}),
{
@ -570,7 +566,6 @@ export class PutConfigSuccess extends S.Class<PutConfigSuccess>("PutConfigSucces
export class PutConfigError extends S.TaggedErrorClass<PutConfigError>()(
"PutConfigError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -602,6 +597,7 @@ export const PutConfigTool = annotateTool(
parameters: PutConfigParameters,
success: PutConfigSuccess,
failure: PutConfigError,
failureMode: "return",
description: "Set configuration values"
}),
{
@ -623,7 +619,6 @@ export class DeleteConfigSuccess extends S.Class<DeleteConfigSuccess>("DeleteCon
export class DeleteConfigError extends S.TaggedErrorClass<DeleteConfigError>()(
"DeleteConfigError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -646,6 +641,7 @@ export const DeleteConfigTool = annotateTool(
parameters: DeleteConfigParameters,
success: DeleteConfigSuccess,
failure: DeleteConfigError,
failureMode: "return",
description: "Delete a configuration entry"
}),
{
@ -667,7 +663,6 @@ export class GetFlowSuccess extends S.Class<GetFlowSuccess>("GetFlowSuccess")(
export class GetFlowError extends S.TaggedErrorClass<GetFlowError>()(
"GetFlowError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -687,6 +682,7 @@ export const GetFlowTool = annotateTool(
parameters: GetFlowParameters,
success: GetFlowSuccess,
failure: GetFlowError,
failureMode: "return",
description: "Get a specific flow definition"
}),
{
@ -710,7 +706,6 @@ export class GetFlowsSuccess extends S.Class<GetFlowsSuccess>("GetFlowsSuccess")
export class GetFlowsError extends S.TaggedErrorClass<GetFlowsError>()(
"GetFlowsError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -726,6 +721,7 @@ export const GetFlowsTool = annotateTool(
parameters: GetFlowsParameters,
success: GetFlowsSuccess,
failure: GetFlowsError,
failureMode: "return",
description: "List all available flows"
}),
{
@ -747,7 +743,6 @@ export class StartFlowSuccess extends S.Class<StartFlowSuccess>("StartFlowSucces
export class StartFlowError extends S.TaggedErrorClass<StartFlowError>()(
"StartFlowError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -776,6 +771,7 @@ export const StartFlowTool = annotateTool(
parameters: StartFlowParameters,
success: StartFlowSuccess,
failure: StartFlowError,
failureMode: "return",
description: "Start a flow instance"
}),
{
@ -797,7 +793,6 @@ export class StopFlowSuccess extends S.Class<StopFlowSuccess>("StopFlowSuccess")
export class StopFlowError extends S.TaggedErrorClass<StopFlowError>()(
"StopFlowError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -817,6 +812,7 @@ export const StopFlowTool = annotateTool(
parameters: StopFlowParameters,
success: StopFlowSuccess,
failure: StopFlowError,
failureMode: "return",
description: "Stop a running flow"
}),
{
@ -840,7 +836,6 @@ export class GetDocumentsSuccess extends S.Class<GetDocumentsSuccess>("GetDocume
export class GetDocumentsError extends S.TaggedErrorClass<GetDocumentsError>()(
"GetDocumentsError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -856,6 +851,7 @@ export const GetDocumentsTool = annotateTool(
parameters: GetDocumentsParameters,
success: GetDocumentsSuccess,
failure: GetDocumentsError,
failureMode: "return",
description: "List all documents in the library"
}),
{
@ -877,7 +873,6 @@ export class LoadDocumentSuccess extends S.Class<LoadDocumentSuccess>("LoadDocum
export class LoadDocumentError extends S.TaggedErrorClass<LoadDocumentError>()(
"LoadDocumentError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -912,6 +907,7 @@ export const LoadDocumentTool = annotateTool(
parameters: LoadDocumentParameters,
success: LoadDocumentSuccess,
failure: LoadDocumentError,
failureMode: "return",
description: "Upload a document to the library"
}),
{
@ -933,7 +929,6 @@ export class RemoveDocumentSuccess extends S.Class<RemoveDocumentSuccess>("Remov
export class RemoveDocumentError extends S.TaggedErrorClass<RemoveDocumentError>()(
"RemoveDocumentError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -956,6 +951,7 @@ export const RemoveDocumentTool = annotateTool(
parameters: RemoveDocumentParameters,
success: RemoveDocumentSuccess,
failure: RemoveDocumentError,
failureMode: "return",
description: "Remove a document from the library"
}),
{
@ -979,7 +975,6 @@ export class GetPromptsSuccess extends S.Class<GetPromptsSuccess>("GetPromptsSuc
export class GetPromptsError extends S.TaggedErrorClass<GetPromptsError>()(
"GetPromptsError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -995,6 +990,7 @@ export const GetPromptsTool = annotateTool(
parameters: GetPromptsParameters,
success: GetPromptsSuccess,
failure: GetPromptsError,
failureMode: "return",
description: "List available prompt templates"
}),
{
@ -1016,7 +1012,6 @@ export class GetPromptSuccess extends S.Class<GetPromptSuccess>("GetPromptSucces
export class GetPromptError extends S.TaggedErrorClass<GetPromptError>()(
"GetPromptError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -1036,6 +1031,7 @@ export const GetPromptTool = annotateTool(
parameters: GetPromptParameters,
success: GetPromptSuccess,
failure: GetPromptError,
failureMode: "return",
description: "Get a specific prompt template"
}),
{
@ -1059,7 +1055,6 @@ export class GetKnowledgeCoresSuccess extends S.Class<GetKnowledgeCoresSuccess>(
export class GetKnowledgeCoresError extends S.TaggedErrorClass<GetKnowledgeCoresError>()(
"GetKnowledgeCoresError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -1075,6 +1070,7 @@ export const GetKnowledgeCoresTool = annotateTool(
parameters: GetKnowledgeCoresParameters,
success: GetKnowledgeCoresSuccess,
failure: GetKnowledgeCoresError,
failureMode: "return",
description: "List available knowledge graph cores"
}),
{
@ -1096,7 +1092,6 @@ export class DeleteKgCoreSuccess extends S.Class<DeleteKgCoreSuccess>("DeleteKgC
export class DeleteKgCoreError extends S.TaggedErrorClass<DeleteKgCoreError>()(
"DeleteKgCoreError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -1119,6 +1114,7 @@ export const DeleteKgCoreTool = annotateTool(
parameters: DeleteKgCoreParameters,
success: DeleteKgCoreSuccess,
failure: DeleteKgCoreError,
failureMode: "return",
description: "Delete a knowledge graph core"
}),
{
@ -1140,7 +1136,6 @@ export class LoadKgCoreSuccess extends S.Class<LoadKgCoreSuccess>("LoadKgCoreSuc
export class LoadKgCoreError extends S.TaggedErrorClass<LoadKgCoreError>()(
"LoadKgCoreError",
{
cause: ToolErrorCause,
message: ToolErrorMessage,
}
) {
@ -1166,6 +1161,7 @@ export const LoadKgCoreTool = annotateTool(
parameters: LoadKgCoreParameters,
success: LoadKgCoreSuccess,
failure: LoadKgCoreError,
failureMode: "return",
description: "Load a knowledge graph core"
}),
{
@ -1384,7 +1380,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
},
collection,
),
catch: (cause) => GraphRagError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GraphRagError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((text) => GraphRagSuccess.make({text})),
),
@ -1392,7 +1388,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
document_rag: ({query, doc_limit, collection}) =>
Effect.tryPromise({
try: () => socket.flow(config.flowId).documentRag(query, doc_limit, collection),
catch: (cause) => DocumentRagError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => DocumentRagError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((text) => DocumentRagSuccess.make({text})),
),
@ -1410,14 +1406,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
resume(Effect.succeed(AgentSuccess.make({text: fullAnswer})))
}
},
(cause) => resume(Effect.fail(AgentError.make({cause, message: toErrorMessage(cause)}))),
(cause) => resume(Effect.fail(AgentError.make({message: toErrorMessage(cause)}))),
)
}),
embeddings: ({text}) =>
Effect.tryPromise({
try: () => socket.flow(config.flowId).embeddings([...text]),
catch: (cause) => EmbeddingsError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => EmbeddingsError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((vectors) => EmbeddingsSuccess.make({vectors})),
),
@ -1432,7 +1428,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
limit,
collection,
),
catch: (cause) => TriplesQueryError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => TriplesQueryError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((triples) => TriplesQuerySuccess.make({triples})),
),
@ -1440,7 +1436,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
graph_embeddings_query: ({query, limit, collection}) =>
Effect.tryPromise({
try: () => socket.flow(config.flowId).embeddings([query]),
catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GraphEmbeddingsQueryError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((vectors) =>
Effect.tryPromise({
@ -1449,7 +1445,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
limit ?? 10,
collection,
),
catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GraphEmbeddingsQueryError.make({message: toErrorMessage(cause)}),
})
),
Effect.map((entities) => GraphEmbeddingsQuerySuccess.make({entities})),
@ -1458,12 +1454,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_config_all: () =>
Effect.tryPromise({
try: () => socket.config().getConfigAll(),
catch: (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetConfigAllError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}),
(cause) => GetConfigAllError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((config) => GetConfigAllSuccess.make({config})),
)
@ -1473,12 +1469,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_config: ({keys}) =>
Effect.tryPromise({
try: () => socket.config().getConfig(keys.map(({type, key}) => ({type, key}))),
catch: (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetConfigError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}),
(cause) => GetConfigError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((config) => GetConfigSuccess.make({config})),
)
@ -1488,12 +1484,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
put_config: ({values}) =>
Effect.tryPromise({
try: () => socket.config().putConfig(values.map(({type, key, value}) => ({type, key, value}))),
catch: (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => PutConfigError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}),
(cause) => PutConfigError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => PutConfigSuccess.make({response})),
)
@ -1503,12 +1499,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
delete_config: ({type, key}) =>
Effect.tryPromise({
try: () => socket.config().deleteConfig({type, key}),
catch: (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => DeleteConfigError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}),
(cause) => DeleteConfigError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => DeleteConfigSuccess.make({response})),
)
@ -1518,7 +1514,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_flows: () =>
Effect.tryPromise({
try: () => socket.flows().getFlows(),
catch: (cause) => GetFlowsError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetFlowsError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((flow_ids) => GetFlowsSuccess.make({flow_ids})),
),
@ -1526,12 +1522,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_flow: ({flow_id}) =>
Effect.tryPromise({
try: () => socket.flows().getFlow(flow_id),
catch: (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetFlowError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}),
(cause) => GetFlowError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((flow) => GetFlowSuccess.make({flow})),
)
@ -1547,12 +1543,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
description,
parameters === undefined ? undefined : {...parameters},
),
catch: (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => StartFlowError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}),
(cause) => StartFlowError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => StartFlowSuccess.make({response})),
)
@ -1562,12 +1558,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
stop_flow: ({flow_id}) =>
Effect.tryPromise({
try: () => socket.flows().stopFlow(flow_id),
catch: (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => StopFlowError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}),
(cause) => StopFlowError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => StopFlowSuccess.make({response})),
)
@ -1577,12 +1573,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_documents: () =>
Effect.tryPromise({
try: () => socket.librarian().getDocuments(),
catch: (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetDocumentsError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonArrayOrFail(
value,
(cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}),
(cause) => GetDocumentsError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((documents) => GetDocumentsSuccess.make({documents})),
)
@ -1600,12 +1596,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
tags === undefined ? [] : [...tags],
id,
),
catch: (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => LoadDocumentError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}),
(cause) => LoadDocumentError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => LoadDocumentSuccess.make({response})),
)
@ -1615,12 +1611,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
remove_document: ({id, collection}) =>
Effect.tryPromise({
try: () => socket.librarian().removeDocument(id, collection),
catch: (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => RemoveDocumentError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}),
(cause) => RemoveDocumentError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => RemoveDocumentSuccess.make({response})),
)
@ -1630,7 +1626,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_prompts: () =>
Effect.tryPromise({
try: () => socket.config().getPrompts(),
catch: (cause) => GetPromptsError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetPromptsError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((prompts) => GetPromptsSuccess.make({prompts})),
),
@ -1638,12 +1634,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_prompt: ({id}) =>
Effect.tryPromise({
try: () => socket.config().getPrompt(id),
catch: (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetPromptError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}),
(cause) => GetPromptError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((prompt) => GetPromptSuccess.make({prompt})),
)
@ -1653,7 +1649,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
get_knowledge_cores: () =>
Effect.tryPromise({
try: () => socket.knowledge().getKnowledgeCores(),
catch: (cause) => GetKnowledgeCoresError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => GetKnowledgeCoresError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.map((ids) => GetKnowledgeCoresSuccess.make({ids})),
),
@ -1661,12 +1657,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
delete_kg_core: ({id, collection}) =>
Effect.tryPromise({
try: () => socket.knowledge().deleteKgCore(id, collection),
catch: (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => DeleteKgCoreError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}),
(cause) => DeleteKgCoreError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => DeleteKgCoreSuccess.make({response})),
)
@ -1676,12 +1672,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer(
load_kg_core: ({id, flow, collection}) =>
Effect.tryPromise({
try: () => socket.knowledge().loadKgCore(id, flow, collection),
catch: (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}),
catch: (cause) => LoadKgCoreError.make({message: toErrorMessage(cause)}),
}).pipe(
Effect.flatMap((value) =>
decodeJsonOrFail(
value,
(cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}),
(cause) => LoadKgCoreError.make({message: toErrorMessage(cause)}),
).pipe(
Effect.map((response) => LoadKgCoreSuccess.make({response})),
)

View file

@ -1,442 +0,0 @@
/**
* TrustGraph MCP stdio compatibility server.
*
* 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 {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 * as 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;
user?: string;
token?: string;
flowId?: string;
}) {
const server = new McpServer({
name: "trustgraph",
version: "0.1.0",
});
const user = config.user ?? "mcp";
const socket: BaseApi = createTrustGraphSocket(
user,
config.token,
config.gatewayUrl,
);
const flowId = config.flowId ?? "default";
// ===================== Flow-scoped tools =====================
server.tool(
"text_completion",
"Run a text completion using the configured LLM",
{
system: z.string().describe("System prompt"),
prompt: z.string().describe("User prompt"),
},
({system, prompt}) =>
runTextTool(gatewayRequest(() => socket.flow(flowId).textCompletion(system, prompt))),
);
server.tool(
"graph_rag",
"Query the knowledge graph using RAG",
{
query: z.string().describe("Natural language query"),
entity_limit: z.number().optional().describe("Max entities to retrieve"),
triple_limit: z.number().optional().describe("Max triples per entity"),
collection: z.string().optional().describe("Collection name"),
},
({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,
)
),
),
);
server.tool(
"document_rag",
"Query documents using RAG",
{
query: z.string().describe("Natural language query"),
doc_limit: z.number().optional().describe("Max documents to retrieve"),
collection: z.string().optional().describe("Collection name"),
},
({query, doc_limit, collection}) =>
runTextTool(gatewayRequest(() => socket.flow(flowId).documentRag(query, doc_limit, collection))),
);
server.tool(
"agent",
"Ask the TrustGraph agent a question",
{
question: z.string().describe("Question for the agent"),
},
({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))),
);
}),
),
);
server.tool(
"embeddings",
"Generate text embeddings",
{
text: z.array(z.string()).describe("Texts to embed"),
},
({text}) => runJsonTool(gatewayRequest(() => socket.flow(flowId).embeddings(text))),
);
server.tool(
"triples_query",
"Query the knowledge graph for triples matching a pattern",
{
s: z.string().optional().describe("Subject IRI"),
p: z.string().optional().describe("Predicate IRI"),
o: z.string().optional().describe("Object IRI or literal"),
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
({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)),
);
},
);
server.tool(
"graph_embeddings_query",
"Find entities similar to a text query using vector embeddings",
{
query: z.string().describe("Text to find similar entities for"),
limit: z.number().optional().describe("Max results"),
collection: z.string().optional().describe("Collection name"),
},
({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 =====================
server.tool(
"get_config_all",
"Get all configuration values",
{},
() => runJsonTool(gatewayRequest(() => socket.config().getConfigAll())),
);
server.tool(
"get_config",
"Get specific configuration values",
{
keys: z.array(
z.object({
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
}),
).describe("Config keys to retrieve"),
},
({keys}) => runJsonTool(gatewayRequest(() => socket.config().getConfig(keys))),
);
server.tool(
"put_config",
"Set configuration values",
{
values: z.array(
z.object({
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
value: z.string().describe("Config value (JSON-encoded)"),
}),
).describe("Key-value entries to set"),
},
({values}) => runJsonTool(gatewayRequest(() => socket.config().putConfig(values))),
);
server.tool(
"delete_config",
"Delete a configuration entry",
{
type: z.string().describe("Config type"),
key: z.string().describe("Config key"),
},
({type, key}) => runJsonTool(gatewayRequest(() => socket.config().deleteConfig({type, key}))),
);
// ===================== Flow management tools =====================
server.tool(
"get_flows",
"List all available flows",
{},
() => runJsonTool(gatewayRequest(() => socket.flows().getFlows())),
);
server.tool(
"get_flow",
"Get a specific flow definition",
{
flow_id: z.string().describe("Flow ID to retrieve"),
},
({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().getFlow(flow_id))),
);
server.tool(
"start_flow",
"Start a flow instance",
{
flow_id: z.string().describe("Flow ID"),
blueprint_name: z.string().describe("Blueprint name"),
description: z.string().describe("Flow description"),
parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"),
},
({flow_id, blueprint_name, description, parameters}) =>
runJsonTool(
gatewayRequest(() => socket.flows().startFlow(flow_id, blueprint_name, description, parameters)),
),
);
server.tool(
"stop_flow",
"Stop a running flow",
{
flow_id: z.string().describe("Flow ID to stop"),
},
({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().stopFlow(flow_id))),
);
// ===================== Library (document) tools =====================
server.tool(
"get_documents",
"List all documents in the library",
{},
() => runJsonTool(gatewayRequest(() => socket.librarian().getDocuments())),
);
server.tool(
"load_document",
"Upload a document to the library",
{
document: z.string().describe("Base64-encoded document content"),
mime_type: z.string().describe("Document MIME type"),
title: z.string().describe("Document title"),
comments: z.string().optional().describe("Additional comments"),
tags: z.array(z.string()).optional().describe("Document tags"),
id: z.string().optional().describe("Optional document ID"),
},
({document, mime_type, title, comments, tags, id}) =>
runJsonTool(
gatewayRequest(() =>
socket.librarian().loadDocument(
document,
mime_type,
title,
comments ?? "",
tags ?? [],
id,
)
),
),
);
server.tool(
"remove_document",
"Remove a document from the library",
{
id: z.string().describe("Document ID to remove"),
collection: z.string().optional().describe("Collection name"),
},
({id, collection}) => runJsonTool(gatewayRequest(() => socket.librarian().removeDocument(id, collection))),
);
// ===================== Prompt tools =====================
server.tool(
"get_prompts",
"List available prompt templates",
{},
() => runJsonTool(gatewayRequest(() => socket.config().getPrompts())),
);
server.tool(
"get_prompt",
"Get a specific prompt template",
{
id: z.string().describe("Prompt template ID"),
},
({id}) => runJsonTool(gatewayRequest(() => socket.config().getPrompt(id))),
);
// ===================== Knowledge core tools =====================
server.tool(
"get_knowledge_cores",
"List available knowledge graph cores",
{},
() => runJsonTool(gatewayRequest(() => socket.knowledge().getKnowledgeCores())),
);
server.tool(
"delete_kg_core",
"Delete a knowledge graph core",
{
id: z.string().describe("Knowledge core ID"),
collection: z.string().optional().describe("Collection name"),
},
({id, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().deleteKgCore(id, collection))),
);
server.tool(
"load_kg_core",
"Load a knowledge graph core",
{
id: z.string().describe("Knowledge core ID"),
flow: z.string().describe("Flow to use for loading"),
collection: z.string().optional().describe("Collection name"),
},
({id, flow, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().loadKgCore(id, flow, collection))),
);
return {server, socket};
}
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();
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);
}

Some files were not shown because too many files have changed in this diff Show more