This commit is contained in:
elpresidank 2026-05-12 08:06:58 -05:00
parent e8c7a4f6e0
commit ffd97375a8
160 changed files with 6704 additions and 1895 deletions

View file

@ -4,19 +4,30 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts",
"./backend": "./src/backend/index.ts",
"./messaging": "./src/messaging/index.ts",
"./processor": "./src/processor/index.ts",
"./runtime": "./src/runtime/index.ts",
"./schema": "./src/schema/index.ts",
"./package.json": "./package.json"
},
"scripts": {
"build": "tsc",
"build": "bunx --bun tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run"
"test": "bunx --bun vitest run"
},
"dependencies": {
"effect": "4.0.0-beta.65",
"nats": "^2.29.0",
"prom-client": "^15.1.0"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.65",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
"vitest": "^4.1.6"
}
}

View file

@ -8,7 +8,7 @@ import type {
CreateProducerOptions,
CreateConsumerOptions,
} from "../backend/types.js";
import { TooManyRequestsError } from "../errors.js";
import { tooManyRequestsError } from "../errors.js";
import type { Flow } from "../processor/flow.js";
// ── Mock Message ──────────────────────────────────────────────────────
@ -202,7 +202,7 @@ describe("Consumer", () => {
const handler = vi.fn().mockImplementation(async () => {
handlerCalls++;
if (handlerCalls === 1) {
throw new TooManyRequestsError("rate limited");
throw tooManyRequestsError("rate limited");
}
// Second call succeeds
});

View file

@ -0,0 +1,266 @@
import { describe, expect, it } from "@effect/vitest";
import { ConfigProvider, Effect, Fiber } from "effect";
import {
Embeddings,
EmbeddingsService,
MessagingRuntimeLive,
PubSub,
embeddingsError,
runProcessorScoped,
topics,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
type CreateProducerOptions,
type EmbeddingsRequest,
type EmbeddingsResponse,
type Message,
type PubSubBackend,
} from "../index.js";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
value: () => value,
properties: () => properties,
};
}
const waitFor = (condition: () => boolean, label: string) =>
Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const check = () => {
if (condition()) {
resolve();
return;
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for ${label}`));
return;
}
setTimeout(check, 5);
};
check();
}),
catch: (error) => error,
});
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 });
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
class PushConsumer<T> implements BackendConsumer<T> {
readonly acknowledged: Array<Message<T>> = [];
readonly nacked: Array<Message<T>> = [];
private readonly messages: Array<Message<T>> = [];
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
private closed = false;
push(message: Message<T>): void {
const waiter = this.waiters.shift();
if (waiter !== undefined) {
waiter(message);
return;
}
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);
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
}
}
class EmbeddingsBackend implements PubSubBackend {
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
readonly consumersByTopic = new Map<string, PushConsumer<unknown>>();
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>;
}
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>;
}
async close(): Promise<void> {
this.closeCount += 1;
}
pushConfig(): void {
this.configConsumer.push(
createMessage({
version: 1,
config: {
flows: {
default: {
topics: {
"embeddings-request": "embeddings-request-topic",
"embeddings-response": "embeddings-response-topic",
},
},
},
},
}),
);
}
}
const fastMessagingConfig = ConfigProvider.layer(
ConfigProvider.fromEnv({
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
TG_RATE_LIMIT_RETRY_MS: "1",
TG_REQUEST_TIMEOUT_MS: "250",
}),
);
describe("EmbeddingsService", () => {
it.effect(
"handles embeddings requests through the Embeddings Context service",
Effect.fnUntraced(function* () {
const backend = new EmbeddingsBackend();
const embeddingCalls: Array<{ readonly texts: ReadonlyArray<string>; readonly model?: string }> = [];
const embeddings = Embeddings.of({
embed: Effect.fn("TestEmbeddings.embed")((texts: ReadonlyArray<string>, model?: string) => {
embeddingCalls.push(model === undefined ? { texts } : { texts, model });
return Effect.succeed(texts.map((text, index) => [text.length, model?.length ?? 0, index]));
}),
});
yield* Effect.scoped(
Effect.gen(function* () {
const fiber = yield* runService(backend, embeddings).pipe(Effect.forkChild);
backend.pushConfig();
yield* waitFor(() => backend.consumersByTopic.has("embeddings-request-topic"), "embeddings consumer");
yield* waitFor(() => backend.producersByTopic.has("embeddings-response-topic"), "embeddings producer");
const input = backend.consumersByTopic.get("embeddings-request-topic") as PushConsumer<EmbeddingsRequest>;
const output = backend.producersByTopic.get("embeddings-response-topic") as RecordingProducer<EmbeddingsResponse>;
input.push(createMessage({ text: ["alpha", "beta"], model: "model-a" }, { id: "request-1" }));
yield* waitFor(() => output.sent.length === 1, "embeddings response");
expect(embeddingCalls).toEqual([{ texts: ["alpha", "beta"], model: "model-a" }]);
expect(output.sent).toEqual([
{
message: { vectors: [[5, 7, 0], [4, 7, 1]] },
properties: { id: "request-1" },
},
]);
expect(input.acknowledged.length).toBe(1);
expect(input.nacked).toEqual([]);
yield* Fiber.interrupt(fiber);
}),
);
expect(backend.closeCount).toBe(1);
}),
);
it.effect(
"returns a wire error response when the Embeddings service fails",
Effect.fnUntraced(function* () {
const backend = new EmbeddingsBackend();
const embeddings = Embeddings.of({
embed: Effect.fn("FailingEmbeddings.embed")(() =>
Effect.fail(embeddingsError("test.embed", new Error("provider unavailable"), "test")),
),
});
yield* Effect.scoped(
Effect.gen(function* () {
const fiber = yield* runService(backend, embeddings).pipe(Effect.forkChild);
backend.pushConfig();
yield* waitFor(() => backend.consumersByTopic.has("embeddings-request-topic"), "embeddings consumer");
yield* waitFor(() => backend.producersByTopic.has("embeddings-response-topic"), "embeddings producer");
const input = backend.consumersByTopic.get("embeddings-request-topic") as PushConsumer<EmbeddingsRequest>;
const output = backend.producersByTopic.get("embeddings-response-topic") as RecordingProducer<EmbeddingsResponse>;
input.push(createMessage({ text: ["alpha"] }, { id: "request-1" }));
yield* waitFor(() => output.sent.length === 1, "embeddings error response");
expect(output.sent).toEqual([
{
message: {
vectors: [],
error: {
type: "embeddings-error",
message: "provider unavailable",
},
},
properties: { id: "request-1" },
},
]);
expect(input.acknowledged.length).toBe(1);
expect(input.nacked).toEqual([]);
yield* Fiber.interrupt(fiber);
}),
);
}),
);
});
const runService = (
backend: EmbeddingsBackend,
embeddings: Embeddings,
) =>
runProcessorScoped(
{
id: "embeddings",
pubsubUrl: "nats://unused:4222",
metricsPort: 8000,
manageProcessSignals: true,
},
(config) => new EmbeddingsService(config),
).pipe(
Effect.provideService(Embeddings, embeddings),
Effect.provide(MessagingRuntimeLive),
Effect.provide(PubSub.layer(backend)),
Effect.provide(fastMessagingConfig),
);

View file

@ -0,0 +1,215 @@
import { describe, expect, it } from "@effect/vitest";
import { ConfigProvider, Effect, Fiber } from "effect";
import {
FlowProcessor,
MessagingRuntimeLive,
ProducerSpec,
PubSub,
runProcessorScoped,
topics,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
type CreateProducerOptions,
type Message,
type ProcessorConfig,
type PubSubBackend,
} from "../index.js";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
value: () => value,
properties: () => properties,
};
}
const waitFor = (condition: () => boolean, label: string) =>
Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const check = () => {
if (condition()) {
resolve();
return;
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for ${label}`));
return;
}
setTimeout(check, 5);
};
check();
}),
catch: (error) => error,
});
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
closeCount = 0;
flushCount = 0;
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
}
async flush(): Promise<void> {
this.flushCount += 1;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class PushConsumer<T> implements BackendConsumer<T> {
readonly acknowledged: Array<Message<T>> = [];
readonly nacked: Array<Message<T>> = [];
closeCount = 0;
private readonly messages: Array<Message<T>> = [];
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
private closed = false;
push(message: Message<T>): void {
const waiter = this.waiters.shift();
if (waiter !== undefined) {
waiter(message);
return;
}
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);
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
}
class FlowProcessorBackend implements PubSubBackend {
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
readonly producerOptions: Array<CreateProducerOptions> = [];
readonly consumerOptions: Array<CreateConsumerOptions> = [];
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>;
}
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>();
}
async close(): Promise<void> {
this.closeCount += 1;
}
pushConfig(version: number, flows: Record<string, unknown>): void {
this.configConsumer.push(createMessage({ version, config: { flows } }));
}
}
class TestFlowProcessor extends FlowProcessor {
constructor(
config: ProcessorConfig,
private readonly events: Array<string>,
) {
super(config);
this.registerSpecification(new ProducerSpec<string>("output"));
this.registerConfigHandler(async (_config, version) => {
this.events.push(`handler:${version}`);
});
}
}
const fastMessagingConfig = ConfigProvider.layer(
ConfigProvider.fromEnv({
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
TG_RATE_LIMIT_RETRY_MS: "1",
TG_REQUEST_TIMEOUT_MS: "250",
}),
);
describe("Effect-native FlowProcessor runtime", () => {
it.effect(
"starts, restarts, and removes flow scopes from config pushes",
Effect.fnUntraced(function* () {
const backend = new FlowProcessorBackend();
const events: Array<string> = [];
yield* Effect.scoped(
Effect.gen(function* () {
const fiber = yield* runProcessorScoped(
{
id: "flow-processor-test",
pubsubUrl: "nats://unused:4222",
metricsPort: 8000,
manageProcessSignals: true,
},
(config) => new TestFlowProcessor(config, events),
).pipe(
Effect.provide(MessagingRuntimeLive),
Effect.provide(PubSub.layer(backend)),
Effect.provide(fastMessagingConfig),
Effect.forkChild,
);
yield* waitFor(() => backend.consumerOptions.length === 1, "config subscription");
backend.pushConfig(1, { default: { topics: { output: "topic-a" } } });
yield* waitFor(() => backend.producers.length === 1, "first flow producer");
yield* waitFor(() => backend.configConsumer.acknowledged.length === 1, "first config ack");
backend.pushConfig(2, { default: { topics: { output: "topic-a" } } });
yield* waitFor(() => backend.configConsumer.acknowledged.length === 2, "unchanged config ack");
expect(backend.producers.length).toBe(1);
backend.pushConfig(3, { default: { topics: { output: "topic-b" } } });
yield* waitFor(() => backend.producers.length === 2, "restarted flow producer");
yield* waitFor(() => backend.producers[0]?.closeCount === 1, "old flow close");
backend.pushConfig(4, {});
yield* waitFor(() => backend.producers[1]?.closeCount === 1, "removed flow close");
yield* Fiber.interrupt(fiber);
}),
);
expect(backend.producerOptions.map((options) => options.topic)).toEqual(["topic-a", "topic-b"]);
expect(events).toEqual(["handler:1", "handler:2", "handler:3", "handler:4"]);
expect(backend.configConsumer.closeCount).toBeGreaterThanOrEqual(1);
expect(backend.closeCount).toBe(1);
}),
);
});

View file

@ -0,0 +1,298 @@
import { describe, expect, it } from "@effect/vitest";
import { ConfigProvider, Duration, Effect, Fiber } from "effect";
import * as TestClock from "effect/testing/TestClock";
import {
ConsumerSpec,
Flow,
MessagingRuntimeLive,
ParameterSpec,
ProducerSpec,
PubSub,
RequestResponseSpec,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
type CreateProducerOptions,
type FlowContext,
type Message,
type PubSubBackend,
} from "../index.js";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
value: () => value,
properties: () => properties,
};
}
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
closeCount = 0;
flushCount = 0;
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);
}
async flush(): Promise<void> {
this.flushCount += 1;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class ScriptedConsumer<T> implements BackendConsumer<T> {
readonly acknowledged: Array<Message<T>> = [];
readonly nacked: Array<Message<T>> = [];
closeCount = 0;
private readonly messages: Array<Message<T>>;
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
private closed = false;
constructor(
messages: Array<Message<T>> = [],
private readonly waitForMessages = false,
) {
this.messages = messages;
}
push(message: Message<T>): void {
const waiter = this.waiters.shift();
if (waiter !== undefined) {
waiter(message);
return;
}
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);
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
}
class RuntimeBackend implements PubSubBackend {
closeCount = 0;
producerOptions: CreateProducerOptions | null = null;
consumerOptions: CreateConsumerOptions | null = null;
readonly producer: RecordingProducer<unknown>;
constructor(
private readonly consumer: BackendConsumer<unknown>,
onSend?: (message: unknown, properties?: Record<string, string>) => void,
) {
this.producer = new RecordingProducer<unknown>(onSend);
}
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
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>;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
const fastMessagingConfig = ConfigProvider.layer(
ConfigProvider.fromEnv({
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
TG_RATE_LIMIT_RETRY_MS: "1",
TG_REQUEST_TIMEOUT_MS: "250",
}),
);
const provideRuntime = <A, E, R>(
backend: RuntimeBackend,
effect: Effect.Effect<A, E, R>,
) =>
effect.pipe(
Effect.provide(MessagingRuntimeLive),
Effect.provideService(PubSub, PubSub.fromBackend(backend)),
Effect.provide(fastMessagingConfig),
);
describe("Effect-native flow specifications", () => {
it.effect(
"starts producer specs through Effect factories and exposes typed accessors",
Effect.fnUntraced(function* () {
const backend = new RuntimeBackend(new ScriptedConsumer<unknown>());
const flow = new Flow(
"default",
"processor",
backend,
{ topics: { output: "actual-output" } },
[new ProducerSpec<string>("output")],
);
yield* Effect.scoped(
provideRuntime(
backend,
Effect.gen(function* () {
yield* flow.startEffect();
const producer = yield* flow.producerEffect<string>("output");
yield* producer.send("request-1", "hello");
}),
),
);
expect(backend.producerOptions).toEqual({ topic: "actual-output" });
expect(backend.producer.sent).toEqual([
{ message: "hello", properties: { id: "request-1" } },
]);
expect(backend.producer.closeCount).toBe(1);
}),
);
it.effect(
"runs Promise handlers through the explicit ConsumerSpec compatibility helper",
Effect.fnUntraced(function* () {
const message = createMessage("payload", { id: "request-1" });
const consumer = new ScriptedConsumer<string>([message]);
const backend = new RuntimeBackend(consumer as BackendConsumer<unknown>);
const handled: Array<string> = [];
const flow = new Flow(
"default",
"processor",
backend,
{},
[
ConsumerSpec.fromPromise<string>(
"input",
async (value, properties, flowContext: FlowContext) => {
handled.push(`${flowContext.name}:${properties.id}:${value}`);
},
),
],
);
yield* Effect.scoped(
provideRuntime(
backend,
Effect.gen(function* () {
yield* flow.startEffect();
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.millis(5));
}),
),
);
expect(consumer.acknowledged).toEqual([message]);
expect(consumer.nacked).toEqual([]);
expect(handled).toEqual(["default:request-1:payload"]);
}),
);
it.effect(
"registers request-response specs through Effect queues and keeps the Promise facade working",
Effect.fnUntraced(function* () {
const responseConsumer = new ScriptedConsumer<string>([], true);
const backend = new RuntimeBackend(
responseConsumer as BackendConsumer<unknown>,
(_message, properties) => {
responseConsumer.push(createMessage("response", { id: properties?.id ?? "" }));
},
);
const flow = new Flow(
"default",
"processor",
backend,
{
topics: {
request: "actual-request",
response: "actual-response",
},
},
[new RequestResponseSpec<string, string>("rr", "request", "response")],
);
const response = yield* Effect.scoped(
provideRuntime(
backend,
Effect.gen(function* () {
yield* flow.startEffect();
const requestor = flow.requestor<string, string>("rr");
const fiber = yield* Effect.promise(() =>
requestor.request("request", { timeoutMs: 250 }),
).pipe(Effect.forkChild);
yield* TestClock.adjust(Duration.millis(5));
return yield* Fiber.join(fiber);
}),
),
);
expect(response).toBe("response");
expect(backend.producerOptions).toEqual({ topic: "actual-request" });
expect(responseConsumer.acknowledged.length).toBe(1);
}),
);
it.effect(
"returns typed errors for missing flow resources",
Effect.fnUntraced(function* () {
const backend = new RuntimeBackend(new ScriptedConsumer<unknown>());
const flow = new Flow(
"default",
"processor",
backend,
{ parameters: { present: 42 } },
[new ParameterSpec("present")],
);
const errors = yield* Effect.scoped(
provideRuntime(
backend,
Effect.gen(function* () {
yield* flow.startEffect();
const producerError = yield* flow.producerEffect<string>("missing-producer").pipe(Effect.flip);
const parameter = yield* flow.parameterEffect<number>("present");
const parameterError = yield* flow.parameterEffect<number>("missing-parameter").pipe(Effect.flip);
return { producerError, parameter, parameterError };
}),
),
);
expect(errors.parameter).toBe(42);
expect(errors.producerError._tag).toBe("FlowResourceNotFoundError");
expect(errors.producerError.resourceType).toBe("producer");
expect(errors.producerError.resourceName).toBe("missing-producer");
expect(errors.parameterError._tag).toBe("FlowResourceNotFoundError");
expect(errors.parameterError.resourceType).toBe("parameter");
expect(() => flow.producer("missing-producer")).toThrow("not found");
}),
);
});

View file

@ -0,0 +1,277 @@
import { describe, expect, it } from "@effect/vitest";
import { Duration, Effect, Fiber } from "effect";
import * as TestClock from "effect/testing/TestClock";
import {
PubSub,
defaultMessagingRuntimeConfig,
makeEffectRequestResponseFromPubSub,
MessagingRuntimeLive,
ProducerSpec,
runEffectConsumerScoped,
runEffectProducerScoped,
runFlowScoped,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
type CreateProducerOptions,
type FlowContext,
type Message,
type PubSubBackend,
} from "../index.js";
import type { Flow } from "../processor/flow.js";
import { Flow as RuntimeFlow } from "../processor/flow.js";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
value: () => value,
properties: () => properties,
};
}
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
closeCount = 0;
flushCount = 0;
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);
}
async flush(): Promise<void> {
this.flushCount += 1;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class ScriptedConsumer<T> implements BackendConsumer<T> {
readonly acknowledged: Array<Message<T>> = [];
readonly nacked: Array<Message<T>> = [];
closeCount = 0;
private readonly messages: Array<Message<T>>;
constructor(messages: Array<Message<T>> = []) {
this.messages = messages;
}
push(message: Message<T>): void {
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined) {
return message;
}
return null;
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class RuntimeBackend implements PubSubBackend {
closeCount = 0;
producerOptions: CreateProducerOptions | null = null;
consumerOptions: CreateConsumerOptions | null = null;
readonly producer: RecordingProducer<unknown>;
constructor(
private readonly consumer: BackendConsumer<unknown>,
onSend?: (message: unknown, properties?: Record<string, string>) => void,
) {
this.producer = new RecordingProducer<unknown>(onSend);
}
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
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>;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
const flowContext: FlowContext = {
id: "processor",
name: "default",
flow: {} as Flow,
};
describe("Effect-native messaging runtime", () => {
it.effect(
"creates scoped producers through PubSub and translates send calls",
Effect.fnUntraced(function* () {
const consumer = new ScriptedConsumer<unknown>();
const backend = new RuntimeBackend(consumer);
yield* Effect.scoped(
Effect.gen(function* () {
const producer = yield* runEffectProducerScoped<string>({ topic: "tg.test.producer" });
yield* producer.send("message-1", "hello");
expect(backend.producerOptions).toEqual({ topic: "tg.test.producer" });
expect(backend.producer.sent).toEqual([
{ message: "hello", properties: { id: "message-1" } },
]);
}).pipe(Effect.provide(PubSub.layer(backend))),
);
expect(backend.producer.closeCount).toBe(1);
expect(backend.closeCount).toBe(1);
}),
);
it.effect(
"runs consumers as scoped fibers and acknowledges handled messages",
Effect.fnUntraced(function* () {
const message = createMessage("payload", { id: "request-1" });
const consumer = new ScriptedConsumer<string>([message]);
const backend = new RuntimeBackend(consumer as BackendConsumer<unknown>);
const handled: Array<string> = [];
yield* Effect.scoped(
Effect.gen(function* () {
yield* runEffectConsumerScoped<string>(
{
topic: "tg.test.consumer",
subscription: "sub",
receiveTimeoutMs: 1,
errorBackoffMs: 1,
handler: (value, properties) =>
Effect.sync(() => {
handled.push(`${properties.id}:${value}`);
}),
},
flowContext,
);
yield* TestClock.adjust(Duration.millis(20));
}).pipe(Effect.provide(PubSub.layer(backend))),
);
expect(handled).toEqual(["request-1:payload"]);
expect(consumer.acknowledged).toEqual([message]);
expect(consumer.nacked).toEqual([]);
expect(consumer.closeCount).toBeGreaterThan(0);
}),
);
it.effect(
"routes request-response replies through an Effect queue",
Effect.fnUntraced(function* () {
const responseConsumer = new ScriptedConsumer<string>();
const backend = new RuntimeBackend(
responseConsumer as BackendConsumer<unknown>,
(_message, properties) => {
responseConsumer.push(createMessage("response", { id: properties?.id ?? "" }));
},
);
const response = yield* Effect.scoped(
Effect.gen(function* () {
const requestor = yield* makeEffectRequestResponseFromPubSub<string, string>(
PubSub.fromBackend(backend),
{
...defaultMessagingRuntimeConfig,
consumerReceiveTimeoutMs: 1,
},
{
requestTopic: "tg.test.request",
responseTopic: "tg.test.response",
subscription: "sub",
},
);
const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild);
yield* TestClock.adjust(Duration.millis(5));
return yield* Fiber.join(fiber);
}),
);
expect(response).toBe("response");
expect(backend.producer.sent[0]?.message).toBe("request");
expect(responseConsumer.acknowledged.length).toBe(1);
}),
);
it.effect(
"fails request-response calls with a typed timeout",
Effect.fnUntraced(function* () {
const responseConsumer = new ScriptedConsumer<string>();
const backend = new RuntimeBackend(responseConsumer as BackendConsumer<unknown>);
const error = yield* Effect.scoped(
Effect.gen(function* () {
const requestor = yield* makeEffectRequestResponseFromPubSub<string, string>(
PubSub.fromBackend(backend),
{
...defaultMessagingRuntimeConfig,
consumerReceiveTimeoutMs: 1,
},
{
requestTopic: "tg.test.request",
responseTopic: "tg.test.response",
subscription: "sub",
},
);
const fiber = yield* requestor.request("request", { timeoutMs: 5 }).pipe(
Effect.flip,
Effect.forkChild,
);
yield* TestClock.adjust(Duration.millis(10));
return yield* Fiber.join(fiber);
}),
);
expect(error._tag).toBe("MessagingTimeoutError");
expect(error.operation).toBe("request-response");
expect(error.timeoutMs).toBe(5);
}),
);
it.effect(
"owns Flow lifecycle through a scoped Effect boundary",
Effect.fnUntraced(function* () {
const consumer = new ScriptedConsumer<unknown>();
const backend = new RuntimeBackend(consumer);
const flow = new RuntimeFlow(
"flow-a",
"processor",
backend,
{},
[new ProducerSpec<string>("flow-output")],
);
yield* Effect.scoped(
runFlowScoped(flow).pipe(
Effect.provide(MessagingRuntimeLive),
Effect.provideService(PubSub, PubSub.fromBackend(backend)),
),
);
expect(backend.producerOptions).toEqual({ topic: "flow-output" });
expect(backend.producer.closeCount).toBe(1);
}),
);
});

View file

@ -0,0 +1,240 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import {
AsyncProcessor,
PubSub,
runProcessorScoped,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
type CreateProducerOptions,
type Message,
type ProcessorConfig,
type PubSubBackend,
} from "../index.js";
class FakeProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
closeCount = 0;
flushCount = 0;
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(
properties === undefined
? { message }
: { message, properties },
);
}
async flush(): Promise<void> {
this.flushCount += 1;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class FakeConsumer<T> implements BackendConsumer<T> {
closeCount = 0;
async receive(): Promise<Message<T> | null> {
return null;
}
async acknowledge(): Promise<void> {}
async negativeAcknowledge(): Promise<void> {}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class FakePubSubBackend implements PubSubBackend {
closeCount = 0;
producerOptions: CreateProducerOptions | null = null;
consumerOptions: CreateConsumerOptions | null = null;
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions = options;
return new FakeProducer<T>();
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions = options;
return new FakeConsumer<T>();
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class FailingProducerBackend extends FakePubSubBackend {
override async createProducer<T>(): Promise<BackendProducer<T>> {
throw new Error("producer unavailable");
}
}
class RecordingProcessor extends AsyncProcessor {
constructor(
config: ProcessorConfig,
private readonly events: Array<string>,
) {
super(config);
}
protected async run(): Promise<void> {
this.events.push(`run:${this.config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`);
}
override async stop(): Promise<void> {
this.events.push("stop");
await super.stop();
}
}
class FailingProcessor extends AsyncProcessor {
protected async run(): Promise<void> {
throw new Error("processor failed");
}
}
class NativeRecordingProcessor extends AsyncProcessor<never, PubSub> {
constructor(
config: ProcessorConfig,
private readonly events: Array<string>,
) {
super(config);
}
protected override runEffect() {
const events = this.events;
const config = this.config;
return Effect.gen(function* () {
const pubsub = yield* PubSub;
events.push(`native:${config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`);
events.push(`pubsub:${pubsub.backend.constructor.name}`);
});
}
override stopEffect() {
this.events.push("native-stop");
return super.stopEffect();
}
}
describe("Effect runtime services", () => {
it.effect(
"provides a compatibility backend through the PubSub service",
Effect.fnUntraced(function* () {
const backend = new FakePubSubBackend();
yield* Effect.scoped(
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" }));
expect(backend.producerOptions).toEqual({ topic: "tg.test.topic" });
expect(pubsub.backend).toBe(backend);
}).pipe(Effect.provide(PubSub.layer(backend))),
);
expect(backend.closeCount).toBe(1);
}),
);
it.effect(
"maps backend failures into PubSubError",
Effect.fnUntraced(function* () {
const backend = new FailingProducerBackend();
const error = yield* Effect.scoped(
Effect.gen(function* () {
const pubsub = yield* PubSub;
return yield* pubsub.createProducer<string>({ topic: "tg.test.failure" }).pipe(Effect.flip);
}).pipe(Effect.provide(PubSub.layer(backend))),
);
expect(error._tag).toBe("PubSubError");
expect(error.operation).toBe("createProducer:tg.test.failure");
expect(error.message).toBe("producer unavailable");
}),
);
it.effect(
"runs a processor with injected PubSub and scoped finalization",
Effect.fnUntraced(function* () {
const backend = new FakePubSubBackend();
const events: Array<string> = [];
yield* Effect.scoped(
runProcessorScoped(
{
id: "recording",
pubsubUrl: "nats://unused:4222",
metricsPort: 8000,
manageProcessSignals: true,
},
(config) => new RecordingProcessor(config, events),
).pipe(Effect.provide(PubSub.layer(backend))),
);
expect(events).toEqual(["run:effect-signals", "stop"]);
expect(backend.closeCount).toBe(1);
}),
);
it.effect(
"runs native processor lifecycle hooks with Effect requirements",
Effect.fnUntraced(function* () {
const backend = new FakePubSubBackend();
const events: Array<string> = [];
yield* Effect.scoped(
runProcessorScoped(
{
id: "native-recording",
pubsubUrl: "nats://unused:4222",
metricsPort: 8000,
manageProcessSignals: true,
},
(config) => new NativeRecordingProcessor(config, events),
).pipe(Effect.provide(PubSub.layer(backend))),
);
expect(events).toEqual(["native:effect-signals", "pubsub:FakePubSubBackend", "native-stop"]);
expect(backend.closeCount).toBe(1);
}),
);
it.effect(
"maps processor start failures into ProcessorLifecycleError",
Effect.fnUntraced(function* () {
const backend = new FakePubSubBackend();
const error = yield* Effect.scoped(
runProcessorScoped(
{
id: "failing",
metricsPort: 8000,
manageProcessSignals: true,
},
(config) => new FailingProcessor(config),
).pipe(
Effect.provide(PubSub.layer(backend)),
Effect.flip,
),
);
expect(error._tag).toBe("ProcessorLifecycleError");
expect(error.operation).toBe("start");
expect(error.processorId).toBe("failing");
expect(error.message).toBe("processor failed");
}),
);
});

View file

@ -0,0 +1,95 @@
import { describe, expect, it } from "@effect/vitest";
import { ConfigProvider, Effect } from "effect";
import * as S from "effect/Schema";
import {
ConfigRequest,
GraphRagResponse,
Term,
TextCompletionRequest,
loadProcessorRuntimeConfig,
} from "../index.js";
describe("Effect schemas", () => {
it.effect(
"decode existing text-completion wire payloads",
Effect.fnUntraced(function* () {
const request = yield* S.decodeUnknownEffect(TextCompletionRequest)({
system: "system",
prompt: "hello",
streaming: true,
});
expect(request.prompt).toBe("hello");
expect(request.streaming).toBe(true);
}),
);
it.effect(
"decode recursive RDF terms",
Effect.fnUntraced(function* () {
const term = yield* S.decodeUnknownEffect(Term)({
type: "TRIPLE",
triple: {
s: { type: "IRI", iri: "urn:s" },
p: { type: "IRI", iri: "urn:p" },
o: { type: "LITERAL", value: "object" },
},
});
expect(term.type).toBe("TRIPLE");
}),
);
it.effect(
"preserve gateway response extension fields",
Effect.fnUntraced(function* () {
const response = yield* S.decodeUnknownEffect(GraphRagResponse)({
response: "ok",
message_type: "explain",
explain_id: "e1",
providerTrace: { kept: true },
});
expect(response.providerTrace).toEqual({ kept: true });
}),
);
it.effect(
"decode config requests",
Effect.fnUntraced(function* () {
const request = yield* S.decodeUnknownEffect(ConfigRequest)({
operation: "put",
keys: ["flows"],
values: { default: { topics: {} } },
});
expect(request.operation).toBe("put");
}),
);
});
describe("Effect runtime config", () => {
it.effect(
"loads processor settings from existing env names",
Effect.fnUntraced(function* () {
const provider = ConfigProvider.fromEnv({
env: {
NATS_URL: "nats://example:4222",
METRICS_PORT: "9000",
},
});
const config = yield* Effect.provide(
loadProcessorRuntimeConfig("svc", { manageProcessSignals: false }),
ConfigProvider.layer(provider),
);
expect(config).toEqual({
id: "svc",
pubsubUrl: "nats://example:4222",
metricsPort: 9000,
manageProcessSignals: false,
});
}),
);
});

View file

@ -10,3 +10,11 @@ export type {
} from "./types.js";
export { NatsBackend } from "./nats.js";
export {
PubSub,
NatsPubSubLive,
makeNatsPubSubLayer,
makePubSubService,
pubSubLayer,
type PubSubService,
} from "./pubsub.js";

View file

@ -19,6 +19,7 @@ import {
AckPolicy,
DeliverPolicy,
} from "nats";
import * as S from "effect/Schema";
import type {
PubSubBackend,
@ -34,12 +35,11 @@ const sc = StringCodec();
class NatsMessage<T> implements Message<T> {
/** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */
readonly _jsMsg: JsMsg;
private readonly decoded: T;
constructor(
msg: JsMsg,
private readonly decoded: T,
) {
constructor(msg: JsMsg, decoded: T) {
this._jsMsg = msg;
this.decoded = decoded;
}
value(): T {
@ -49,9 +49,12 @@ class NatsMessage<T> implements Message<T> {
properties(): Record<string, string> {
const headers = this._jsMsg.headers;
const props: Record<string, string> = {};
if (headers) {
if (headers !== undefined) {
for (const [key, values] of headers) {
props[key] = values[0];
const value = values[0];
if (value !== undefined) {
props[key] = value;
}
}
}
return props;
@ -59,16 +62,24 @@ class NatsMessage<T> implements Message<T> {
}
class NatsProducer<T> implements BackendProducer<T> {
constructor(
private readonly js: JetStreamClient,
private readonly subject: string,
) {}
private readonly js: JetStreamClient;
private readonly subject: string;
private readonly schema: S.Top | undefined;
constructor(js: JetStreamClient, subject: string, schema?: S.Top) {
this.js = js;
this.subject = subject;
this.schema = schema;
}
async send(message: T, properties?: Record<string, string>): Promise<void> {
const data = sc.encode(JSON.stringify(message));
const encoded = this.schema !== undefined
? S.encodeUnknownSync(this.schema as S.Codec<unknown, unknown>)(message)
: message;
const data = sc.encode(JSON.stringify(encoded));
const opts: Record<string, unknown> = {};
if (properties && Object.keys(properties).length > 0) {
if (properties !== undefined && Object.keys(properties).length > 0) {
const { headers } = await import("nats");
const hdrs = headers();
for (const [key, val] of Object.entries(properties)) {
@ -91,15 +102,31 @@ class NatsProducer<T> implements BackendProducer<T> {
class NatsConsumer<T> implements BackendConsumer<T> {
private consumer: NatsJsConsumer | null = null;
private readonly js: JetStreamClient;
private readonly jsm: JetStreamManager;
private readonly subject: string;
private readonly subscription: string;
private readonly initialPosition: "latest" | "earliest";
private readonly streamName: string;
private readonly schema: S.Top | undefined;
constructor(
private readonly js: JetStreamClient,
private readonly jsm: JetStreamManager,
private readonly subject: string,
private readonly subscription: string,
private readonly initialPosition: "latest" | "earliest",
private readonly streamName: string,
) {}
js: JetStreamClient,
jsm: JetStreamManager,
subject: string,
subscription: string,
initialPosition: "latest" | "earliest",
streamName: string,
schema?: S.Top,
) {
this.js = js;
this.jsm = jsm;
this.subject = subject;
this.subscription = subscription;
this.initialPosition = initialPosition;
this.streamName = streamName;
this.schema = schema;
}
async init(): Promise<void> {
// Stream is already ensured by NatsBackend.ensureStream().
@ -124,14 +151,17 @@ class NatsConsumer<T> implements BackendConsumer<T> {
}
async receive(timeoutMs = 2000): Promise<Message<T> | null> {
if (!this.consumer) throw new Error("Consumer not initialized");
if (this.consumer === null) throw new Error("Consumer not initialized");
// Pull a single message with a timeout using the pull-based API.
// consumer.next() returns a JsMsg or null when the timeout expires.
const msg = await this.consumer.next({ expires: timeoutMs });
if (!msg) return null;
if (msg === null) return null;
const decoded = JSON.parse(sc.decode(msg.data)) as T;
const parsed = JSON.parse(sc.decode(msg.data));
const decoded = this.schema !== undefined
? S.decodeUnknownSync(this.schema as S.Codec<unknown, unknown>)(parsed) as T
: parsed as T;
return new NatsMessage(msg, decoded);
}
@ -161,11 +191,14 @@ export class NatsBackend implements PubSubBackend {
private js: JetStreamClient | null = null;
private jsm: JetStreamManager | null = null;
private initializedStreams = new Set<string>();
private readonly url: string;
constructor(private readonly url: string = "nats://localhost:4222") {}
constructor(url = "nats://localhost:4222") {
this.url = url;
}
private async ensureConnected(): Promise<void> {
if (!this.connection) {
if (this.connection === null) {
this.connection = await connect({ servers: this.url });
this.js = this.connection.jetstream();
this.jsm = await this.connection.jetstreamManager();
@ -184,10 +217,13 @@ export class NatsBackend implements PubSubBackend {
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
const jsm = this.jsm;
if (jsm === null) throw new Error("NATS backend not connected");
try {
await this.jsm!.streams.info(streamName);
await jsm.streams.info(streamName);
} catch {
await this.jsm!.streams.add({
await jsm.streams.add({
name: streamName,
subjects: [wildcardSubject],
});
@ -199,26 +235,32 @@ export class NatsBackend implements PubSubBackend {
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
await this.ensureConnected();
await this.ensureStream(options.topic);
return new NatsProducer<T>(this.js!, options.topic);
const js = this.js;
if (js === null) throw new Error("NATS backend not connected");
return new NatsProducer<T>(js, options.topic, options.schema);
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
await this.ensureConnected();
const streamName = await this.ensureStream(options.topic);
const js = this.js;
const jsm = this.jsm;
if (js === null || jsm === null) throw new Error("NATS backend not connected");
const consumer = new NatsConsumer<T>(
this.js!,
this.jsm!,
js,
jsm,
options.topic,
options.subscription,
options.initialPosition ?? "latest",
streamName,
options.schema,
);
await consumer.init();
return consumer;
}
async close(): Promise<void> {
if (this.connection) {
if (this.connection !== null) {
await this.connection.drain();
this.connection = null;
this.js = null;

View file

@ -0,0 +1,101 @@
/**
* 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.
*/
import { Config, Context, Effect, Layer } from "effect";
import * as O from "effect/Option";
import type {
BackendConsumer,
BackendProducer,
CreateConsumerOptions,
CreateProducerOptions,
PubSubBackend,
} from "./types.js";
import { NatsBackend } from "./nats.js";
import { pubSubError } from "../errors.js";
export interface PubSubService {
readonly backend: PubSubBackend;
readonly createProducer: <T>(
options: CreateProducerOptions,
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
readonly createConsumer: <T>(
options: CreateConsumerOptions,
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
}
export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgraph/base/backend/pubsub") {
static fromBackend(backend: PubSubBackend): PubSubService {
return makePubSubService(backend);
}
static layer(backend: PubSubBackend): Layer.Layer<PubSub> {
return pubSubLayer(backend);
}
}
export function makePubSubService(backend: PubSubBackend): PubSubService {
return {
backend,
createProducer: <T>(options: CreateProducerOptions) =>
Effect.tryPromise({
try: () => backend.createProducer<T>(options),
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
}),
createConsumer: <T>(options: CreateConsumerOptions) =>
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),
}),
};
}
export function pubSubLayer(backend: PubSubBackend): Layer.Layer<PubSub> {
return Layer.effect(PubSub)(
Effect.gen(function* () {
const service = makePubSubService(backend);
yield* Effect.addFinalizer(() =>
service.close.pipe(
Effect.catch((error) =>
Effect.logError("[PubSub] Failed to close backend", {
error: error.message,
operation: error.operation,
}),
),
),
);
return PubSub.of(service);
}),
);
}
export function makeNatsPubSubLayer(url = "nats://localhost:4222"): Layer.Layer<PubSub> {
return pubSubLayer(new NatsBackend(url));
}
export const NatsPubSubLive = Layer.effect(PubSub)(
Effect.gen(function* () {
const natsUrl = O.getOrUndefined(yield* Config.string("NATS_URL").pipe(Config.option));
const pulsarHost = O.getOrUndefined(yield* Config.string("PULSAR_HOST").pipe(Config.option));
const service = makePubSubService(new NatsBackend(natsUrl ?? pulsarHost ?? "nats://localhost:4222"));
yield* Effect.addFinalizer(() =>
service.close.pipe(
Effect.catch((error) =>
Effect.logError("[PubSub] Failed to close NATS backend", {
error: error.message,
operation: error.operation,
}),
),
),
);
return PubSub.of(service);
}),
);

View file

@ -5,6 +5,8 @@
* (NATS, Pulsar, Redis Streams) implements these interfaces.
*/
import type * as S from "effect/Schema";
export interface Message<T = unknown> {
value(): T;
properties(): Record<string, string>;
@ -29,6 +31,7 @@ export type InitialPosition = "latest" | "earliest";
export interface CreateProducerOptions {
topic: string;
schema?: S.Top;
}
export interface CreateConsumerOptions {
@ -36,6 +39,7 @@ export interface CreateConsumerOptions {
subscription: string;
initialPosition?: InitialPosition;
consumerType?: ConsumerType;
schema?: S.Top;
}
export interface PubSubBackend {

View file

@ -1,29 +1,310 @@
/**
* Custom error types.
* Typed errors and wire-error translation helpers.
*
* Python reference: trustgraph-base/trustgraph/exceptions.py
*/
export class TooManyRequestsError extends Error {
constructor(message = "Rate limit exceeded") {
super(message);
this.name = "TooManyRequestsError";
}
import * as S from "effect/Schema";
import type { TgError } from "./schema/primitives.js";
export class TooManyRequestsError extends S.TaggedErrorClass<TooManyRequestsError>()(
"TooManyRequestsError",
{
message: S.String,
},
) {}
export class LlmError extends S.TaggedErrorClass<LlmError>()(
"LlmError",
{
message: S.String,
errorType: S.String,
},
) {}
export class EmbeddingsError extends S.TaggedErrorClass<EmbeddingsError>()(
"EmbeddingsError",
{
message: S.String,
operation: S.String,
provider: S.optionalKey(S.String),
},
) {}
export class ParseError extends S.TaggedErrorClass<ParseError>()(
"ParseError",
{
message: S.String,
},
) {}
export class RuntimeConfigError extends S.TaggedErrorClass<RuntimeConfigError>()(
"RuntimeConfigError",
{
message: S.String,
key: S.optionalKey(S.String),
},
) {}
export class WireDecodeError extends S.TaggedErrorClass<WireDecodeError>()(
"WireDecodeError",
{
message: S.String,
service: S.optionalKey(S.String),
},
) {}
export class PubSubError extends S.TaggedErrorClass<PubSubError>()(
"PubSubError",
{
message: S.String,
operation: S.String,
},
) {}
export class ProcessorLifecycleError extends S.TaggedErrorClass<ProcessorLifecycleError>()(
"ProcessorLifecycleError",
{
message: S.String,
operation: S.String,
processorId: S.String,
},
) {}
export class MessagingLifecycleError extends S.TaggedErrorClass<MessagingLifecycleError>()(
"MessagingLifecycleError",
{
message: S.String,
operation: S.String,
resource: S.String,
},
) {}
export class MessagingDeliveryError extends S.TaggedErrorClass<MessagingDeliveryError>()(
"MessagingDeliveryError",
{
message: S.String,
operation: S.String,
topic: S.String,
},
) {}
export class MessagingDecodeError extends S.TaggedErrorClass<MessagingDecodeError>()(
"MessagingDecodeError",
{
message: S.String,
operation: S.String,
topic: S.optionalKey(S.String),
},
) {}
export class MessagingTimeoutError extends S.TaggedErrorClass<MessagingTimeoutError>()(
"MessagingTimeoutError",
{
message: S.String,
operation: S.String,
timeoutMs: S.Number,
},
) {}
export class MessagingHandlerError extends S.TaggedErrorClass<MessagingHandlerError>()(
"MessagingHandlerError",
{
message: S.String,
topic: S.String,
subscription: S.String,
},
) {}
export class FlowRuntimeError extends S.TaggedErrorClass<FlowRuntimeError>()(
"FlowRuntimeError",
{
message: S.String,
flowName: S.String,
operation: S.String,
},
) {}
export class FlowResourceNotFoundError extends S.TaggedErrorClass<FlowResourceNotFoundError>()(
"FlowResourceNotFoundError",
{
message: S.String,
flowName: S.String,
resourceType: S.Union([
S.Literal("producer"),
S.Literal("consumer"),
S.Literal("requestor"),
S.Literal("parameter"),
]),
resourceName: S.String,
},
) {}
export type TrustGraphError =
| TooManyRequestsError
| LlmError
| EmbeddingsError
| ParseError
| RuntimeConfigError
| WireDecodeError
| PubSubError
| ProcessorLifecycleError
| MessagingLifecycleError
| MessagingDeliveryError
| MessagingDecodeError
| MessagingTimeoutError
| MessagingHandlerError
| FlowRuntimeError
| FlowResourceNotFoundError;
export type MessagingRuntimeError =
| PubSubError
| MessagingLifecycleError
| MessagingDeliveryError
| MessagingDecodeError
| MessagingTimeoutError
| MessagingHandlerError
| FlowRuntimeError
| FlowResourceNotFoundError;
export function tooManyRequestsError(message = "Rate limit exceeded"): TooManyRequestsError {
return new TooManyRequestsError({ message });
}
export class LlmError extends Error {
constructor(
message: string,
public readonly errorType: string = "llm-error",
) {
super(message);
this.name = "LlmError";
}
export function llmError(message: string, errorType = "llm-error"): LlmError {
return new LlmError({ message, errorType });
}
export class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ParseError";
}
export function embeddingsError(
operation: string,
error: unknown,
provider?: string,
): EmbeddingsError {
return new EmbeddingsError({
operation,
message: errorMessage(error),
...(provider === undefined ? {} : { provider }),
});
}
export function parseError(message: string): ParseError {
return new ParseError({ message });
}
export function pubSubError(operation: string, error: unknown): PubSubError {
return new PubSubError({ operation, message: errorMessage(error) });
}
export function processorLifecycleError(
processorId: string,
operation: string,
error: unknown,
): ProcessorLifecycleError {
return new ProcessorLifecycleError({
processorId,
operation,
message: errorMessage(error),
});
}
export function messagingLifecycleError(
resource: string,
operation: string,
error: unknown,
): MessagingLifecycleError {
return new MessagingLifecycleError({
resource,
operation,
message: errorMessage(error),
});
}
export function messagingDeliveryError(
topic: string,
operation: string,
error: unknown,
): MessagingDeliveryError {
return new MessagingDeliveryError({
topic,
operation,
message: errorMessage(error),
});
}
export function messagingDecodeError(
operation: string,
error: unknown,
topic?: string,
): MessagingDecodeError {
return new MessagingDecodeError({
operation,
message: errorMessage(error),
...(topic === undefined ? {} : { topic }),
});
}
export function messagingTimeoutError(
operation: string,
timeoutMs: number,
): MessagingTimeoutError {
return new MessagingTimeoutError({
operation,
timeoutMs,
message: `${operation} timed out after ${timeoutMs}ms`,
});
}
export function messagingHandlerError(
topic: string,
subscription: string,
error: unknown,
): MessagingHandlerError {
return new MessagingHandlerError({
topic,
subscription,
message: errorMessage(error),
});
}
export function flowRuntimeError(
flowName: string,
operation: string,
error: unknown,
): FlowRuntimeError {
return new FlowRuntimeError({
flowName,
operation,
message: errorMessage(error),
});
}
export function flowResourceNotFoundError(
flowName: string,
resourceType: FlowResourceNotFoundError["resourceType"],
resourceName: string,
): FlowResourceNotFoundError {
return new FlowResourceNotFoundError({
flowName,
resourceType,
resourceName,
message: `${resourceType} "${resourceName}" not found in flow "${flowName}"`,
});
}
export function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return String(error);
}
export function toTgError(error: unknown, fallbackType = "internal"): TgError {
if (typeof error === "object" && error !== null && "_tag" in error) {
const tag = (error as { _tag?: unknown })._tag;
if (typeof tag === "string") {
return { type: tag, message: errorMessage(error) };
}
}
return { type: fallbackType, message: errorMessage(error) };
}

View file

@ -7,4 +7,5 @@ export * from "./schema/index.js";
export * from "./spec/index.js";
export * from "./services/index.js";
export * from "./metrics/index.js";
export * from "./runtime/index.js";
export * from "./errors.js";

View file

@ -7,6 +7,7 @@
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
import type { Flow } from "../processor/flow.js";
import { TooManyRequestsError } from "../errors.js";
import * as S from "effect/Schema";
export type MessageHandler<T> = (
message: T,
@ -14,11 +15,11 @@ export type MessageHandler<T> = (
flow: FlowContext,
) => Promise<void>;
export interface FlowContext {
export interface FlowContext<Requirements = never> {
id: string;
name: string;
/** Reference to the owning Flow instance, giving handlers access to producers and parameters. */
flow: Flow;
flow: Flow<Requirements>;
}
export interface ConsumerOptions<T> {
@ -36,11 +37,13 @@ export class Consumer<T> {
private backend: BackendConsumer<T> | null = null;
private running = false;
private abortController = new AbortController();
private readonly options: ConsumerOptions<T>;
private readonly concurrency: number;
private readonly rateLimitRetryMs: number;
constructor(private readonly options: ConsumerOptions<T>) {
constructor(options: ConsumerOptions<T>) {
this.options = options;
this.concurrency = options.concurrency ?? 1;
this.rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
}
@ -65,7 +68,7 @@ export class Consumer<T> {
async stop(): Promise<void> {
this.running = false;
this.abortController.abort();
if (this.backend) {
if (this.backend !== null) {
await this.backend.close();
this.backend = null;
}
@ -75,17 +78,23 @@ export class Consumer<T> {
while (this.running) {
let msg: Message<T> | null = null;
try {
msg = await this.backend!.receive(2000);
if (!msg) continue;
const backend = this.backend;
if (backend === null) throw new Error("Consumer backend not started");
msg = await backend.receive(2000);
if (msg === null) continue;
await this.handleWithRetry(msg, flow);
await this.backend!.acknowledge(msg);
await backend.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[Consumer] Error in consume loop:", err);
if (msg) {
if (msg !== null) {
try {
await this.backend!.negativeAcknowledge(msg);
const backend = this.backend;
if (backend !== null) {
await backend.negativeAcknowledge(msg);
}
} catch (nakErr) {
console.error("[Consumer] Failed to nak message:", nakErr);
}
@ -99,7 +108,7 @@ export class Consumer<T> {
try {
await this.options.handler(msg.value(), msg.properties(), flow);
} catch (err) {
if (err instanceof TooManyRequestsError) {
if (S.is(TooManyRequestsError)(err)) {
console.warn(`[Consumer] Rate limited, retrying in ${this.rateLimitRetryMs}ms`);
await sleep(this.rateLimitRetryMs);
await this.options.handler(msg.value(), msg.properties(), flow);

View file

@ -2,3 +2,37 @@ export { Producer } from "./producer.js";
export { Consumer, type MessageHandler, type FlowContext, type ConsumerOptions } from "./consumer.js";
export { Subscriber, AsyncQueue } from "./subscriber.js";
export { RequestResponse, type RequestResponseOptions } from "./request-response.js";
export {
ConsumerFactory,
ConsumerFactoryLive,
FlowRuntime,
FlowRuntimeLive,
MessagingRuntimeLive,
ProducerFactory,
ProducerFactoryLive,
RequestResponseFactory,
RequestResponseFactoryLive,
makeEffectConsumerFromPubSub,
makeEffectProducerFromPubSub,
makeEffectProducerHandle,
makeEffectRequestResponseFromPubSub,
makeConsumerFactoryService,
makeProducerFactoryService,
makeRequestResponseFactoryService,
runEffectConsumerScoped,
runEffectProducerScoped,
runEffectRequestResponseScoped,
runFlowScoped,
type ConsumerFactoryService,
type EffectConsumer,
type EffectConsumerOptions,
type EffectMessageHandler,
type EffectProducer,
type EffectProducerOptions,
type EffectRequestOptions,
type EffectRequestResponse,
type EffectRequestResponseOptions,
type FlowRuntimeService,
type ProducerFactoryService,
type RequestResponseFactoryService,
} from "./runtime.js";

View file

@ -6,34 +6,44 @@
import type { PubSubBackend, BackendProducer } from "../backend/types.js";
import type { ProducerMetrics } from "../metrics/prometheus.js";
import { Effect } from "effect";
import { makeEffectProducerHandle, type EffectProducer } from "./runtime.js";
export class Producer<T> {
private backend: BackendProducer<T> | null = null;
private running = false;
private effectProducer: EffectProducer<T> | null = null;
private readonly pubsub: PubSubBackend;
private readonly topic: string;
private readonly metrics: ProducerMetrics | undefined;
constructor(
private readonly pubsub: PubSubBackend,
private readonly topic: string,
private readonly metrics?: ProducerMetrics,
) {}
constructor(pubsub: PubSubBackend, topic: string, metrics?: ProducerMetrics) {
this.pubsub = pubsub;
this.topic = topic;
this.metrics = metrics;
}
async start(): Promise<void> {
this.backend = await this.pubsub.createProducer<T>({ topic: this.topic });
this.running = true;
this.effectProducer = makeEffectProducerHandle(this.backend, {
topic: this.topic,
...(this.metrics === undefined ? {} : { metrics: this.metrics }),
});
}
async send(id: string, message: T): Promise<void> {
if (!this.backend) throw new Error("Producer not started");
if (this.effectProducer === null) throw new Error("Producer not started");
await this.backend.send(message, { id });
this.metrics?.inc();
await Effect.runPromise(this.effectProducer.send(id, message));
}
async stop(): Promise<void> {
this.running = false;
if (this.backend) {
await this.backend.flush();
await this.backend.close();
if (this.effectProducer !== null) {
await Effect.runPromise(
this.effectProducer.flush.pipe(
Effect.flatMap(() => this.effectProducer === null ? Effect.void : this.effectProducer.close),
),
);
this.effectProducer = null;
this.backend = null;
}
}

View file

@ -23,7 +23,7 @@ export class RequestResponse<TReq, TRes> {
private producer: Producer<TReq>;
private subscriber: Subscriber<TRes>;
constructor(private readonly options: RequestResponseOptions) {
constructor(options: RequestResponseOptions) {
this.producer = new Producer<TReq>(options.pubsub, options.requestTopic);
this.subscriber = new Subscriber<TRes>(
options.pubsub,
@ -77,7 +77,7 @@ export class RequestResponse<TReq, TRes> {
const response = await queue.pop(remaining);
if (recipient) {
if (recipient !== undefined) {
const isFinal = await recipient(response);
if (isFinal) return response;
} else {

View file

@ -0,0 +1,612 @@
/**
* Effect-native messaging factories and scoped runtime helpers.
*/
import { randomUUID } from "node:crypto";
import { Context, Duration, Effect, Fiber, Layer, Queue, Scope } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import type {
BackendConsumer,
BackendProducer,
CreateConsumerOptions,
CreateProducerOptions,
Message,
} from "../backend/types.js";
import { PubSub, type PubSubService } from "../backend/pubsub.js";
import {
flowRuntimeError,
messagingDeliveryError,
messagingHandlerError,
messagingLifecycleError,
messagingTimeoutError,
TooManyRequestsError,
type FlowRuntimeError,
type MessagingDeliveryError,
type MessagingLifecycleError,
type MessagingTimeoutError,
type PubSubError,
} from "../errors.js";
import type { ProducerMetrics } from "../metrics/prometheus.js";
import type { FlowContext } from "./consumer.js";
import type { Flow } from "../processor/flow.js";
import type { SpecRuntimeRequirements } from "../spec/types.js";
import {
loadMessagingRuntimeConfig,
type MessagingRuntimeConfig,
} from "../runtime/messaging-config.js";
const isTooManyRequestsError = S.is(TooManyRequestsError);
export type EffectMessageHandler<T, E = never, R = never> = (
message: T,
properties: Record<string, string>,
flow: FlowContext<R>,
) => Effect.Effect<void, E, R>;
export interface EffectProducerOptions {
readonly topic: string;
readonly schema?: S.Top;
readonly metrics?: ProducerMetrics;
}
export interface EffectProducer<T> {
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError>;
readonly flush: Effect.Effect<void, MessagingDeliveryError>;
readonly close: Effect.Effect<void, MessagingDeliveryError>;
}
export interface EffectConsumerOptions<T, E = never, R = never> {
readonly topic: string;
readonly subscription: string;
readonly handler: EffectMessageHandler<T, E, R>;
readonly concurrency?: number;
readonly initialPosition?: "latest" | "earliest";
readonly schema?: S.Top;
readonly receiveTimeoutMs?: number;
readonly errorBackoffMs?: number;
readonly rateLimitRetryMs?: number;
}
export interface EffectConsumer {
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
readonly fibers: ReadonlyArray<Fiber.Fiber<void, never>>;
}
export interface EffectRequestResponseOptions {
readonly requestTopic: string;
readonly responseTopic: string;
readonly subscription: string;
readonly requestSchema?: S.Top;
readonly responseSchema?: S.Top;
}
export interface EffectRequestOptions<TRes, E = never, R = never> {
readonly timeoutMs?: number;
readonly recipient?: (response: TRes) => Effect.Effect<boolean, E, R>;
}
export interface EffectRequestResponse<TReq, TRes> {
readonly request: <E = never, R = never>(
request: TReq,
options?: EffectRequestOptions<TRes, E, R>,
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingTimeoutError | E, R>;
readonly stop: Effect.Effect<void, MessagingLifecycleError | MessagingDeliveryError>;
}
export interface ProducerFactoryService {
readonly make: <T>(
options: EffectProducerOptions,
) => Effect.Effect<EffectProducer<T>, PubSubError, Scope.Scope>;
}
export interface ConsumerFactoryService {
readonly run: <T, E = never, R = never>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) => Effect.Effect<EffectConsumer, PubSubError, Scope.Scope | R>;
}
export interface RequestResponseFactoryService {
readonly make: <TReq, TRes>(
options: EffectRequestResponseOptions,
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, PubSubError, Scope.Scope>;
}
export interface FlowRuntimeService {
readonly run: <Requirements = never>(
flow: Flow<Requirements>,
) => Effect.Effect<void, FlowRuntimeError, SpecRuntimeRequirements | Requirements>;
}
export class ProducerFactory extends Context.Service<ProducerFactory, ProducerFactoryService>()(
"@trustgraph/base/messaging/runtime/ProducerFactory",
) {}
export class ConsumerFactory extends Context.Service<ConsumerFactory, ConsumerFactoryService>()(
"@trustgraph/base/messaging/runtime/ConsumerFactory",
) {}
export class RequestResponseFactory extends Context.Service<
RequestResponseFactory,
RequestResponseFactoryService
>()("@trustgraph/base/messaging/runtime/RequestResponseFactory") {}
export class FlowRuntime extends Context.Service<FlowRuntime, FlowRuntimeService>()(
"@trustgraph/base/messaging/runtime/FlowRuntime",
) {}
export function makeEffectProducerHandle<T>(
backend: BackendProducer<T>,
options: EffectProducerOptions,
): 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(
Effect.tap(() =>
options.metrics === undefined
? Effect.void
: Effect.sync(() => {
options.metrics?.inc();
}),
),
),
),
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),
}),
};
}
export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* <T>(
pubsub: PubSubService,
options: EffectProducerOptions,
) {
const createOptions: CreateProducerOptions = options.schema === undefined
? { topic: options.topic }
: { topic: options.topic, schema: options.schema };
const backend = yield* pubsub.createProducer<T>(createOptions);
const producer = makeEffectProducerHandle(backend, options);
yield* Effect.addFinalizer(() =>
producer.close.pipe(
Effect.catch((error) =>
Effect.logError("[Producer] Failed to close producer", {
error: error.message,
topic: error.topic,
}),
),
),
);
return producer;
});
const closeConsumerBackend = <T>(
backend: BackendConsumer<T>,
topic: string,
subscription: string,
) =>
Effect.tryPromise({
try: () => backend.close(),
catch: (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),
});
const negativeAcknowledgeMessage = <T>(
backend: BackendConsumer<T>,
message: Message<T>,
topic: string,
) =>
Effect.tryPromise({
try: () => backend.negativeAcknowledge(message),
catch: (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),
});
const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* <T, E, R>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
message: Message<T>,
config: MessagingRuntimeConfig,
) {
const runHandler = Effect.fn(`Consumer.handler:${options.topic}`)(() =>
options.handler(message.value(), message.properties(), flow).pipe(
Effect.mapError((error) => messagingHandlerError(options.topic, options.subscription, error)),
),
);
return yield* options.handler(message.value(), message.properties(), flow).pipe(
Effect.catch((error) => {
if (isTooManyRequestsError(error)) {
return Effect.gen(function* () {
yield* Effect.logWarning("[Consumer] Rate limited, retrying", {
topic: options.topic,
subscription: options.subscription,
retryMs: config.rateLimitRetryMs,
});
yield* Effect.sleep(Duration.millis(config.rateLimitRetryMs));
yield* runHandler();
});
}
return Effect.fail(messagingHandlerError(options.topic, options.subscription, error));
}),
);
});
const processConsumerMessage = Effect.fn("processConsumerMessage")(function* <T, E, R>(
backend: BackendConsumer<T>,
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
message: Message<T>,
config: MessagingRuntimeConfig,
) {
yield* handleMessageWithRetry(options, flow, message, config).pipe(
Effect.flatMap(() => acknowledgeMessage(backend, message, options.topic)),
Effect.catch((error) =>
negativeAcknowledgeMessage(backend, message, options.topic).pipe(
Effect.catch((nakError) =>
Effect.logError("[Consumer] Failed to negative-acknowledge message", {
error: nakError.message,
topic: nakError.topic,
}),
),
Effect.flatMap(() =>
Effect.logError("[Consumer] Message handling failed", {
error: error.message,
topic: options.topic,
subscription: options.subscription,
}),
),
),
),
);
});
const consumerLoop = <T, E, R>(
backend: BackendConsumer<T>,
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
config: MessagingRuntimeConfig,
): Effect.Effect<void, never, R> =>
Effect.whileLoop({
while: () => true,
body: () =>
receiveMessage(backend, options.topic, options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs).pipe(
Effect.flatMap((message) =>
message === null
? Effect.sleep(Duration.millis(options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs))
: processConsumerMessage(backend, options, flow, message, config),
),
Effect.catch((error) =>
Effect.logError("[Consumer] Receive loop failed", {
error: error.message,
topic: options.topic,
subscription: options.subscription,
}).pipe(
Effect.flatMap(() =>
Effect.sleep(Duration.millis(options.errorBackoffMs ?? config.consumerErrorBackoffMs)),
),
),
),
),
step: () => undefined,
});
export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPubSub")(function* <T, E, R>(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) {
const createOptions: CreateConsumerOptions = {
topic: options.topic,
subscription: options.subscription,
...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }),
...(options.schema === undefined ? {} : { schema: options.schema }),
};
const backend = yield* pubsub.createConsumer<T>(createOptions);
const concurrency = Math.max(1, options.concurrency ?? 1);
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
const fibers = yield* Effect.forEach(workerIndexes, () =>
consumerLoop(backend, options, flow, {
...config,
rateLimitRetryMs: options.rateLimitRetryMs ?? config.rateLimitRetryMs,
}).pipe(Effect.forkChild),
);
const stop = Effect.fn(`Consumer.stop:${options.topic}`)(function* () {
yield* Effect.forEach(fibers, Fiber.interrupt, { discard: true });
yield* closeConsumerBackend(backend, options.topic, options.subscription);
});
yield* Effect.addFinalizer(() =>
stop().pipe(
Effect.catch((error) =>
Effect.logError("[Consumer] Failed to stop consumer", {
error: error.message,
resource: error.resource,
operation: error.operation,
}),
),
),
);
return {
fibers,
stop: stop(),
} satisfies EffectConsumer;
});
const dispatchResponseLoop = <T>(
backend: BackendConsumer<T>,
responseTopic: string,
subscribers: Map<string, Queue.Queue<T>>,
config: MessagingRuntimeConfig,
): Effect.Effect<void> =>
Effect.whileLoop({
while: () => true,
body: () =>
receiveMessage(backend, responseTopic, config.consumerReceiveTimeoutMs).pipe(
Effect.flatMap((message) => {
if (message === null) {
return Effect.sleep(Duration.millis(config.consumerReceiveTimeoutMs));
}
const id = message.properties().id;
const queue = id === undefined ? undefined : subscribers.get(id);
return Effect.gen(function* () {
if (queue !== undefined) {
yield* Queue.offer(queue, message.value());
}
yield* acknowledgeMessage(backend, message, responseTopic);
});
}),
Effect.catch((error) =>
Effect.logError("[RequestResponse] Response dispatch failed", {
error: error.message,
topic: responseTopic,
}).pipe(Effect.flatMap(() => Effect.sleep(Duration.millis(config.consumerErrorBackoffMs)))),
),
),
step: () => undefined,
});
const waitForResponse = Effect.fn("waitForResponse")(function* <TRes, E, R>(
queue: Queue.Queue<TRes>,
options: EffectRequestOptions<TRes, E, R> | undefined,
) {
while (true) {
const response = yield* Queue.take(queue);
if (options?.recipient === undefined) {
return response;
}
const complete = yield* options.recipient(response);
if (complete) {
return response;
}
}
});
export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestResponseFromPubSub")(function* <
TReq,
TRes,
>(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
options: EffectRequestResponseOptions,
) {
const producer = yield* makeEffectProducerFromPubSub<TReq>(pubsub, {
topic: options.requestTopic,
...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }),
});
const createOptions: CreateConsumerOptions = {
topic: options.responseTopic,
subscription: options.subscription,
...(options.responseSchema === undefined ? {} : { schema: options.responseSchema }),
};
const backend = yield* pubsub.createConsumer<TRes>(createOptions);
const subscribers = new Map<string, Queue.Queue<TRes>>();
const fiber = yield* dispatchResponseLoop(backend, options.responseTopic, subscribers, config).pipe(Effect.forkChild);
const stop = Effect.fn(`RequestResponse.stop:${options.requestTopic}`)(function* () {
yield* Fiber.interrupt(fiber);
yield* producer.close;
yield* closeConsumerBackend(backend, options.responseTopic, options.subscription);
});
yield* Effect.addFinalizer(() =>
stop().pipe(
Effect.catch((error) =>
Effect.logError("[RequestResponse] Failed to stop runtime", {
error: error.message,
}),
),
),
);
return {
request: <E = never, R = never>(
request: TReq,
requestOptions?: EffectRequestOptions<TRes, E, R>,
) => {
const id = randomUUID();
const timeoutMs = requestOptions?.timeoutMs ?? config.requestTimeoutMs;
return Effect.acquireUseRelease(
Queue.unbounded<TRes>().pipe(
Effect.tap((queue) =>
Effect.sync(() => {
subscribers.set(id, queue);
}),
),
),
(queue) =>
Effect.gen(function* () {
yield* producer.send(id, request);
const result = yield* waitForResponse(queue, requestOptions).pipe(
Effect.timeoutOption(Duration.millis(timeoutMs)),
);
return yield* O.match(result, {
onNone: () => Effect.fail(messagingTimeoutError("request-response", timeoutMs)),
onSome: Effect.succeed,
});
}),
(queue) =>
Effect.sync(() => {
subscribers.delete(id);
}).pipe(
Effect.flatMap(() => Queue.shutdown(queue)),
Effect.ignore,
),
);
},
stop: stop(),
} satisfies EffectRequestResponse<TReq, TRes>;
});
export function makeProducerFactoryService(pubsub: PubSubService): ProducerFactoryService {
return {
make: Effect.fn("ProducerFactory.make")(<T>(options: EffectProducerOptions) =>
makeEffectProducerFromPubSub<T>(pubsub, options),
),
};
}
export function makeConsumerFactoryService(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
): ConsumerFactoryService {
return {
run: Effect.fn("ConsumerFactory.run")(<T, E = never, R = never>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) =>
makeEffectConsumerFromPubSub(pubsub, config, options, flow),
),
};
}
export function makeRequestResponseFactoryService(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
): RequestResponseFactoryService {
const make = Effect.fn("RequestResponseFactory.make")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
) {
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
}) as RequestResponseFactoryService["make"];
return { make };
}
export const ProducerFactoryLive = Layer.effect(
ProducerFactory,
Effect.gen(function* () {
const pubsub = yield* PubSub;
return ProducerFactory.of(makeProducerFactoryService(pubsub));
}),
);
export const ConsumerFactoryLive = Layer.effect(
ConsumerFactory,
Effect.gen(function* () {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return ConsumerFactory.of(makeConsumerFactoryService(pubsub, config));
}),
);
export const RequestResponseFactoryLive = Layer.effect(
RequestResponseFactory,
Effect.gen(function* () {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, config));
}),
);
export const runFlowRuntimeScoped = Effect.fn("FlowRuntime.run")(function* <Requirements = never>(
flow: Flow<Requirements>,
) {
yield* flow.startEffect().pipe(
Effect.mapError((error) => flowRuntimeError(flow.name, "start", error)),
);
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
flow.clearResources();
}),
);
});
export const FlowRuntimeLive = Layer.succeed(
FlowRuntime,
FlowRuntime.of({
run: runFlowRuntimeScoped,
}),
);
export const MessagingRuntimeLive = Layer.mergeAll(
ProducerFactoryLive,
ConsumerFactoryLive,
RequestResponseFactoryLive,
FlowRuntimeLive,
);
export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* <T>(
options: EffectProducerOptions,
) {
const pubsub = yield* PubSub;
return yield* makeEffectProducerFromPubSub<T>(pubsub, options);
});
export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(function* <T, E = never, R = never>(
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return yield* makeEffectConsumerFromPubSub(pubsub, config, options, flow);
});
export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
) {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
});
export const runFlowScoped = Effect.fn("runFlowScoped")(function* (
flow: Flow,
) {
yield* runFlowRuntimeScoped(flow);
});

View file

@ -19,7 +19,7 @@ export class AsyncQueue<T> {
push(item: T): void {
const waiter = this.waiters.shift();
if (waiter) {
if (waiter !== undefined) {
waiter(item);
} else {
this.buffer.push(item);
@ -34,7 +34,7 @@ export class AsyncQueue<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const waiter = (value: T) => {
if (timer) clearTimeout(timer);
if (timer !== undefined) clearTimeout(timer);
resolve(value);
};
@ -58,17 +58,20 @@ export class AsyncQueue<T> {
export class Subscriber<T> {
private backend: BackendConsumer<T> | null = null;
private running = false;
private readonly pubsub: PubSubBackend;
private readonly topic: string;
private readonly subscription: string;
// ID-specific subscriptions (request/response correlation)
private idSubscribers = new Map<string, Resolver<T>>();
// Wildcard subscribers (receive all messages)
private allSubscribers = new Map<string, Resolver<T>>();
constructor(
private readonly pubsub: PubSubBackend,
private readonly topic: string,
private readonly subscription: string,
) {}
constructor(pubsub: PubSubBackend, topic: string, subscription: string) {
this.pubsub = pubsub;
this.topic = topic;
this.subscription = subscription;
}
async start(): Promise<void> {
this.backend = await this.pubsub.createConsumer<T>({
@ -78,13 +81,13 @@ export class Subscriber<T> {
this.running = true;
// Start the dispatch loop (fire and forget — runs until stop)
this.dispatchLoop().catch((err) => {
if (this.running) console.error("[Subscriber] dispatch loop error:", err);
if (this.running === true) console.error("[Subscriber] dispatch loop error:", err);
});
}
async stop(): Promise<void> {
this.running = false;
if (this.backend) {
if (this.backend !== null) {
await this.backend.close();
this.backend = null;
}
@ -114,8 +117,11 @@ export class Subscriber<T> {
let consecutiveErrors = 0;
while (this.running) {
try {
const msg = await this.backend!.receive(2000);
if (!msg) continue;
const backend = this.backend;
if (backend === null) throw new Error("Subscriber backend not started");
const msg = await backend.receive(2000);
if (msg === null) continue;
consecutiveErrors = 0;
@ -124,9 +130,9 @@ export class Subscriber<T> {
const value = msg.value();
// Route to ID-specific subscriber
if (id) {
if (id !== undefined && id.length > 0) {
const sub = this.idSubscribers.get(id);
if (sub) {
if (sub !== undefined) {
sub.queue.push(value);
}
}
@ -136,7 +142,7 @@ export class Subscriber<T> {
sub.queue.push(value);
}
await this.backend!.acknowledge(msg);
await backend.acknowledge(msg);
} catch (err) {
if (!this.running) break;
consecutiveErrors++;

View file

@ -13,8 +13,10 @@ export class ConsumerMetrics {
private requestHistogram: Histogram;
private processingCounter: Counter;
private rateLimitCounter: Counter;
private readonly labels: { processor: string; flow: string; name: string };
constructor(processor: string, flow: string, name: string) {
this.labels = { processor, flow, name };
this.requestHistogram = new Histogram({
name: "tg_consumer_request_duration_seconds",
help: "Consumer request processing time",
@ -38,22 +40,24 @@ export class ConsumerMetrics {
}
recordTime(seconds: number): void {
this.requestHistogram.observe(seconds);
this.requestHistogram.observe(this.labels, seconds);
}
process(status: "success" | "error"): void {
this.processingCounter.inc({ status });
this.processingCounter.inc({ ...this.labels, status });
}
rateLimit(): void {
this.rateLimitCounter.inc();
this.rateLimitCounter.inc(this.labels);
}
}
export class ProducerMetrics {
private counter: Counter;
private readonly labels: { processor: string; flow: string; name: string };
constructor(processor: string, flow: string, name: string) {
this.labels = { processor, flow, name };
this.counter = new Counter({
name: "tg_producer_items_total",
help: "Producer items sent",
@ -63,6 +67,6 @@ export class ProducerMetrics {
}
inc(): void {
this.counter.inc();
this.counter.inc(this.labels);
}
}

View file

@ -8,12 +8,16 @@
import type { PubSubBackend } from "../backend/types.js";
import { NatsBackend } from "../backend/nats.js";
import { topics } from "../schema/topics.js";
import { Effect } from "effect";
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
import { loadProcessorRuntimeConfig } from "../runtime/config.js";
export interface ProcessorConfig {
id: string;
pubsubUrl?: string;
metricsPort?: number;
manageProcessSignals?: boolean;
pubsub?: PubSubBackend;
}
export type ConfigHandler = (
@ -21,14 +25,29 @@ export type ConfigHandler = (
version: number,
) => Promise<void>;
export abstract class AsyncProcessor {
export type EffectConfigHandler<E = never, R = never> = (
config: Record<string, unknown>,
version: number,
) => Effect.Effect<void, E, R>;
interface RegisteredSignalHandler {
readonly signal: NodeJS.Signals;
readonly handler: () => void;
}
export abstract class AsyncProcessor<RunError = ProcessorLifecycleError, RunRequirements = never> {
protected pubsub: PubSubBackend;
protected running = false;
protected configHandlers: ConfigHandler[] = [];
private shutdownCallbacks: Array<() => Promise<void>> = [];
private signalHandlers: RegisteredSignalHandler[] = [];
private readonly ownsPubSub: boolean;
protected readonly config: ProcessorConfig;
constructor(protected readonly config: ProcessorConfig) {
this.pubsub = new NatsBackend(config.pubsubUrl ?? "nats://localhost:4222");
constructor(config: ProcessorConfig) {
this.config = config;
this.pubsub = config.pubsub ?? new NatsBackend(config.pubsubUrl ?? "nats://localhost:4222");
this.ownsPubSub = config.pubsub === undefined;
}
registerConfigHandler(handler: ConfigHandler): void {
@ -36,47 +55,107 @@ export abstract class AsyncProcessor {
}
async start(): Promise<void> {
this.running = true;
// Set up graceful shutdown
const shutdown = async () => {
console.log(`[${this.config.id}] Shutting down...`);
await this.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await this.run();
await Effect.runPromise(
this.startEffect() as Effect.Effect<void, RunError | ProcessorLifecycleError>,
);
}
async stop(): Promise<void> {
this.running = false;
for (const cb of this.shutdownCallbacks) {
await cb();
}
await this.pubsub.close();
await Effect.runPromise(this.stopEffect());
}
protected onShutdown(callback: () => Promise<void>): void {
this.shutdownCallbacks.push(callback);
}
protected abstract run(): Promise<void>;
startEffect(): Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements> {
const processor = this;
return Effect.gen(function* () {
yield* Effect.sync(() => {
processor.running = true;
processor.registerProcessSignalHandlers();
});
yield* processor.runEffect();
}).pipe(
Effect.withSpan("trustgraph.processor.start", {
attributes: {
"trustgraph.processor.id": processor.config.id,
},
}),
);
}
stopEffect(): Effect.Effect<void, ProcessorLifecycleError> {
const processor = this;
return Effect.gen(function* () {
yield* Effect.sync(() => {
processor.running = false;
processor.unregisterProcessSignalHandlers();
});
for (const cb of processor.shutdownCallbacks) {
yield* Effect.tryPromise({
try: () => cb(),
catch: (error) => processorLifecycleError(processor.config.id, "shutdown-callback", error),
});
}
if (processor.ownsPubSub) {
yield* Effect.tryPromise({
try: () => processor.pubsub.close(),
catch: (error) => processorLifecycleError(processor.config.id, "close-pubsub", error),
});
}
});
}
protected run(): Promise<void> {
return Effect.runPromise(this.runEffect() as unknown as Effect.Effect<void, RunError>);
}
protected runEffect(): Effect.Effect<void, RunError, RunRequirements> {
return Effect.tryPromise({
try: () => this.run(),
catch: (error) => processorLifecycleError(this.config.id, "start", error),
}) as unknown as Effect.Effect<void, RunError, RunRequirements>;
}
private registerProcessSignalHandlers(): void {
if (this.config.manageProcessSignals === false || this.signalHandlers.length > 0) {
return;
}
const shutdown = () => {
console.log(`[${this.config.id}] Shutting down...`);
void this.stop().then(() => process.exit(0));
};
const handlers: RegisteredSignalHandler[] = [
{ signal: "SIGINT", handler: shutdown },
{ signal: "SIGTERM", handler: shutdown },
];
for (const { signal, handler } of handlers) {
process.once(signal, handler);
}
this.signalHandlers = handlers;
}
private unregisterProcessSignalHandlers(): void {
for (const { signal, handler } of this.signalHandlers) {
process.off(signal, handler);
}
this.signalHandlers = [];
}
/**
* Static launch helper parses env/args and starts the processor.
* Subclasses call: `MyProcessor.launch("my-service")`
*/
static async launch<T extends AsyncProcessor>(
static async launch<T extends AsyncProcessor<unknown, unknown>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void> {
const config: ProcessorConfig = {
id,
pubsubUrl: process.env.NATS_URL ?? process.env.PULSAR_HOST,
metricsPort: parseInt(process.env.METRICS_PORT ?? "8000", 10),
};
const config = await Effect.runPromise(loadProcessorRuntimeConfig(id));
const processor = new this(config);
await processor.start();
}

View file

@ -12,15 +12,53 @@ import type { Spec } from "../spec/types.js";
import type { BackendConsumer } from "../backend/types.js";
import { Flow, type FlowDefinition } from "./flow.js";
import { topics } from "../schema/topics.js";
import {
pubSubError,
type FlowRuntimeError,
type ProcessorLifecycleError,
type PubSubError,
} from "../errors.js";
import {
ConsumerFactory,
FlowRuntime,
ProducerFactory,
RequestResponseFactory,
makeConsumerFactoryService,
makeProducerFactoryService,
makeRequestResponseFactoryService,
runFlowRuntimeScoped,
} from "../messaging/runtime.js";
import { makePubSubService, PubSub } from "../backend/pubsub.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import { Duration, Effect, Exit, Scope } from "effect";
import * as S from "effect/Schema";
interface ConfigPush {
version: number;
config: Record<string, unknown>;
}
export abstract class FlowProcessor extends AsyncProcessor {
private specifications: Spec[] = [];
private flows = new Map<string, Flow>();
interface ActiveFlow {
readonly scope: Scope.Closeable;
}
const ConfigPushSchema = S.Struct({
version: S.Number,
config: S.Record(S.String, S.Unknown),
});
export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProcessor<
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
| PubSub
| FlowRuntime
| ProducerFactory
| ConsumerFactory
| RequestResponseFactory
| Scope.Scope
| FlowRequirements
> {
private specifications: Array<Spec<FlowRequirements>> = [];
private flows = new Map<string, ActiveFlow>();
private configConsumer: BackendConsumer<ConfigPush> | null = null;
private lastFlowsJson = "";
@ -28,110 +66,254 @@ export abstract class FlowProcessor extends AsyncProcessor {
super(config);
}
registerSpecification(spec: Spec): void {
this.specifications.push(spec);
registerSpecification<Requirements extends FlowRequirements>(
spec: Spec<Requirements>,
): void {
this.specifications.push(spec as Spec<FlowRequirements>);
}
protected async run(): Promise<void> {
// Subscribe to config-push topic to receive flow definitions.
// Use "earliest" to replay any config pushes that arrived before this service started.
this.configConsumer = await this.pubsub.createConsumer<ConfigPush>({
topic: topics.configPush,
subscription: `${this.config.id}-config-push`,
initialPosition: "earliest",
override async start(): Promise<void> {
const pubsub = makePubSubService(this.pubsub);
const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig());
const start = this.startEffect().pipe(
Effect.provideService(PubSub, pubsub),
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
),
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
) as Effect.Effect<void, PubSubError | FlowRuntimeError | ProcessorLifecycleError>;
await Effect.runPromise(
Effect.scoped(
start,
),
);
}
protected override runEffect(): Effect.Effect<
void,
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
| PubSub
| FlowRuntime
| ProducerFactory
| ConsumerFactory
| RequestResponseFactory
| Scope.Scope
| FlowRequirements
> {
const processor = this;
return Effect.gen(function* () {
const pubsub = yield* PubSub;
// Subscribe to config-push topic to receive flow definitions.
// Use "earliest" to replay any config pushes that arrived before this service started.
processor.configConsumer = yield* pubsub.createConsumer<ConfigPush>({
topic: topics.configPush,
subscription: `${processor.config.id}-config-push`,
initialPosition: "earliest",
schema: ConfigPushSchema,
});
yield* Effect.addFinalizer(() =>
processor.closeConfigConsumerEffect().pipe(
Effect.flatMap(() => processor.closeAllFlowsEffect()),
),
);
yield* Effect.log(`[${processor.config.id}] Listening for config pushes on ${topics.configPush}`);
yield* Effect.whileLoop({
while: () => processor.running,
body: () => processor.processNextConfigPushEffect(),
step: () => undefined,
});
});
}
console.log(`[${this.config.id}] Listening for config pushes on ${topics.configPush}`);
private onConfigureFlowsEffect(
config: Record<string, unknown>,
_version: number,
): Effect.Effect<
void,
FlowRuntimeError,
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
> {
const processor = this;
return Effect.gen(function* () {
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
if (flowDefs === undefined) {
yield* Effect.log(`[${processor.config.id}] No flows in config push, skipping`);
return;
}
while (this.running) {
try {
const msg = await this.configConsumer.receive(2000);
if (!msg) continue;
// Skip flow restart if the flow definitions haven't changed.
// This prevents disrupting in-flight requests when non-flow config
// sections (prompts, tools, mcp) are updated.
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
Effect.catch((error) => Effect.succeed(String(error))),
);
if (processor.lastFlowsJson.length > 0 && flowsJson === processor.lastFlowsJson && processor.flows.size > 0) {
yield* Effect.log(`[${processor.config.id}] Flow definitions unchanged, skipping restart`);
return;
}
processor.lastFlowsJson = flowsJson;
const push = msg.value();
console.log(`[${this.config.id}] Received config push version=${push.version}`);
// Stop removed flows
for (const [name, activeFlow] of processor.flows) {
if (!(name in flowDefs)) {
yield* Effect.log(`[${processor.config.id}] Stopping removed flow: ${name}`);
yield* processor.closeFlowEffect(name, activeFlow);
processor.flows.delete(name);
}
}
await this.onConfigureFlows(push.config, push.version);
// Also call any registered config handlers
for (const handler of this.configHandlers) {
await handler(push.config, push.version);
// Start or update flows
for (const [name, defn] of Object.entries(flowDefs)) {
// Skip invalid definitions (e.g., stringified JSON)
if (typeof defn !== "object" || defn === null) {
yield* Effect.logWarning(`[${processor.config.id}] Skipping flow "${name}": definition is not an object`);
continue;
}
await this.configConsumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error(`[${this.config.id}] Config consumer error:`, err);
await sleep(1000);
// Stop existing flow before (re)starting with new config
const existing = processor.flows.get(name);
if (existing !== undefined) {
yield* Effect.log(`[${processor.config.id}] Restarting flow "${name}" with updated config`);
yield* processor.closeFlowEffect(name, existing);
processor.flows.delete(name);
}
yield* Effect.log(`[${processor.config.id}] Starting flow "${name}"`);
const activeFlow = yield* processor.startFlowEffect(name, defn);
processor.flows.set(name, activeFlow);
yield* Effect.log(`[${processor.config.id}] Flow "${name}" started`);
}
}
});
}
private async onConfigureFlows(
config: Record<string, unknown>,
version: number,
): Promise<void> {
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
if (!flowDefs) {
console.log(`[${this.config.id}] No flows in config push, skipping`);
return;
}
// Skip flow restart if the flow definitions haven't changed.
// This prevents disrupting in-flight requests when non-flow config
// sections (prompts, tools, mcp) are updated.
const flowsJson = JSON.stringify(flowDefs);
if (this.lastFlowsJson && flowsJson === this.lastFlowsJson && this.flows.size > 0) {
console.log(`[${this.config.id}] Flow definitions unchanged, skipping restart`);
return;
}
this.lastFlowsJson = flowsJson;
// Stop removed flows
for (const [name, flow] of this.flows) {
if (!(name in flowDefs)) {
console.log(`[${this.config.id}] Stopping removed flow: ${name}`);
await flow.stop();
this.flows.delete(name);
}
}
// Start or update flows
for (const [name, defn] of Object.entries(flowDefs)) {
// Skip invalid definitions (e.g., stringified JSON)
if (typeof defn !== "object" || defn === null) {
console.warn(`[${this.config.id}] Skipping flow "${name}": definition is not an object`);
continue;
}
// Stop existing flow before (re)starting with new config
if (this.flows.has(name)) {
console.log(`[${this.config.id}] Restarting flow "${name}" with updated config`);
await this.flows.get(name)!.stop();
this.flows.delete(name);
}
console.log(`[${this.config.id}] Starting flow "${name}" with topics:`, defn.topics);
const flow = new Flow(name, this.config.id, this.pubsub, defn, this.specifications);
await flow.start();
this.flows.set(name, flow);
console.log(`[${this.config.id}] Flow "${name}" started`);
}
override stopEffect(): Effect.Effect<void, ProcessorLifecycleError> {
return this.closeConfigConsumerEffect().pipe(
Effect.flatMap(() => this.closeAllFlowsEffect()),
Effect.flatMap(() => super.stopEffect()),
);
}
override async stop(): Promise<void> {
if (this.configConsumer) {
await this.configConsumer.close();
this.configConsumer = null;
private processNextConfigPushEffect(): Effect.Effect<
void,
never,
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
> {
const processor = this;
return Effect.gen(function* () {
const consumer = processor.configConsumer;
if (consumer === null) {
yield* Effect.sleep(Duration.millis(1000));
return;
}
const msg = yield* Effect.tryPromise({
try: () => consumer.receive(2000),
catch: (error) => pubSubError("receive:config-push", error),
});
if (msg === null) {
return;
}
const push = msg.value();
yield* Effect.log(`[${processor.config.id}] Received config push version=${push.version}`);
yield* processor.onConfigureFlowsEffect(push.config, push.version);
for (const handler of processor.configHandlers) {
yield* Effect.tryPromise({
try: () => handler(push.config, push.version),
catch: (error) => pubSubError("config-handler", error),
});
}
yield* Effect.tryPromise({
try: () => consumer.acknowledge(msg),
catch: (error) => pubSubError("acknowledge:config-push", error),
});
}).pipe(
Effect.catch((error) => {
if (!processor.running) {
return Effect.void;
}
return Effect.logError(`[${processor.config.id}] Config consumer error`, {
error: error.message,
}).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
);
}),
);
}
private startFlowEffect(
name: string,
definition: FlowDefinition,
): Effect.Effect<
ActiveFlow,
FlowRuntimeError,
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
> {
const processor = this;
return Effect.gen(function* () {
const flowRuntime = yield* FlowRuntime;
const scope = yield* Scope.make();
const flow = new Flow<FlowRequirements>(
name,
processor.config.id,
processor.pubsub,
definition,
processor.specifications,
);
return yield* flowRuntime.run(flow).pipe(
Scope.provide(scope),
Effect.as({ scope } satisfies ActiveFlow),
Effect.catch((error) =>
Scope.close(scope, Exit.void).pipe(
Effect.flatMap(() => Effect.fail(error)),
),
),
);
});
}
private closeFlowEffect(name: string, activeFlow: ActiveFlow): Effect.Effect<void> {
return Scope.close(activeFlow.scope, Exit.void).pipe(
Effect.tap(() => Effect.log(`[${this.config.id}] Flow "${name}" stopped`)),
);
}
private closeAllFlowsEffect(): Effect.Effect<void> {
const processor = this;
return Effect.gen(function* () {
const flows = Array.from(processor.flows.entries());
for (const [name, activeFlow] of flows) {
yield* processor.closeFlowEffect(name, activeFlow);
}
processor.flows.clear();
});
}
private closeConfigConsumerEffect(): Effect.Effect<void> {
const consumer = this.configConsumer;
this.configConsumer = null;
if (consumer === null) {
return Effect.void;
}
for (const flow of this.flows.values()) {
await flow.stop();
}
this.flows.clear();
await super.stop();
return Effect.tryPromise({
try: () => consumer.close(),
catch: (error) => pubSubError("close:config-push", error),
}).pipe(
Effect.catch((error) =>
Effect.logError(`[${this.config.id}] Failed to close config consumer`, {
error: error.message,
}),
),
);
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -4,11 +4,28 @@
* Python reference: trustgraph-base/trustgraph/base/flow.py
*/
import { Effect, Exit, Scope } from "effect";
import type { PubSubBackend } from "../backend/types.js";
import type { Spec } from "../spec/types.js";
import type { Producer } from "../messaging/producer.js";
import type { Consumer } from "../messaging/consumer.js";
import type { RequestResponse } from "../messaging/request-response.js";
import { makePubSubService } from "../backend/pubsub.js";
import {
flowResourceNotFoundError,
type FlowResourceNotFoundError,
type PubSubError,
} from "../errors.js";
import {
ConsumerFactory,
ProducerFactory,
RequestResponseFactory,
type EffectConsumer,
type EffectProducer,
type EffectRequestOptions,
type EffectRequestResponse,
makeConsumerFactoryService,
makeProducerFactoryService,
makeRequestResponseFactoryService,
} from "../messaging/runtime.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import type { Spec, SpecRuntimeRequirements } from "../spec/types.js";
export interface FlowDefinition {
/** Topic overrides keyed by spec name */
@ -17,54 +34,119 @@ export interface FlowDefinition {
parameters?: Record<string, unknown>;
}
export class Flow {
private producers = new Map<string, Producer<unknown>>();
private consumers = new Map<string, Consumer<unknown>>();
private requestors = new Map<string, RequestResponse<unknown, unknown>>();
export interface FlowProducer<T> {
readonly send: (id: string, message: T) => Promise<void>;
readonly flush: () => Promise<void>;
readonly stop: () => Promise<void>;
}
export interface FlowConsumer {
readonly stop: () => Promise<void>;
}
export interface FlowRequestOptions<TRes> {
readonly timeoutMs?: number;
readonly recipient?: (response: TRes) => Promise<boolean>;
}
export interface FlowRequestor<TReq, TRes> {
readonly request: (
request: TReq,
options?: FlowRequestOptions<TRes>,
) => Promise<TRes>;
readonly stop: () => Promise<void>;
}
export class Flow<Requirements = never> {
private producers = new Map<string, EffectProducer<unknown>>();
private consumers = new Map<string, EffectConsumer>();
private requestors = new Map<string, EffectRequestResponse<unknown, unknown>>();
private parameters = new Map<string, unknown>();
private compatibilityScope: Scope.Closeable | null = null;
public readonly name: string;
public readonly processorId: string;
private readonly pubsub: PubSubBackend;
private readonly definition: FlowDefinition;
private readonly specifications: ReadonlyArray<Spec<Requirements>>;
constructor(
public readonly name: string,
public readonly processorId: string,
private readonly pubsub: PubSubBackend,
private readonly definition: FlowDefinition,
private readonly specifications: Spec[],
) {}
name: string,
processorId: string,
pubsub: PubSubBackend,
definition: FlowDefinition,
specifications: ReadonlyArray<Spec<Requirements>>,
) {
this.name = name;
this.processorId = processorId;
this.pubsub = pubsub;
this.definition = definition;
this.specifications = specifications;
}
startEffect(): Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements> {
const flow = this;
return Effect.gen(function* () {
for (const spec of flow.specifications) {
yield* spec.addEffect(flow, flow.definition);
}
});
}
async start(): Promise<void> {
for (const spec of this.specifications) {
await spec.add(this, this.pubsub, this.definition);
}
// Start all consumers, passing this Flow instance via FlowContext
for (const consumer of this.consumers.values()) {
consumer.start({ id: this.processorId, name: this.name, flow: this }).catch((err) => {
console.error(`[Flow:${this.name}] Consumer error:`, err);
});
if (this.compatibilityScope !== null) {
await this.stop();
}
await this.runInCompatibilityScope(
this.startEffect() as Effect.Effect<void, PubSubError, SpecRuntimeRequirements>,
this.pubsub,
);
}
async stop(): Promise<void> {
for (const consumer of this.consumers.values()) {
await consumer.stop();
}
for (const producer of this.producers.values()) {
await producer.stop();
}
for (const rr of this.requestors.values()) {
await rr.stop();
const scope = this.compatibilityScope;
this.compatibilityScope = null;
if (scope !== null) {
await Effect.runPromise(Scope.close(scope, Exit.void));
}
this.clearResources();
}
registerProducer(name: string, producer: Producer<unknown>): void {
async runInCompatibilityScope<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements>,
pubsub: PubSubBackend,
): Promise<A> {
const scope = await this.ensureCompatibilityScope();
const pubsubService = makePubSubService(pubsub);
const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig());
return await Effect.runPromise(
effect.pipe(
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)),
),
Scope.provide(scope),
),
);
}
clearResources(): void {
this.producers.clear();
this.consumers.clear();
this.requestors.clear();
this.parameters.clear();
}
registerProducer(name: string, producer: EffectProducer<unknown>): void {
this.producers.set(name, producer);
}
registerConsumer(name: string, consumer: Consumer<unknown>): void {
registerConsumer(name: string, consumer: EffectConsumer): void {
this.consumers.set(name, consumer);
}
registerRequestor(name: string, rr: RequestResponse<unknown, unknown>): void {
registerRequestor(name: string, rr: EffectRequestResponse<unknown, unknown>): void {
this.requestors.set(name, rr);
}
@ -72,27 +154,97 @@ export class Flow {
this.parameters.set(name, value);
}
producer<T>(name: string): Producer<T> {
producerEffect<T>(name: string): Effect.Effect<EffectProducer<T>, FlowResourceNotFoundError> {
const p = this.producers.get(name);
if (!p) throw new Error(`Producer "${name}" not found in flow "${this.name}"`);
return p as Producer<T>;
return p === undefined
? Effect.fail(flowResourceNotFoundError(this.name, "producer", name))
: Effect.succeed(p as EffectProducer<T>);
}
consumer<T>(name: string): Consumer<T> {
consumerEffect(name: string): Effect.Effect<EffectConsumer, FlowResourceNotFoundError> {
const c = this.consumers.get(name);
if (!c) throw new Error(`Consumer "${name}" not found in flow "${this.name}"`);
return c as Consumer<T>;
return c === undefined
? Effect.fail(flowResourceNotFoundError(this.name, "consumer", name))
: Effect.succeed(c);
}
requestor<TReq, TRes>(name: string): RequestResponse<TReq, TRes> {
requestorEffect<TReq, TRes>(
name: string,
): Effect.Effect<EffectRequestResponse<TReq, TRes>, FlowResourceNotFoundError> {
const rr = this.requestors.get(name);
if (!rr) throw new Error(`Requestor "${name}" not found in flow "${this.name}"`);
return rr as RequestResponse<TReq, TRes>;
return rr === undefined
? Effect.fail(flowResourceNotFoundError(this.name, "requestor", name))
: Effect.succeed(rr as EffectRequestResponse<TReq, TRes>);
}
parameterEffect<T>(name: string): Effect.Effect<T, FlowResourceNotFoundError> {
const v = this.parameters.get(name);
return v === undefined
? Effect.fail(flowResourceNotFoundError(this.name, "parameter", name))
: Effect.succeed(v as T);
}
producer<T>(name: string): FlowProducer<T> {
const p = this.producers.get(name);
if (p === undefined) throw flowResourceNotFoundError(this.name, "producer", name);
return {
send: (id, message) => Effect.runPromise((p as EffectProducer<T>).send(id, message)),
flush: () => Effect.runPromise(p.flush),
stop: () => Effect.runPromise(p.flush.pipe(Effect.flatMap(() => p.close))),
};
}
consumer(name: string): FlowConsumer {
const c = this.consumers.get(name);
if (c === undefined) throw flowResourceNotFoundError(this.name, "consumer", name);
return {
stop: () => Effect.runPromise(c.stop),
};
}
requestor<TReq, TRes>(name: string): FlowRequestor<TReq, TRes> {
const rr = this.requestors.get(name);
if (rr === undefined) throw flowResourceNotFoundError(this.name, "requestor", name);
return {
request: (request, options) =>
Effect.runPromise(
(rr as EffectRequestResponse<TReq, TRes>).request(
request,
this.toEffectRequestOptions(options),
),
),
stop: () => Effect.runPromise(rr.stop),
};
}
parameter<T>(name: string): T {
const v = this.parameters.get(name);
if (v === undefined) throw new Error(`Parameter "${name}" not found in flow "${this.name}"`);
if (v === undefined) throw flowResourceNotFoundError(this.name, "parameter", name);
return v as T;
}
private async ensureCompatibilityScope(): Promise<Scope.Closeable> {
if (this.compatibilityScope !== null) {
return this.compatibilityScope;
}
this.compatibilityScope = await Effect.runPromise(Scope.make());
return this.compatibilityScope;
}
private toEffectRequestOptions<TRes>(
options: FlowRequestOptions<TRes> | undefined,
): EffectRequestOptions<TRes> | undefined {
if (options === undefined) {
return undefined;
}
const recipient = options.recipient;
return {
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
...(recipient === undefined
? {}
: {
recipient: (response: TRes) => Effect.promise(() => recipient(response)),
}),
};
}
}

View file

@ -1,3 +1,22 @@
export { AsyncProcessor, type ProcessorConfig, type ConfigHandler } from "./async-processor.js";
export {
AsyncProcessor,
type ConfigHandler,
type EffectConfigHandler,
type ProcessorConfig,
} from "./async-processor.js";
export { FlowProcessor } from "./flow-processor.js";
export { Flow, type FlowDefinition } from "./flow.js";
export {
Flow,
type FlowConsumer,
type FlowDefinition,
type FlowProducer,
type FlowRequestOptions,
type FlowRequestor,
} from "./flow.js";
export {
makeAsyncProcessorProgram,
makeFlowProcessorProgram,
makeProcessorProgram,
runProcessorScoped,
type ProcessorProgramOptions,
} from "./program.js";

View file

@ -0,0 +1,139 @@
/**
* Scoped Effect runtime helpers for legacy processor classes.
*
* These helpers make `Context.Service`/Layer composition the canonical
* executable path while the processor internals remain Promise-based.
*/
import { Effect, Scope } from "effect";
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
import { NatsBackend } from "../backend/nats.js";
import { makePubSubService, PubSub } from "../backend/pubsub.js";
import {
ConsumerFactory,
FlowRuntime,
ProducerFactory,
RequestResponseFactory,
makeConsumerFactoryService,
makeProducerFactoryService,
makeRequestResponseFactoryService,
runFlowRuntimeScoped,
} from "../messaging/runtime.js";
import {
loadProcessorRuntimeConfig,
type ProcessorRuntimeConfigOptions,
} from "../runtime/config.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import type { AsyncProcessor, ProcessorConfig } from "./async-processor.js";
type ProcessorRunError<Processor> = Processor extends AsyncProcessor<infer Error, unknown> ? Error : never;
type ProcessorRunRequirements<Processor> = Processor extends AsyncProcessor<unknown, infer Requirements> ? Requirements : never;
export interface ProcessorProgramOptions<
Config extends ProcessorConfig,
Error,
Requirements,
Processor extends AsyncProcessor<unknown, unknown>,
> {
readonly id: string;
readonly make: (config: Config) => Processor;
readonly loadConfig?: Effect.Effect<Config, Error, Requirements>;
}
export function runProcessorScoped<
Config extends ProcessorConfig,
Processor extends AsyncProcessor<unknown, unknown>,
>(
config: Config,
make: (config: Config) => Processor,
): Effect.Effect<
void,
ProcessorRunError<Processor> | ProcessorLifecycleError,
PubSub | Scope.Scope | ProcessorRunRequirements<Processor>
> {
return Effect.gen(function* () {
const pubsub = yield* PubSub;
const runtimeConfig = {
...config,
manageProcessSignals: false,
pubsub: pubsub.backend,
} as Config;
const processor = make(runtimeConfig);
yield* Effect.addFinalizer(() =>
Effect.tryPromise({
try: () => processor.stop(),
catch: (error) => processorLifecycleError(config.id, "stop", error),
}).pipe(
Effect.catch((error) =>
Effect.logError("[Processor] Failed to stop processor", {
error: error.message,
operation: error.operation,
processorId: error.processorId,
}),
),
),
);
const typedProcessor = processor as unknown as AsyncProcessor<
ProcessorRunError<Processor>,
ProcessorRunRequirements<Processor>
>;
yield* typedProcessor.startEffect();
});
}
export function makeProcessorProgram<
Config extends ProcessorConfig,
Error = never,
Requirements = never,
Processor extends AsyncProcessor<unknown, unknown> = AsyncProcessor,
>(
options: ProcessorProgramOptions<Config, Error, Requirements, Processor>,
) {
return Effect.scoped(
Effect.gen(function* () {
const config = yield* (
options.loadConfig ??
loadProcessorRuntimeConfig(options.id, {
manageProcessSignals: false,
} satisfies ProcessorRuntimeConfigOptions)
);
const runtimeConfig = {
...config,
manageProcessSignals: false,
} as Config;
const pubsub = makePubSubService(new NatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222"));
const messagingConfig = yield* loadMessagingRuntimeConfig();
yield* Effect.addFinalizer(() =>
pubsub.close.pipe(
Effect.catch((error) =>
Effect.logError("[PubSub] Failed to close processor backend", {
error: error.message,
operation: error.operation,
}),
),
),
);
const processorEffect = runProcessorScoped<Config, Processor>(
runtimeConfig,
options.make,
);
yield* processorEffect.pipe(
Effect.provideService(PubSub, pubsub),
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
Effect.provideService(
RequestResponseFactory,
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
),
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
);
}),
);
}
export const makeAsyncProcessorProgram = makeProcessorProgram;
export const makeFlowProcessorProgram = makeProcessorProgram;

View file

@ -0,0 +1,33 @@
/**
* Effect Config contracts for process/runtime settings.
*
* These declarations preserve the existing environment variable names while
* moving reads to a typed Effect boundary.
*/
import { Config, Effect } from "effect";
import * as O from "effect/Option";
export interface ProcessorRuntimeConfigOptions {
readonly manageProcessSignals?: boolean;
}
export const optionalStringConfig = Effect.fn("optionalStringConfig")(function* (name: string) {
return O.getOrUndefined(yield* Config.string(name).pipe(Config.option));
});
export const loadProcessorRuntimeConfig = Effect.fn("loadProcessorRuntimeConfig")(function* (
id: string,
options: ProcessorRuntimeConfigOptions = {},
) {
const natsUrl = yield* optionalStringConfig("NATS_URL");
const pulsarHost = yield* optionalStringConfig("PULSAR_HOST");
const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000));
return {
id,
pubsubUrl: natsUrl ?? pulsarHost ?? "nats://localhost:4222",
metricsPort,
manageProcessSignals: options.manageProcessSignals ?? true,
};
});

View file

@ -0,0 +1,10 @@
export {
defaultMessagingRuntimeConfig,
loadMessagingRuntimeConfig,
type MessagingRuntimeConfig,
} from "./messaging-config.js";
export {
loadProcessorRuntimeConfig,
optionalStringConfig,
type ProcessorRuntimeConfigOptions,
} from "./config.js";

View file

@ -0,0 +1,41 @@
/**
* Effect Config contracts for messaging runtime behavior.
*/
import { Config, Effect } from "effect";
export interface MessagingRuntimeConfig {
readonly consumerReceiveTimeoutMs: number;
readonly consumerErrorBackoffMs: number;
readonly rateLimitRetryMs: number;
readonly requestTimeoutMs: number;
}
export const defaultMessagingRuntimeConfig: MessagingRuntimeConfig = {
consumerReceiveTimeoutMs: 2_000,
consumerErrorBackoffMs: 1_000,
rateLimitRetryMs: 10_000,
requestTimeoutMs: 300_000,
};
export const loadMessagingRuntimeConfig = Effect.fn("loadMessagingRuntimeConfig")(function* () {
const consumerReceiveTimeoutMs = yield* Config.number("TG_CONSUMER_RECEIVE_TIMEOUT_MS").pipe(
Config.withDefault(defaultMessagingRuntimeConfig.consumerReceiveTimeoutMs),
);
const consumerErrorBackoffMs = yield* Config.number("TG_CONSUMER_ERROR_BACKOFF_MS").pipe(
Config.withDefault(defaultMessagingRuntimeConfig.consumerErrorBackoffMs),
);
const rateLimitRetryMs = yield* Config.number("TG_RATE_LIMIT_RETRY_MS").pipe(
Config.withDefault(defaultMessagingRuntimeConfig.rateLimitRetryMs),
);
const requestTimeoutMs = yield* Config.number("TG_REQUEST_TIMEOUT_MS").pipe(
Config.withDefault(defaultMessagingRuntimeConfig.requestTimeoutMs),
);
return {
consumerReceiveTimeoutMs,
consumerErrorBackoffMs,
rateLimitRetryMs,
requestTimeoutMs,
} satisfies MessagingRuntimeConfig;
});

View file

@ -1,344 +1,455 @@
/**
* Message types for service communication.
* Schema-backed message types for service communication.
*
* Python reference: trustgraph-base/trustgraph/schema/services/
*/
import type { TgError, Triple, Term, RowSchema } from "./primitives.js";
import * as S from "effect/Schema";
import { Term, TgError, Triple } from "./primitives.js";
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 NumberArrays = MutableArray(NumberArray);
// Text completion
export interface TextCompletionRequest {
system: string;
prompt: string;
model?: string;
temperature?: number;
streaming?: boolean;
}
export const TextCompletionRequest = S.Struct({
system: S.String,
prompt: S.String,
model: S.optionalKey(S.String),
temperature: S.optionalKey(S.Number),
streaming: S.optionalKey(S.Boolean),
});
export type TextCompletionRequest = typeof TextCompletionRequest.Type;
export interface TextCompletionResponse {
response: string;
model?: string;
inToken?: number;
outToken?: number;
error?: TgError;
endOfStream?: boolean;
}
export const TextCompletionResponse = S.Struct({
response: S.String,
model: S.optionalKey(S.String),
inToken: S.optionalKey(S.Number),
outToken: S.optionalKey(S.Number),
error: S.optionalKey(TgError),
endOfStream: S.optionalKey(S.Boolean),
});
export type TextCompletionResponse = typeof TextCompletionResponse.Type;
// Embeddings
export interface EmbeddingsRequest {
text: string[];
model?: string;
}
export const EmbeddingsRequest = S.Struct({
text: StringArray,
model: S.optionalKey(S.String),
});
export type EmbeddingsRequest = typeof EmbeddingsRequest.Type;
export interface EmbeddingsResponse {
vectors: number[][];
error?: TgError;
}
export const EmbeddingsResponse = S.Struct({
vectors: NumberArrays,
error: S.optionalKey(TgError),
});
export type EmbeddingsResponse = typeof EmbeddingsResponse.Type;
// Graph RAG
export interface GraphRagRequest {
query: string;
collection?: string;
entityLimit?: number;
tripleLimit?: number;
maxSubgraphSize?: number;
maxPathLength?: number;
streaming?: boolean;
}
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),
streaming: S.optionalKey(S.Boolean),
});
export type GraphRagRequest = typeof GraphRagRequest.Type;
export interface GraphRagResponse {
response: string;
error?: TgError;
endOfStream?: boolean;
// Explainability: include retrieved subgraph triples
message_type?: "chunk" | "explain";
explain_id?: string;
explain_triples?: Triple[];
[key: string]: unknown;
}
export const GraphRagResponse = S.StructWithRest(
S.Struct({
response: S.String,
error: S.optionalKey(TgError),
endOfStream: S.optionalKey(S.Boolean),
message_type: S.optionalKey(S.Union([S.Literal("chunk"), S.Literal("explain")])),
explain_id: S.optionalKey(S.String),
explain_triples: OptionalMutableArray(Triple),
}),
[UnknownRecord],
);
export type GraphRagResponse = typeof GraphRagResponse.Type;
// Document RAG
export interface DocumentRagRequest {
query: string;
collection?: string;
streaming?: boolean;
}
export const DocumentRagRequest = S.Struct({
query: S.String,
collection: S.optionalKey(S.String),
streaming: S.optionalKey(S.Boolean),
});
export type DocumentRagRequest = typeof DocumentRagRequest.Type;
export interface DocumentRagResponse {
response: string;
error?: TgError;
endOfStream?: boolean;
}
export const DocumentRagResponse = S.Struct({
response: S.String,
error: S.optionalKey(TgError),
endOfStream: S.optionalKey(S.Boolean),
});
export type DocumentRagResponse = typeof DocumentRagResponse.Type;
// Agent
export interface AgentRequest {
question: string;
collection?: string;
streaming?: boolean;
group?: string[];
state?: string;
}
export const AgentRequest = S.Struct({
question: S.String,
collection: S.optionalKey(S.String),
streaming: S.optionalKey(S.Boolean),
group: S.optionalKey(StringArray),
state: S.optionalKey(S.String),
});
export type AgentRequest = typeof AgentRequest.Type;
export interface AgentResponse {
/** Streaming chunk type */
chunk_type?: "thought" | "observation" | "answer" | "error" | "explain";
content?: string;
end_of_message?: boolean;
end_of_dialog?: boolean;
/** Legacy non-streaming fields */
answer?: string;
error?: TgError;
endOfStream?: boolean;
endOfSession?: boolean;
/** Explainability fields */
explain_id?: string;
explain_graph?: string;
explain_triples?: unknown[];
message_type?: string;
}
export const AgentResponse = S.Struct({
chunk_type: S.optionalKey(S.Union([
S.Literal("thought"),
S.Literal("observation"),
S.Literal("answer"),
S.Literal("error"),
S.Literal("explain"),
])),
content: S.optionalKey(S.String),
end_of_message: S.optionalKey(S.Boolean),
end_of_dialog: S.optionalKey(S.Boolean),
answer: S.optionalKey(S.String),
error: S.optionalKey(TgError),
endOfStream: S.optionalKey(S.Boolean),
endOfSession: S.optionalKey(S.Boolean),
explain_id: S.optionalKey(S.String),
explain_graph: S.optionalKey(S.String),
explain_triples: OptionalMutableArray(S.Unknown),
message_type: S.optionalKey(S.String),
});
export type AgentResponse = typeof AgentResponse.Type;
// Triples query
export interface TriplesQueryRequest {
s?: Term;
p?: Term;
o?: Term;
collection?: string;
limit?: number;
}
export const TriplesQueryRequest = S.Struct({
s: S.optionalKey(Term),
p: S.optionalKey(Term),
o: S.optionalKey(Term),
collection: S.optionalKey(S.String),
limit: S.optionalKey(S.Number),
});
export type TriplesQueryRequest = typeof TriplesQueryRequest.Type;
export interface TriplesQueryResponse {
triples: Triple[];
error?: TgError;
}
export const TriplesQueryResponse = S.Struct({
triples: MutableArray(Triple),
error: S.optionalKey(TgError),
});
export type TriplesQueryResponse = typeof TriplesQueryResponse.Type;
// Graph embeddings query
export interface GraphEmbeddingsRequest {
vectors: number[][];
user?: string;
limit?: number;
collection?: string;
}
export const GraphEmbeddingsRequest = S.Struct({
vectors: NumberArrays,
user: S.optionalKey(S.String),
limit: S.optionalKey(S.Number),
collection: S.optionalKey(S.String),
});
export type GraphEmbeddingsRequest = typeof GraphEmbeddingsRequest.Type;
export interface GraphEmbeddingsResponse {
entities: Term[];
error?: TgError;
}
export const GraphEmbeddingsResponse = S.Struct({
entities: MutableArray(Term),
error: S.optionalKey(TgError),
});
export type GraphEmbeddingsResponse = typeof GraphEmbeddingsResponse.Type;
// Document embeddings query
export interface DocumentEmbeddingsRequest {
vectors: number[][];
limit?: number;
user?: string;
collection?: string;
}
export const DocumentEmbeddingsRequest = S.Struct({
vectors: NumberArrays,
limit: S.optionalKey(S.Number),
user: S.optionalKey(S.String),
collection: S.optionalKey(S.String),
});
export type DocumentEmbeddingsRequest = typeof DocumentEmbeddingsRequest.Type;
export interface DocumentEmbeddingsResponse {
chunks: Array<{ chunkId: string; score: number; content?: string }>;
error?: TgError;
}
const DocumentEmbeddingChunk = S.Struct({
chunkId: S.String,
score: S.Number,
content: S.optionalKey(S.String),
});
export const DocumentEmbeddingsResponse = S.Struct({
chunks: MutableArray(DocumentEmbeddingChunk),
error: S.optionalKey(TgError),
});
export type DocumentEmbeddingsResponse = typeof DocumentEmbeddingsResponse.Type;
// Config
export type ConfigOperation = "get" | "list" | "delete" | "put" | "config" | "getvalues";
export const ConfigOperation = S.Union([
S.Literal("get"),
S.Literal("list"),
S.Literal("delete"),
S.Literal("put"),
S.Literal("config"),
S.Literal("getvalues"),
]);
export type ConfigOperation = typeof ConfigOperation.Type;
export interface ConfigRequest {
operation: ConfigOperation;
keys?: string[];
values?: Record<string, unknown>;
type?: string;
}
export const ConfigRequest = S.Struct({
operation: ConfigOperation,
keys: S.optionalKey(StringArray),
values: S.optionalKey(UnknownRecord),
type: S.optionalKey(S.String),
});
export type ConfigRequest = typeof ConfigRequest.Type;
export interface ConfigResponse {
version?: number;
values?: Record<string, unknown>;
directory?: string[];
config?: Record<string, unknown>;
error?: TgError;
}
export const ConfigResponse = S.Struct({
version: S.optionalKey(S.Number),
values: S.optionalKey(S.Unknown),
directory: S.optionalKey(StringArray),
config: S.optionalKey(UnknownRecord),
error: S.optionalKey(TgError),
});
export type ConfigResponse = typeof ConfigResponse.Type;
// Prompt
export interface PromptRequest {
name: string;
variables?: Record<string, string>;
}
export const PromptRequest = S.Struct({
name: S.String,
variables: S.optionalKey(S.Record(S.String, S.String)),
});
export type PromptRequest = typeof PromptRequest.Type;
export interface PromptResponse {
system: string;
prompt: string;
error?: TgError;
}
export const PromptResponse = S.Struct({
system: S.String,
prompt: S.String,
error: S.optionalKey(TgError),
});
export type PromptResponse = typeof PromptResponse.Type;
// ---------- Pipeline types ----------
// Pipeline types
export const PipelineMetadata = S.Struct({
id: S.String,
root: S.String,
user: S.String,
collection: S.String,
});
export type PipelineMetadata = typeof PipelineMetadata.Type;
export interface PipelineMetadata {
id: string;
root: string;
user: string;
collection: string;
}
export const Document = S.Struct({
metadata: PipelineMetadata,
documentId: S.String,
});
export type Document = typeof Document.Type;
/** Document message — triggers the decode pipeline for a librarian document. */
export interface Document {
metadata: PipelineMetadata;
documentId: string;
}
export const TextDocument = S.Struct({
metadata: PipelineMetadata,
text: S.String,
documentId: S.String,
});
export type TextDocument = typeof TextDocument.Type;
export interface TextDocument {
metadata: PipelineMetadata;
text: string;
documentId: string;
}
export const Chunk = S.Struct({
metadata: PipelineMetadata,
chunk: S.String,
documentId: S.String,
});
export type Chunk = typeof Chunk.Type;
export interface Chunk {
metadata: PipelineMetadata;
chunk: string;
documentId: string;
}
export const EntityContext = S.Struct({
entity: Term,
context: S.String,
chunkId: S.String,
});
export type EntityContext = typeof EntityContext.Type;
export interface EntityContext {
entity: Term;
context: string;
chunkId: string;
}
export const EntityContexts = S.Struct({
metadata: PipelineMetadata,
entities: MutableArray(EntityContext),
});
export type EntityContexts = typeof EntityContexts.Type;
export interface EntityContexts {
metadata: PipelineMetadata;
entities: EntityContext[];
}
export const Triples = S.Struct({
metadata: PipelineMetadata,
triples: MutableArray(Triple),
});
export type Triples = typeof Triples.Type;
export interface Triples {
metadata: PipelineMetadata;
triples: Triple[];
}
// Document metadata
export const DocumentMetadata = S.Struct({
id: S.String,
time: S.Number,
kind: S.String,
title: S.String,
comments: S.String,
user: S.String,
tags: StringArray,
parentId: S.optionalKey(S.String),
documentType: S.String,
metadata: OptionalMutableArray(Triple),
});
export type DocumentMetadata = typeof DocumentMetadata.Type;
// ---------- Document metadata ----------
export const ProcessingMetadata = S.Struct({
id: S.String,
documentId: S.String,
time: S.Number,
flow: S.String,
user: S.String,
collection: S.String,
tags: StringArray,
});
export type ProcessingMetadata = typeof ProcessingMetadata.Type;
export interface DocumentMetadata {
id: string;
time: number;
kind: string;
title: string;
comments: string;
user: string;
tags: string[];
parentId?: string;
documentType: string; // "source" | "page" | "chunk" | "extracted"
metadata?: Triple[];
}
// Librarian
export const LibrarianOperation = S.Literals([
"add-document",
"remove-document",
"list-documents",
"get-document-metadata",
"get-document-content",
"add-child-document",
"list-children",
"add-processing",
"remove-processing",
"list-processing",
]);
export type LibrarianOperation = typeof LibrarianOperation.Type;
export interface ProcessingMetadata {
id: string;
documentId: string;
time: number;
flow: string;
user: string;
collection: string;
tags: string[];
}
export const LibrarianRequest = S.Struct({
operation: LibrarianOperation,
documentId: S.optionalKey(S.String),
processingId: S.optionalKey(S.String),
documentMetadata: S.optionalKey(DocumentMetadata),
processingMetadata: S.optionalKey(ProcessingMetadata),
content: S.optionalKey(S.String),
user: S.optionalKey(S.String),
collection: S.optionalKey(S.String),
});
export type LibrarianRequest = typeof LibrarianRequest.Type;
// ---------- Librarian ----------
export const LibrarianResponse = S.Struct({
error: S.optionalKey(TgError),
documentMetadata: S.optionalKey(DocumentMetadata),
content: S.optionalKey(S.String),
documents: OptionalMutableArray(DocumentMetadata),
processing: OptionalMutableArray(ProcessingMetadata),
});
export type LibrarianResponse = typeof LibrarianResponse.Type;
export type LibrarianOperation =
| "add-document"
| "remove-document"
| "list-documents"
| "get-document-metadata"
| "get-document-content"
| "add-child-document"
| "list-children"
| "add-processing"
| "remove-processing"
| "list-processing";
// Knowledge core
export const KnowledgeOperation = S.Literals([
"list-kg-cores",
"get-kg-core",
"delete-kg-core",
"put-kg-core",
"load-kg-core",
]);
export type KnowledgeOperation = typeof KnowledgeOperation.Type;
export interface LibrarianRequest {
operation: LibrarianOperation;
documentId?: string;
processingId?: string;
documentMetadata?: DocumentMetadata;
processingMetadata?: ProcessingMetadata;
content?: string; // base64
user?: string;
collection?: string;
}
const GraphEmbedding = S.Struct({
entity: Term,
vectors: NumberArrays,
});
export interface LibrarianResponse {
error?: TgError;
documentMetadata?: DocumentMetadata;
content?: string; // base64
documents?: DocumentMetadata[];
processing?: ProcessingMetadata[];
}
export const KnowledgeRequest = S.Struct({
operation: KnowledgeOperation,
user: S.optionalKey(S.String),
id: S.optionalKey(S.String),
flow: S.optionalKey(S.String),
collection: S.optionalKey(S.String),
triples: OptionalMutableArray(Triple),
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
});
export type KnowledgeRequest = typeof KnowledgeRequest.Type;
// ---------- Knowledge core ----------
export const KnowledgeResponse = S.Struct({
error: S.optionalKey(TgError),
ids: S.optionalKey(StringArray),
eos: S.optionalKey(S.Boolean),
triples: OptionalMutableArray(Triple),
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
});
export type KnowledgeResponse = typeof KnowledgeResponse.Type;
export type KnowledgeOperation =
| "list-kg-cores"
| "get-kg-core"
| "delete-kg-core"
| "put-kg-core"
| "load-kg-core";
// Collection management
export const CollectionOperation = S.Literals([
"list-collections",
"update-collection",
"delete-collection",
]);
export type CollectionOperation = typeof CollectionOperation.Type;
export interface KnowledgeRequest {
operation: KnowledgeOperation;
user?: string;
id?: string;
flow?: string;
collection?: string;
triples?: Triple[];
graphEmbeddings?: { entity: Term; vectors: number[][] }[];
}
const CollectionEntry = S.Struct({
user: S.String,
collection: S.String,
name: S.String,
description: S.String,
tags: StringArray,
});
export interface KnowledgeResponse {
error?: TgError;
ids?: string[];
eos?: boolean;
triples?: Triple[];
graphEmbeddings?: { entity: Term; vectors: number[][] }[];
}
export const CollectionManagementRequest = S.Struct({
operation: CollectionOperation,
user: S.optionalKey(S.String),
collection: S.optionalKey(S.String),
name: S.optionalKey(S.String),
description: S.optionalKey(S.String),
tags: S.optionalKey(StringArray),
});
export type CollectionManagementRequest = typeof CollectionManagementRequest.Type;
// ---------- Collection management ----------
export const CollectionManagementResponse = S.Struct({
error: S.optionalKey(TgError),
collections: OptionalMutableArray(CollectionEntry),
});
export type CollectionManagementResponse = typeof CollectionManagementResponse.Type;
export type CollectionOperation =
| "list-collections"
| "update-collection"
| "delete-collection";
// Tool invocation (MCP tools)
export const ToolRequest = S.Struct({
name: S.String,
parameters: S.String,
});
export type ToolRequest = typeof ToolRequest.Type;
export interface CollectionManagementRequest {
operation: CollectionOperation;
user?: string;
collection?: string;
name?: string;
description?: string;
tags?: string[];
}
export const ToolResponse = S.Struct({
error: S.optionalKey(TgError),
text: S.optionalKey(S.String),
object: S.optionalKey(S.String),
});
export type ToolResponse = typeof ToolResponse.Type;
export interface CollectionManagementResponse {
error?: TgError;
collections?: { user: string; collection: string; name: string; description: string; tags: string[] }[];
}
// Flow management
export const FlowRequest = S.StructWithRest(
S.Struct({
operation: S.String,
}),
[UnknownRecord],
);
export type FlowRequest = typeof FlowRequest.Type;
// ---------- Tool invocation (MCP tools) ----------
export const FlowResponse = S.StructWithRest(
S.Struct({
error: S.optionalKey(TgError),
}),
[UnknownRecord],
);
export type FlowResponse = typeof FlowResponse.Type;
export interface ToolRequest {
name: string;
parameters: string; // JSON-encoded
}
export interface ToolResponse {
error?: TgError;
text?: string; // Plain text response
object?: string; // JSON-encoded structured response
}
// ---------- Flow management ----------
// Flow request/response use kebab-case wire format to match the client.
// Access fields via bracket notation: request["flow-id"]
export interface FlowRequest {
operation: string;
[key: string]: unknown;
}
export interface FlowResponse {
error?: TgError;
[key: string]: unknown;
}
export const ServiceMessageSchemas = {
TextCompletionRequest,
TextCompletionResponse,
EmbeddingsRequest,
EmbeddingsResponse,
GraphRagRequest,
GraphRagResponse,
DocumentRagRequest,
DocumentRagResponse,
AgentRequest,
AgentResponse,
TriplesQueryRequest,
TriplesQueryResponse,
GraphEmbeddingsRequest,
GraphEmbeddingsResponse,
DocumentEmbeddingsRequest,
DocumentEmbeddingsResponse,
ConfigRequest,
ConfigResponse,
PromptRequest,
PromptResponse,
LibrarianRequest,
LibrarianResponse,
KnowledgeRequest,
KnowledgeResponse,
CollectionManagementRequest,
CollectionManagementResponse,
ToolRequest,
ToolResponse,
FlowRequest,
FlowResponse,
} as const;

View file

@ -1,72 +1,102 @@
/**
* Core data types mirroring the Python schema primitives.
* Schema-backed core data types mirroring the Python schema primitives.
*
* Python reference: trustgraph-base/trustgraph/schema/core/primitives.py
*/
export interface TgError {
type: string;
message: string;
}
import * as S from "effect/Schema";
// RDF Term types — discriminated union
export type TermType = "IRI" | "BLANK" | "LITERAL" | "TRIPLE";
export const TgError = S.Struct({
type: S.String,
message: S.String,
});
export type TgError = typeof TgError.Type;
export interface IriTerm {
type: "IRI";
iri: string;
}
export const TermType = S.Literals([
"IRI",
"BLANK",
"LITERAL",
"TRIPLE",
]);
export type TermType = typeof TermType.Type;
export interface BlankTerm {
type: "BLANK";
id: string;
}
export const IriTerm = S.Struct({
type: S.tag("IRI"),
iri: S.String,
});
export type IriTerm = typeof IriTerm.Type;
export interface LiteralTerm {
type: "LITERAL";
value: string;
datatype?: string;
language?: string;
}
export const BlankTerm = S.Struct({
type: S.tag("BLANK"),
id: S.String,
});
export type BlankTerm = typeof BlankTerm.Type;
export interface TripleTerm {
type: "TRIPLE";
triple: Triple;
}
export const LiteralTerm = S.Struct({
type: S.tag("LITERAL"),
value: S.String,
datatype: S.optionalKey(S.String),
language: S.optionalKey(S.String),
});
export type LiteralTerm = typeof LiteralTerm.Type;
export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm;
export type Triple = {
readonly s: Term;
readonly p: Term;
readonly o: Term;
readonly g?: Term;
};
export interface Triple {
s: Term;
p: Term;
o: Term;
g?: Term; // Named graph (optional quad)
export const Triple: S.Codec<Triple, Triple> = S.suspend(() =>
S.Struct({
s: Term,
p: Term,
o: Term,
g: S.optionalKey(Term),
})
);
export const TripleTerm: S.Codec<TripleTerm, TripleTerm> = S.suspend(() =>
S.Struct({
type: S.tag("TRIPLE"),
triple: Triple,
})
);
export interface TripleTerm {
readonly type: "TRIPLE";
readonly triple: Triple;
}
export interface Field {
name: string;
type: string;
description?: string;
}
export const Term: S.Codec<Term, Term> = S.suspend(() => S.Union([IriTerm, BlankTerm, LiteralTerm, TripleTerm]));
export interface RowSchema {
name: string;
description?: string;
fields: Field[];
}
export const Field = S.Struct({
name: S.String,
type: S.String,
description: S.optionalKey(S.String),
});
export type Field = typeof Field.Type;
// LLM-related types
export interface LlmResult {
text: string;
inToken: number;
outToken: number;
model: string;
}
export const RowSchema = S.Struct({
name: S.String,
description: S.optionalKey(S.String),
fields: S.Array(Field).pipe(S.mutable),
});
export type RowSchema = typeof RowSchema.Type;
export interface LlmChunk {
text: string;
inToken: number | null;
outToken: number | null;
model: string;
isFinal: boolean;
}
export const LlmResult = S.Struct({
text: S.String,
inToken: S.Number,
outToken: S.Number,
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),
model: S.String,
isFinal: S.Boolean,
});
export type LlmChunk = typeof LlmChunk.Type;

View file

@ -1,54 +1,82 @@
/**
* Base embeddings service.
* Embeddings capability contract and message-bus adapter.
*
* Python reference: trustgraph-base/trustgraph/base/embeddings_service.py
*/
import { FlowProcessor } from "../processor/flow-processor.js";
import { ConsumerSpec } from "../spec/consumer-spec.js";
import { ProducerSpec } from "../spec/producer-spec.js";
import { ParameterSpec } from "../spec/parameter-spec.js";
import type { ProcessorConfig } from "../processor/async-processor.js";
import { Context, Effect } from "effect";
import {
errorMessage,
type EmbeddingsError,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
} from "../errors.js";
import type { FlowContext } from "../messaging/consumer.js";
import { FlowProcessor } from "../processor/flow-processor.js";
import type { ProcessorConfig } from "../processor/async-processor.js";
import type { EmbeddingsRequest, EmbeddingsResponse } from "../schema/messages.js";
import { ConsumerSpec } from "../spec/consumer-spec.js";
import { ParameterSpec } from "../spec/parameter-spec.js";
import { ProducerSpec } from "../spec/producer-spec.js";
export abstract class EmbeddingsService extends FlowProcessor {
protected constructor(config: ProcessorConfig) {
export interface EmbeddingsServiceShape {
readonly embed: (
texts: ReadonlyArray<string>,
model?: string,
) => Effect.Effect<number[][], EmbeddingsError>;
}
export class Embeddings extends Context.Service<Embeddings, EmbeddingsServiceShape>()(
"@trustgraph/base/services/embeddings-service/Embeddings",
) {}
export class EmbeddingsService extends FlowProcessor<Embeddings> {
constructor(config: ProcessorConfig) {
super(config);
this.registerSpecification(
new ConsumerSpec<EmbeddingsRequest>(
new ConsumerSpec<EmbeddingsRequest, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings>(
"embeddings-request",
this.onRequest.bind(this),
this.onRequestEffect.bind(this),
),
);
this.registerSpecification(new ProducerSpec<EmbeddingsResponse>("embeddings-response"));
this.registerSpecification(new ParameterSpec("model"));
}
private async onRequest(
private onRequestEffect(
msg: EmbeddingsRequest,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
flowCtx: FlowContext<Embeddings>,
): Effect.Effect<void, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings> {
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<EmbeddingsResponse>("embeddings-response");
try {
const vectors = await this.onEmbeddings(msg.text, msg.model);
await responseProducer.send(requestId, { vectors });
} catch (err) {
console.error(`[EmbeddingsService] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
vectors: [],
error: { type: "embeddings-error", message },
});
if (requestId === undefined || requestId.length === 0) {
return Effect.void;
}
}
abstract onEmbeddings(texts: string[], model?: string): Promise<number[][]>;
return Effect.gen(function* () {
const responseProducer = yield* flowCtx.flow.producerEffect<EmbeddingsResponse>("embeddings-response");
const embeddings = yield* Embeddings;
const response = yield* embeddings.embed(msg.text, msg.model).pipe(
Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse),
Effect.catch((error) =>
Effect.logError("[EmbeddingsService] Error processing request", {
error: errorMessage(error),
operation: error.operation,
provider: error.provider ?? "unknown",
}).pipe(
Effect.as({
vectors: [],
error: {
type: "embeddings-error",
message: errorMessage(error),
},
} satisfies EmbeddingsResponse),
),
),
);
yield* responseProducer.send(requestId, response);
});
}
}

View file

@ -1,2 +1,6 @@
export { LlmService } from "./llm-service.js";
export { EmbeddingsService } from "./embeddings-service.js";
export {
Embeddings,
EmbeddingsService,
type EmbeddingsServiceShape,
} from "./embeddings-service.js";

View file

@ -22,7 +22,7 @@ export abstract class LlmService extends FlowProcessor {
super(config);
this.registerSpecification(
new ConsumerSpec<TextCompletionRequest>(
ConsumerSpec.fromPromise<TextCompletionRequest>(
"text-completion-request",
this.onRequest.bind(this),
),
@ -36,50 +36,52 @@ export abstract class LlmService extends FlowProcessor {
msg: TextCompletionRequest,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
): Promise<void> {
const requestId = properties.id;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
try {
if (msg.streaming && this.supportsStreaming()) {
for await (const chunk of this.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
)) {
await responseProducer.send(
requestId,
{
try {
if (msg.streaming === true && this.supportsStreaming()) {
for await (const chunk of this.generateContentStream(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
)) {
const response = {
response: chunk.text,
model: chunk.model,
inToken: chunk.inToken ?? undefined,
outToken: chunk.outToken ?? undefined,
...(chunk.model !== undefined ? { model: chunk.model } : {}),
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
endOfStream: chunk.isFinal,
}
);
}
} else {
};
await responseProducer.send(
requestId,
response
);
}
} else {
const result = await this.generateContent(
msg.system,
msg.prompt,
msg.model,
msg.temperature,
);
await responseProducer.send(
requestId,
{
);
const response = {
response: result.text,
model: result.model,
inToken: result.inToken,
outToken: result.outToken,
...(result.model !== undefined ? { model: result.model } : {}),
...(result.inToken !== undefined ? { inToken: result.inToken } : {}),
...(result.outToken !== undefined ? { outToken: result.outToken } : {}),
endOfStream: true,
}
);
}
};
await responseProducer.send(
requestId,
response
);
}
} catch (err) {
console.error(
`[LlmService] Error processing request:`,

View file

@ -4,29 +4,84 @@
* Python reference: trustgraph-base/trustgraph/base/consumer_spec.py
*/
import { Effect } from "effect";
import * as S from "effect/Schema";
import type { Spec } from "./types.js";
import type { SpecRuntimeRequirements } from "./types.js";
import type { PubSubBackend } from "../backend/types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import { Consumer, type MessageHandler } from "../messaging/consumer.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);
export class ConsumerSpec<T, E = never, R = never> implements Spec<R> {
public readonly name: string;
private readonly handler: EffectMessageHandler<T, E, R>;
private readonly concurrency: number;
export class ConsumerSpec<T> implements Spec {
constructor(
public readonly name: string,
private readonly handler: MessageHandler<T>,
private readonly concurrency = 1,
) {}
name: string,
handler: EffectMessageHandler<T, E, R>,
concurrency = 1,
) {
this.name = name;
this.handler = handler;
this.concurrency = concurrency;
}
static fromPromise<T>(
name: string,
handler: MessageHandler<T>,
concurrency = 1,
): ConsumerSpec<T, TooManyRequestsError | MessagingHandlerError> {
return new ConsumerSpec<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,
);
}
addEffect(flow: Flow<R>, definition: FlowDefinition) {
const spec = this;
return Effect.gen(function* () {
const topic = definition.topics?.[spec.name] ?? spec.name;
const factory = yield* ConsumerFactory;
const consumer = yield* factory.run<T, E, R>(
{
topic,
subscription: `${flow.processorId}-${flow.name}-${spec.name}`,
handler: spec.handler,
concurrency: spec.concurrency,
},
{ id: flow.processorId, name: flow.name, flow },
);
flow.registerConsumer(spec.name, consumer);
});
}
async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
const topic = definition.topics?.[this.name] ?? this.name;
const consumer = new Consumer<T>({
pubsub,
topic,
subscription: `${flow.processorId}-${flow.name}-${this.name}`,
handler: this.handler,
concurrency: this.concurrency,
});
flow.registerConsumer(this.name, consumer as Consumer<unknown>);
const effect = this.addEffect(flow, definition) as Effect.Effect<
void,
PubSubError,
SpecRuntimeRequirements
>;
await flow.runInCompatibilityScope(effect, pubsub);
}
}

View file

@ -1,4 +1,4 @@
export type { Spec } from "./types.js";
export type { Spec, SpecRuntimeError, SpecRuntimeRequirements } from "./types.js";
export { ConsumerSpec } from "./consumer-spec.js";
export { ProducerSpec } from "./producer-spec.js";
export { ParameterSpec } from "./parameter-spec.js";

View file

@ -4,15 +4,27 @@
* Python reference: trustgraph-base/trustgraph/base/parameter_spec.py
*/
import { Effect } from "effect";
import type { Spec } from "./types.js";
import type { PubSubBackend } from "../backend/types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
export class ParameterSpec implements Spec {
constructor(public readonly name: string) {}
public readonly name: string;
constructor(name: string) {
this.name = name;
}
addEffect(flow: Flow, definition: FlowDefinition) {
const spec = this;
return Effect.sync(() => {
const value = definition.parameters?.[spec.name];
flow.setParameter(spec.name, value);
});
}
async add(flow: Flow, _pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
const value = definition.parameters?.[this.name];
flow.setParameter(this.name, value);
await Effect.runPromise(this.addEffect(flow, definition));
}
}

View file

@ -4,18 +4,33 @@
* Python reference: trustgraph-base/trustgraph/base/producer_spec.py
*/
import { Effect } from "effect";
import type { Spec } from "./types.js";
import type { PubSubBackend } from "../backend/types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import { Producer } from "../messaging/producer.js";
import {
ProducerFactory,
type EffectProducer,
} from "../messaging/runtime.js";
export class ProducerSpec<T> implements Spec {
constructor(public readonly name: string) {}
public readonly name: string;
constructor(name: string) {
this.name = name;
}
addEffect(flow: Flow, definition: FlowDefinition) {
const spec = this;
return Effect.gen(function* () {
const topic = definition.topics?.[spec.name] ?? spec.name;
const factory = yield* ProducerFactory;
const producer = yield* factory.make<T>({ topic });
flow.registerProducer(spec.name, producer as EffectProducer<unknown>);
});
}
async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
const topic = definition.topics?.[this.name] ?? this.name;
const producer = new Producer<T>(pubsub, topic);
await producer.start();
flow.registerProducer(this.name, producer as Producer<unknown>);
await flow.runInCompatibilityScope(this.addEffect(flow, definition), pubsub);
}
}

View file

@ -7,30 +7,46 @@
* Python reference: trustgraph-base/trustgraph/base/prompt_client_spec.py
*/
import { Effect } from "effect";
import type { Spec } from "./types.js";
import type { PubSubBackend } from "../backend/types.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import { RequestResponse } from "../messaging/request-response.js";
import {
RequestResponseFactory,
type EffectRequestResponse,
} from "../messaging/runtime.js";
export class RequestResponseSpec<TReq, TRes> implements Spec {
public readonly name: string;
private readonly requestTopicName: string;
private readonly responseTopicName: string;
constructor(
public readonly name: string,
private readonly requestTopicName: string,
private readonly responseTopicName: string,
) {}
name: string,
requestTopicName: string,
responseTopicName: string,
) {
this.name = name;
this.requestTopicName = requestTopicName;
this.responseTopicName = responseTopicName;
}
addEffect(flow: Flow, definition: FlowDefinition) {
const spec = this;
return Effect.gen(function* () {
const requestTopic = definition.topics?.[spec.requestTopicName] ?? spec.requestTopicName;
const responseTopic = definition.topics?.[spec.responseTopicName] ?? spec.responseTopicName;
const factory = yield* RequestResponseFactory;
const requestor = yield* factory.make<TReq, TRes>({
requestTopic,
responseTopic,
subscription: `${flow.processorId}-${flow.name}-${spec.name}`,
});
flow.registerRequestor(spec.name, requestor as EffectRequestResponse<unknown, unknown>);
});
}
async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
const requestTopic = definition.topics?.[this.requestTopicName] ?? this.requestTopicName;
const responseTopic = definition.topics?.[this.responseTopicName] ?? this.responseTopicName;
const rr = new RequestResponse<TReq, TRes>({
pubsub,
requestTopic,
responseTopic,
subscription: `${flow.processorId}-${flow.name}-${this.name}`,
});
await rr.start();
flow.registerRequestor(this.name, rr as RequestResponse<unknown, unknown>);
await flow.runInCompatibilityScope(this.addEffect(flow, definition), pubsub);
}
}

View file

@ -4,10 +4,29 @@
* Python reference: trustgraph-base/trustgraph/base/spec.py and siblings
*/
import type { Effect, Scope } from "effect";
import type { PubSubBackend } from "../backend/types.js";
import type {
ConsumerFactory,
ProducerFactory,
RequestResponseFactory,
} from "../messaging/runtime.js";
import type { Flow, FlowDefinition } from "../processor/flow.js";
import type { PubSubError } from "../errors.js";
export interface Spec {
export type SpecRuntimeRequirements =
| Scope.Scope
| ProducerFactory
| ConsumerFactory
| RequestResponseFactory;
export type SpecRuntimeError = PubSubError;
export interface Spec<Requirements = never> {
name: string;
addEffect(
flow: Flow<Requirements>,
definition: FlowDefinition,
): Effect.Effect<void, SpecRuntimeError, SpecRuntimeRequirements | Requirements>;
add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void>;
}

View file

@ -3,6 +3,7 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"],
"composite": true
},
"include": ["src"],

View file

@ -1,6 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
exclude: ["dist/**", "node_modules/**"],
globals: true,
},
});

View file

@ -6,20 +6,22 @@
"tg": "dist/index.js"
},
"scripts": {
"build": "tsc",
"build": "bunx --bun tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run --passWithNoTests"
"test": "bunx --bun vitest run --passWithNoTests"
},
"dependencies": {
"@trustgraph/base": "workspace:*",
"@trustgraph/client": "workspace:*",
"commander": "^13.1.0",
"effect": "4.0.0-beta.65",
"ws": "^8.18.0"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.65",
"@types/ws": "^8.5.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
"vitest": "^4.1.6"
}
}

View file

@ -24,15 +24,15 @@ export function registerAgentCommands(program: Command): void {
question,
(chunk) => {
// think — show thought process
if (chunk) process.stderr.write(chunk);
if (chunk.length > 0) process.stderr.write(chunk);
},
(chunk) => {
// observe — show observations
if (chunk) process.stderr.write(chunk);
if (chunk.length > 0) process.stderr.write(chunk);
},
(chunk, complete) => {
// answer — print to stdout
if (chunk) process.stdout.write(chunk);
if (chunk.length > 0) process.stdout.write(chunk);
if (complete) {
process.stdout.write("\n");
resolve();

View file

@ -58,8 +58,9 @@ export function registerFlowCommands(program: Command): void {
try {
const flows = socket.flows();
const params = cmdOpts.parameters
? JSON.parse(cmdOpts.parameters as string)
const rawParameters = cmdOpts.parameters as string | undefined;
const params = rawParameters !== undefined && rawParameters.length > 0
? JSON.parse(rawParameters)
: undefined;
const resp = await flows.startFlow(
id,

View file

@ -21,13 +21,14 @@ export function registerGraphRagCommands(program: Command): void {
try {
const flow = socket.flow(opts.flow);
const collection = cmdOpts.collection as string | undefined;
const response = await flow.graphRag(
query,
{
entityLimit: parseInt(cmdOpts.entityLimit, 10),
tripleLimit: parseInt(cmdOpts.tripleLimit, 10),
},
cmdOpts.collection,
collection,
);
console.log(response);
} finally {
@ -47,10 +48,14 @@ export function registerGraphRagCommands(program: Command): void {
try {
const flow = socket.flow(opts.flow);
const docLimit = cmdOpts.docLimit as string | undefined;
const collection = cmdOpts.collection as string | undefined;
const response = await flow.documentRag(
query,
cmdOpts.docLimit ? parseInt(cmdOpts.docLimit, 10) : undefined,
cmdOpts.collection,
docLimit !== undefined && docLimit.length > 0
? parseInt(docLimit, 10)
: undefined,
collection,
);
console.log(response);
} finally {

View file

@ -4,11 +4,15 @@
* Manages documents stored in the TrustGraph library.
*/
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import type { Command } from "commander";
import { createSocket, getOpts } from "./util.js";
function basenamePath(filepath: string): string {
const normalized = filepath.replace(/\/+$/, "");
const index = normalized.lastIndexOf("/");
return index >= 0 ? normalized.slice(index + 1) : normalized;
}
/** Simple MIME-type lookup by file extension. */
function guessMimeType(filepath: string): string {
const ext = filepath.split(".").pop()?.toLowerCase();
@ -69,10 +73,10 @@ export function registerLibraryCommands(program: Command): void {
try {
const lib = socket.librarian();
const data = readFileSync(file);
const b64 = data.toString("base64");
const data = new Uint8Array(await Bun.file(file).arrayBuffer());
const b64 = Buffer.from(data).toString("base64");
const mimeType = (cmdOpts.mimeType as string | undefined) ?? guessMimeType(file);
const title = (cmdOpts.title as string | undefined) ?? basename(file);
const title = (cmdOpts.title as string | undefined) ?? basenamePath(file);
const comments = cmdOpts.comments as string;
const tags: string[] = (cmdOpts.tags as string[] | undefined) ?? [];

View file

@ -23,14 +23,17 @@ export function registerTriplesCommands(program: Command): void {
try {
const flow = socket.flow(opts.flow);
const s: Term | undefined = cmdOpts.subject
? { t: "i", i: cmdOpts.subject as string }
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 }
: undefined;
const p: Term | undefined = cmdOpts.predicate
? { t: "i", i: cmdOpts.predicate as string }
const p: Term | undefined = predicate !== undefined && predicate.length > 0
? { t: "i", i: predicate }
: undefined;
const o: Term | undefined = cmdOpts.object
? { t: "i", i: cmdOpts.object as string }
const o: Term | undefined = object !== undefined && object.length > 0
? { t: "i", i: object }
: undefined;
const triples = await flow.triplesQuery(

View file

@ -15,7 +15,7 @@ export interface CliOpts {
export function getOpts(cmd: Command): CliOpts {
// Walk up to root command to get global options
let root = cmd;
while (root.parent) root = root.parent;
while (root.parent !== null) root = root.parent;
return root.opts() as CliOpts;
}

View file

@ -3,6 +3,7 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node", "bun"],
"composite": true
},
"include": ["src"],

View file

@ -6,10 +6,13 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"build": "bunx --bun tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run"
"test": "bunx --bun vitest run"
},
"dependencies": {
"effect": "4.0.0-beta.65"
},
"peerDependencies": {
"ws": "^8.0.0"
@ -20,10 +23,11 @@
}
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.65",
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0",
"vitest": "^4.1.6",
"happy-dom": "^20.0.0"
},
"license": "Apache-2.0"

View file

@ -1,4 +1,4 @@
import { Triple, Term } from "./Triple.js";
import type { Term, Triple } from "./Triple.js";
export type Request = object;
export type Response = object;

View file

@ -1,4 +1,4 @@
import { RequestMessage } from "../models/messages.js";
import type { RequestMessage } from "../models/messages.js";
import { WS_OPEN, WS_CONNECTING, type IsomorphicWebSocket } from "./websocket-adapter.js";
// Constant defining the delay before attempting to reconnect a WebSocket
@ -8,8 +8,14 @@ export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: IsomorphicWebSocket;
inflight: { [key: string]: ServiceCallMulti };
ws: IsomorphicWebSocket | null | undefined;
inflight: {
[key: string]: {
onReceived: (resp: object) => void;
retryNow: () => void;
error: (err: object | string) => void;
};
};
reopen: () => void;
getNextId?: () => string;
user?: string;
@ -42,7 +48,7 @@ export class ServiceCallMulti {
success: (resp: unknown) => void;
error: (err: object | string) => void;
receiver: (resp: unknown) => boolean;
timeoutId?: ReturnType<typeof setTimeout>;
timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
timeout: number;
retries: number;
socket: Socket;
@ -121,7 +127,7 @@ export class ServiceCallMulti {
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) {
if (this.socket.ws !== null && this.socket.ws !== undefined && this.socket.ws.readyState === WS_OPEN) {
try {
this.socket.ws.send(JSON.stringify(this.msg));
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
@ -148,7 +154,8 @@ export class ServiceCallMulti {
// No WebSocket connection available or not ready
// Check if socket is connecting
if (
this.socket.ws &&
this.socket.ws !== null &&
this.socket.ws !== undefined &&
this.socket.ws.readyState === WS_CONNECTING
) {
// Wait a bit longer for connection to establish

View file

@ -1,4 +1,4 @@
import { RequestMessage } from "../models/messages.js";
import type { RequestMessage } from "../models/messages.js";
import { WS_OPEN, type IsomorphicWebSocket } from "./websocket-adapter.js";
// Constant defining the delay before attempting to reconnect a WebSocket
@ -8,8 +8,14 @@ export const SOCKET_RECONNECTION_TIMEOUT = 2000;
// Forward declare Socket type to avoid circular dependency
// Using a minimal interface that matches what BaseApi provides
interface Socket {
ws?: IsomorphicWebSocket;
inflight: { [key: string]: ServiceCall };
ws: IsomorphicWebSocket | null | undefined;
inflight: {
[key: string]: {
onReceived: (resp: object) => void;
retryNow: () => void;
error: (err: object | string) => void;
};
};
reopen: () => void;
getNextId?: () => string;
user?: string;
@ -52,7 +58,7 @@ export class ServiceCall {
msg: RequestMessage; // The request message
success: (resp: unknown) => void; // Success callback
error: (err: object | string) => void; // Error callback
timeoutId?: ReturnType<typeof setTimeout>; // Reference to the active timeout timer
timeoutId: ReturnType<typeof setTimeout> | undefined = undefined; // Reference to the active timeout timer
timeout: number; // Timeout duration in milliseconds
retries: number; // Remaining retry attempts
socket: Socket; // WebSocket connection reference
@ -77,7 +83,10 @@ export class ServiceCall {
*/
onReceived(resp: object) {
// Guard: ignore duplicate responses after completion
if (this.complete) return;
if (this.complete) {
console.log(this.mid, "should not happen, request is already complete");
return;
}
// Mark as complete to prevent duplicate processing
this.complete = true;
@ -93,18 +102,18 @@ export class ServiceCall {
let errorToHandle: unknown = null;
// Check for direct error in response
if (resp && typeof resp === "object" && "error" in resp) {
if (resp !== null && typeof resp === "object" && "error" in resp) {
errorToHandle = (resp as Record<string, unknown>).error;
}
// Check for nested error under response property
else if (resp && typeof resp === "object" && "response" in resp) {
else if (resp !== null && typeof resp === "object" && "response" in resp) {
const response = (resp as Record<string, unknown>).response;
if (response && typeof response === "object" && "error" in response) {
if (response !== null && typeof response === "object" && "error" in response) {
errorToHandle = (response as Record<string, unknown>).error;
}
}
if (errorToHandle) {
if (errorToHandle !== null && errorToHandle !== undefined) {
// Response contains an error - call error callback
const errorObj = errorToHandle as Record<string, unknown>;
const errorMessage =
@ -151,7 +160,13 @@ export class ServiceCall {
*/
onTimeout() {
// Guard: ignore timeout after completion
if (this.complete) return;
if (this.complete) {
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
return;
}
console.log("Request", this.mid, "timed out");
@ -180,7 +195,13 @@ export class ServiceCall {
*/
attempt() {
// Guard: don't retry completed requests
if (this.complete) return;
if (this.complete) {
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
return;
}
// Decrement retry counter
this.retries--;
@ -197,7 +218,7 @@ export class ServiceCall {
}
// Check if WebSocket connection is available and ready
if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) {
if (this.socket.ws !== null && this.socket.ws !== undefined && this.socket.ws.readyState === WS_OPEN) {
try {
// Attempt to send the message as JSON
this.socket.ws.send(JSON.stringify(this.msg));

File diff suppressed because it is too large Load diff

View file

@ -117,7 +117,9 @@ export function getDefaultSocketUrl(): string {
*/
export function getRandomValues(array: Uint32Array): Uint32Array {
if (typeof globalThis.crypto?.getRandomValues === "function") {
return globalThis.crypto.getRandomValues(array);
const random = globalThis.crypto.getRandomValues(new Uint32Array(array.length));
array.set(random);
return array;
}
// Node.js fallback for versions < 19 where globalThis.crypto may not exist
try {

View file

@ -4,6 +4,7 @@
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"composite": true
},
"include": ["src"],

View file

@ -5,13 +5,14 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"build": "bunx --bun tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test": "vitest run"
"test": "bunx --bun vitest run"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@effect/platform-bun": "4.0.0-beta.65",
"@fastify/websocket": "^11.0.0",
"@qdrant/js-client-rest": "^1.13.0",
"@trustgraph/base": "workspace:*",
@ -20,12 +21,14 @@
"ollama": "^0.6.3",
"@mistralai/mistralai": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"effect": "4.0.0-beta.65",
"openai": "^4.85.0",
"pdfjs-dist": "^5.6.205"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.65",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
"vitest": "^4.1.6"
}
}

View file

@ -0,0 +1,230 @@
import { describe, expect, it } from "@effect/vitest";
import { ConfigProvider, Effect, Fiber } from "effect";
import {
MessagingRuntimeLive,
PubSub,
runProcessorScoped,
topics,
type BackendConsumer,
type BackendProducer,
type Chunk,
type CreateConsumerOptions,
type CreateProducerOptions,
type Message,
type PubSubBackend,
type TextDocument,
} from "@trustgraph/base";
import { ChunkingService } from "../chunking/service.js";
import { recursiveSplit } from "../chunking/recursive-splitter.js";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
value: () => value,
properties: () => properties,
};
}
const waitFor = (condition: () => boolean, label: string) =>
Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const check = () => {
if (condition()) {
resolve();
return;
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for ${label}`));
return;
}
setTimeout(check, 5);
};
check();
}),
catch: (error) => error,
});
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
closeCount = 0;
flushCount = 0;
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
}
async flush(): Promise<void> {
this.flushCount += 1;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class PushConsumer<T> implements BackendConsumer<T> {
readonly acknowledged: Array<Message<T>> = [];
readonly nacked: Array<Message<T>> = [];
closeCount = 0;
private readonly messages: Array<Message<T>> = [];
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
private closed = false;
push(message: Message<T>): void {
const waiter = this.waiters.shift();
if (waiter !== undefined) {
waiter(message);
return;
}
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);
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
}
class ChunkingBackend implements PubSubBackend {
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
readonly consumersByTopic = new Map<string, PushConsumer<unknown>>();
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
readonly producerOptions: Array<CreateProducerOptions> = [];
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>;
}
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>;
}
async close(): Promise<void> {
this.closeCount += 1;
}
pushConfig(): void {
this.configConsumer.push(
createMessage({
version: 1,
config: {
flows: {
default: {
topics: {
"chunk-input": "chunk-input-topic",
"chunk-output": "chunk-output-topic",
"chunk-triples": "chunk-triples-topic",
},
parameters: {
"chunk-size": 18,
"chunk-overlap": 0,
},
},
},
},
}),
);
}
}
const fastMessagingConfig = ConfigProvider.layer(
ConfigProvider.fromEnv({
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
TG_RATE_LIMIT_RETRY_MS: "1",
TG_REQUEST_TIMEOUT_MS: "250",
}),
);
describe("ChunkingService", () => {
it.effect(
"handles chunk-input with native Effect flow resources",
Effect.fnUntraced(function* () {
const backend = new ChunkingBackend();
yield* Effect.scoped(
Effect.gen(function* () {
const fiber = yield* runProcessorScoped(
{
id: "chunking",
pubsubUrl: "nats://unused:4222",
metricsPort: 8000,
manageProcessSignals: true,
},
(config) => new ChunkingService(config),
).pipe(
Effect.provide(MessagingRuntimeLive),
Effect.provide(PubSub.layer(backend)),
Effect.provide(fastMessagingConfig),
Effect.forkChild,
);
backend.pushConfig();
yield* waitFor(() => backend.consumersByTopic.has("chunk-input-topic"), "chunk consumer");
yield* waitFor(() => backend.producersByTopic.has("chunk-output-topic"), "chunk producer");
const document: TextDocument = {
documentId: "doc-1",
metadata: {
id: "pipeline-1",
root: "root-1",
user: "user-1",
collection: "collection-1",
},
text: "alpha beta gamma delta epsilon zeta eta theta",
};
const inputConsumer = backend.consumersByTopic.get("chunk-input-topic") as PushConsumer<TextDocument>;
inputConsumer.push(createMessage(document, { id: "request-1" }));
const outputProducer = backend.producersByTopic.get("chunk-output-topic") as RecordingProducer<Chunk>;
const expectedChunks = recursiveSplit(document.text, 18, 0);
yield* waitFor(() => outputProducer.sent.length === expectedChunks.length, "chunk outputs");
expect(inputConsumer.acknowledged.length).toBe(1);
expect(inputConsumer.nacked).toEqual([]);
expect(outputProducer.sent.map(({ message }) => message.chunk)).toEqual(expectedChunks);
expect(outputProducer.sent.every(({ properties }) => properties?.id === "request-1")).toBe(true);
yield* Fiber.interrupt(fiber);
}),
);
expect(backend.closeCount).toBe(1);
}),
);
});

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { makeOllamaEmbeddings } from "../embeddings/ollama.js";
describe("Ollama embeddings provider", () => {
it.effect(
"posts embedding requests to Ollama",
Effect.fnUntraced(function* () {
const calls: Array<{ readonly input: RequestInfo | URL; readonly init?: RequestInit }> = [];
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push(init === undefined ? { input } : { input, init });
return Promise.resolve(
new Response(JSON.stringify({ embeddings: [[1, 2, 3]] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}) as typeof fetch;
const embeddings = makeOllamaEmbeddings({
id: "embeddings",
model: "default-model",
ollamaHost: "http://ollama.local",
fetch: fetchImpl,
});
const vectors = yield* embeddings.embed(["alpha"], "override-model");
expect(vectors).toEqual([[1, 2, 3]]);
expect(calls).toHaveLength(1);
expect(String(calls[0]?.input)).toBe("http://ollama.local/api/embed");
expect(calls[0]?.init?.method).toBe("POST");
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
model: "override-model",
input: ["alpha"],
});
}),
);
it.effect(
"does not call Ollama for empty requests",
Effect.fnUntraced(function* () {
const calls: Array<RequestInfo | URL> = [];
const fetchImpl = ((input: RequestInfo | URL) => {
calls.push(input);
return Promise.resolve(new Response(JSON.stringify({ embeddings: [] })));
}) as typeof fetch;
const embeddings = makeOllamaEmbeddings({
id: "embeddings",
fetch: fetchImpl,
});
const vectors = yield* embeddings.embed([]);
expect(vectors).toEqual([]);
expect(calls).toEqual([]);
}),
);
it.effect(
"maps failed Ollama responses to EmbeddingsError",
Effect.fnUntraced(function* () {
const fetchImpl = (() =>
Promise.resolve(
new Response("not found", {
status: 404,
}),
)) as typeof fetch;
const embeddings = makeOllamaEmbeddings({
id: "embeddings",
ollamaHost: "http://ollama.local",
fetch: fetchImpl,
});
const error = yield* embeddings.embed(["alpha"]).pipe(Effect.flip);
expect(error._tag).toBe("EmbeddingsError");
expect(error.operation).toBe("ollama.embed");
expect(error.provider).toBe("ollama");
expect(error.message).toContain("Ollama embeddings request failed (404): not found");
}),
);
});

View file

@ -22,6 +22,7 @@ import {
type ToolRequest,
type ToolResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
interface McpServiceConfig {
url: string;
@ -36,7 +37,7 @@ export class McpToolService extends FlowProcessor {
super(config);
this.registerSpecification(
new ConsumerSpec<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
ConsumerSpec.fromPromise<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
);
this.registerSpecification(new ProducerSpec<ToolResponse>("mcp-tool-response"));
@ -77,14 +78,16 @@ export class McpToolService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<ToolResponse>("mcp-tool-response");
try {
const result = await this.invokeTool(
msg.name,
msg.parameters ? JSON.parse(msg.parameters) : {},
msg.parameters !== undefined && msg.parameters.length > 0
? JSON.parse(msg.parameters) as Record<string, unknown>
: {},
);
if (typeof result === "string") {
@ -110,7 +113,7 @@ export class McpToolService extends FlowProcessor {
}
const svcConfig = this.mcpServices[name];
if (!svcConfig.url) {
if (svcConfig.url.length === 0) {
throw new Error(`MCP service "${name}" URL not defined`);
}
@ -118,7 +121,7 @@ export class McpToolService extends FlowProcessor {
// Build headers with optional bearer token
const headers: Record<string, string> = {};
if (svcConfig["auth-token"]) {
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`;
}
@ -133,7 +136,7 @@ export class McpToolService extends FlowProcessor {
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
try {
await client.connect(transport);
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
const result = await client.callTool({
name: remoteName,
@ -141,11 +144,11 @@ export class McpToolService extends FlowProcessor {
});
// Extract response — prefer structured content, fall back to text
if (result.structuredContent) {
if (result.structuredContent !== undefined && result.structuredContent !== null) {
return result.structuredContent;
}
if (result.content && Array.isArray(result.content)) {
if (result.content !== undefined && Array.isArray(result.content)) {
return result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
@ -158,3 +161,8 @@ export class McpToolService extends FlowProcessor {
}
}
}
export const program = makeProcessorProgram({
id: "mcp-tool",
make: (config) => new McpToolService(config),
});

View file

@ -25,13 +25,22 @@ const MAX_MARKER_LEN = Math.max(...MARKERS.map((m) => m.prefix.length));
export class StreamingReActParser {
private state: ReActState = "initial";
private buffer = "";
private onThought: (text: string) => void;
private onAction: (name: string) => void;
private onActionInput: (input: string) => void;
private onFinalAnswer: (text: string) => void;
constructor(
private onThought: (text: string) => void,
private onAction: (name: string) => void,
private onActionInput: (input: string) => void,
private onFinalAnswer: (text: string) => void,
) {}
onThought: (text: string) => void,
onAction: (name: string) => void,
onActionInput: (input: string) => void,
onFinalAnswer: (text: string) => void,
) {
this.onThought = onThought;
this.onAction = onAction;
this.onActionInput = onActionInput;
this.onFinalAnswer = onFinalAnswer;
}
/**
* Feed a chunk of LLM output text into the parser.

View file

@ -36,6 +36,7 @@ import {
type ToolRequest,
type ToolResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import {
createKnowledgeQueryTool,
@ -45,7 +46,7 @@ import {
type ExplainData,
} from "./tools.js";
import { buildReActPrompt } from "./prompt.js";
import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js";
import { filterToolsByGroupAndState } from "../tool-filter.js";
import type { AgentTool, ToolArg } from "./types.js";
const MAX_ITERATIONS = 10;
@ -59,7 +60,7 @@ export class AgentService extends FlowProcessor {
// Consumer: agent requests
this.registerSpecification(
new ConsumerSpec<AgentRequest>("agent-request", this.onRequest.bind(this)),
ConsumerSpec.fromPromise<AgentRequest>("agent-request", this.onRequest.bind(this)),
);
// Producer: agent responses (streaming chunks)
@ -132,11 +133,12 @@ export class AgentService extends FlowProcessor {
for (const [_toolId, toolValue] of Object.entries(toolConfig)) {
try {
const data = JSON.parse(toolValue) as Record<string, unknown>;
const implType = data["type"] as string;
const name = data["name"] as string;
const description = data["description"] as string ?? "";
const implType = typeof data["type"] === "string" ? data["type"] : "";
const name = typeof data["name"] === "string" ? data["name"] : "";
const description =
typeof data["description"] === "string" ? data["description"] : "";
if (!name) {
if (name.length === 0) {
console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`);
continue;
}
@ -148,7 +150,10 @@ export class AgentService extends FlowProcessor {
// Will be wired to requestor at request time
tool = {
name,
description: description || "Query the knowledge graph for information about entities and their relationships.",
description:
description.length > 0
? description
: "Query the knowledge graph for information about entities and their relationships.",
args: [{ name: "question", type: "string", description: "The question to ask" }],
config: data,
execute: async () => "", // placeholder — wired at request time
@ -158,7 +163,10 @@ export class AgentService extends FlowProcessor {
case "document-query":
tool = {
name,
description: description || "Search documents for relevant information.",
description:
description.length > 0
? description
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config: data,
execute: async () => "",
@ -168,7 +176,10 @@ export class AgentService extends FlowProcessor {
case "triples-query":
tool = {
name,
description: description || "Query for specific triples in the knowledge graph.",
description:
description.length > 0
? description
: "Query for specific triples in the knowledge graph.",
args: [
{ name: "subject", type: "string", description: "Subject entity (optional)" },
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
@ -203,7 +214,7 @@ export class AgentService extends FlowProcessor {
continue;
}
if (tool) {
if (tool !== null) {
tools.push(tool);
console.log(`[AgentService] Registered tool: ${name} (${implType})`);
}
@ -276,7 +287,7 @@ export class AgentService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
@ -290,7 +301,7 @@ export class AgentService extends FlowProcessor {
// Build tools — config-driven or hardcoded fallback
let tools: AgentTool[];
if (this.configuredTools) {
if (this.configuredTools !== null) {
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
} else {
// Hardcoded fallback (backward compat)
@ -339,7 +350,7 @@ export class AgentService extends FlowProcessor {
prompt: conversation,
});
if (llmResponse.error) {
if (llmResponse.error !== undefined) {
await responseProducer.send(requestId, {
chunk_type: "error",
content: `LLM error: ${llmResponse.error.message}`,
@ -354,7 +365,7 @@ export class AgentService extends FlowProcessor {
const parsed = parseReActResponse(text);
// Send thought chunk
if (parsed.thought) {
if (parsed.thought.length > 0) {
await responseProducer.send(requestId, {
chunk_type: "thought",
content: parsed.thought,
@ -363,7 +374,7 @@ export class AgentService extends FlowProcessor {
}
// If we got a final answer, emit explain events then send the answer
if (parsed.finalAnswer) {
if (parsed.finalAnswer.length > 0) {
// Emit explain events collected from tool calls
for (const explain of explainEvents) {
await responseProducer.send(requestId, {
@ -384,11 +395,11 @@ export class AgentService extends FlowProcessor {
}
// Execute tool if action was specified
if (parsed.action && parsed.actionInput) {
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
const tool = tools.find((t) => t.name === parsed.action);
let observation: string;
if (tool) {
if (tool !== undefined) {
try {
observation = await tool.execute(parsed.actionInput);
} catch (err) {
@ -407,7 +418,7 @@ export class AgentService extends FlowProcessor {
// Append the full exchange to conversation for the next iteration
conversation += `\n${text}\nObservation: ${observation}\n`;
} else if (!parsed.finalAnswer) {
} else if (parsed.finalAnswer.length === 0) {
// LLM didn't produce a valid action or final answer -- nudge it
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
}
@ -464,30 +475,31 @@ function parseReActResponse(text: string): {
// Everything from "Final Answer:" to end of text is the answer
const firstLine = trimmed.slice("Final Answer:".length).trim();
const remainingLines = lines.slice(i + 1).join("\n").trim();
finalAnswer = firstLine + (remainingLines ? "\n" + remainingLines : "");
finalAnswer =
firstLine + (remainingLines.length > 0 ? "\n" + remainingLines : "");
break;
} else if (trimmed.startsWith("Thought:")) {
currentSection = "thought";
const content = trimmed.slice("Thought:".length).trim();
if (content) {
thought += (thought ? "\n" : "") + content;
if (content.length > 0) {
thought += (thought.length > 0 ? "\n" : "") + content;
}
} else if (trimmed.startsWith("Action Input:")) {
currentSection = "action_input";
const content = trimmed.slice("Action Input:".length).trim();
if (content) {
if (content.length > 0) {
actionInput += content;
}
} else if (trimmed.startsWith("Action:")) {
currentSection = "action";
const content = trimmed.slice("Action:".length).trim();
if (content) {
if (content.length > 0) {
action = content;
}
} else if (trimmed.startsWith("Observation:")) {
// Stop processing -- observations are injected by us, not the LLM
currentSection = null;
} else if (trimmed.length > 0 && currentSection) {
} else if (trimmed.length > 0 && currentSection !== null) {
// Continuation line for current section
switch (currentSection) {
case "thought":
@ -512,6 +524,11 @@ function parseReActResponse(text: string): {
};
}
export const program = makeProcessorProgram({
id: "agent",
make: (config) => new AgentService(config),
});
export async function run(): Promise<void> {
await AgentService.launch("agent");
}

View file

@ -6,7 +6,7 @@
*/
import type {
RequestResponse,
FlowRequestor,
GraphRagRequest,
GraphRagResponse,
DocumentRagRequest,
@ -68,7 +68,7 @@ export interface ExplainData {
* Query the knowledge graph for information about entities and their relationships.
*/
export function createKnowledgeQueryTool(
client: RequestResponse<GraphRagRequest, GraphRagResponse>,
client: FlowRequestor<GraphRagRequest, GraphRagResponse>,
collection?: string,
onExplain?: (data: ExplainData) => void,
): AgentTool {
@ -86,19 +86,27 @@ export function createKnowledgeQueryTool(
async execute(input: string): Promise<string> {
const question = parseQuestion(input);
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
const res = await client.request({ query: question, collection });
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
const request: GraphRagRequest = {
query: question,
...(collection !== undefined ? { collection } : {}),
};
const res = await client.request(request);
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
// Extract explain data if embedded in the response
const rawRes = res as Record<string, unknown>;
if (rawRes.message_type === "explain" && rawRes.explain_triples && onExplain) {
if (
rawRes.message_type === "explain" &&
rawRes.explain_triples !== undefined &&
onExplain !== undefined
) {
onExplain({
explainId: (rawRes.explain_id as string) ?? "",
triples: rawRes.explain_triples as Triple[],
});
}
if (res.error) return `Error: ${res.error.message}`;
if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response;
},
};
@ -108,7 +116,7 @@ export function createKnowledgeQueryTool(
* Search documents for relevant information.
*/
export function createDocumentQueryTool(
client: RequestResponse<DocumentRagRequest, DocumentRagResponse>,
client: FlowRequestor<DocumentRagRequest, DocumentRagResponse>,
collection?: string,
): AgentTool {
return {
@ -124,8 +132,12 @@ export function createDocumentQueryTool(
],
async execute(input: string): Promise<string> {
const question = parseQuestion(input);
const res = await client.request({ query: question, collection });
if (res.error) return `Error: ${res.error.message}`;
const request: DocumentRagRequest = {
query: question,
...(collection !== undefined ? { collection } : {}),
};
const res = await client.request(request);
if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response;
},
};
@ -153,13 +165,20 @@ function parseTriplesInput(input: string): {
return undefined;
};
return {
s: toTerm(parsed.subject ?? parsed.s),
p: toTerm(parsed.predicate ?? parsed.p),
o: toTerm(parsed.object ?? parsed.o),
limit:
typeof parsed.limit === "number" ? parsed.limit : undefined,
};
const result: {
s?: Term;
p?: Term;
o?: Term;
limit?: number;
} = {};
const s = toTerm(parsed.subject ?? parsed.s);
const p = toTerm(parsed.predicate ?? parsed.p);
const o = toTerm(parsed.object ?? parsed.o);
if (s !== undefined) result.s = s;
if (p !== undefined) result.p = p;
if (o !== undefined) result.o = o;
if (typeof parsed.limit === "number") result.limit = parsed.limit;
return result;
} catch {
// If not valid JSON, treat as a subject search
return {
@ -172,7 +191,7 @@ function parseTriplesInput(input: string): {
* Query for specific triples (subject-predicate-object relationships) in the knowledge graph.
*/
export function createTriplesQueryTool(
client: RequestResponse<TriplesQueryRequest, TriplesQueryResponse>,
client: FlowRequestor<TriplesQueryRequest, TriplesQueryResponse>,
collection?: string,
): AgentTool {
return {
@ -199,17 +218,18 @@ export function createTriplesQueryTool(
],
async execute(input: string): Promise<string> {
const { s, p, o, limit } = parseTriplesInput(input);
const res = await client.request({
s,
p,
o,
collection,
const request: TriplesQueryRequest = {
limit: limit ?? 20,
});
...(s !== undefined ? { s } : {}),
...(p !== undefined ? { p } : {}),
...(o !== undefined ? { o } : {}),
...(collection !== undefined ? { collection } : {}),
};
const res = await client.request(request);
if (res.error) return `Error: ${res.error.message}`;
if (res.error !== undefined) return `Error: ${res.error.message}`;
if (!res.triples || res.triples.length === 0) {
if (res.triples === undefined || res.triples.length === 0) {
return "No triples found matching the query.";
}
@ -229,7 +249,7 @@ export function createTriplesQueryTool(
* this function just wraps it as an AgentTool the ReAct agent can invoke.
*/
export function createMcpTool(
client: RequestResponse<ToolRequest, ToolResponse>,
client: FlowRequestor<ToolRequest, ToolResponse>,
toolName: string,
description: string,
args: ToolArg[],
@ -240,9 +260,9 @@ export function createMcpTool(
args,
async execute(input: string): Promise<string> {
const res = await client.request({ name: toolName, parameters: input });
if (res.error) return `Error: ${res.error.message}`;
if (res.text) return res.text;
if (res.object) return res.object;
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

@ -17,7 +17,7 @@ export function filterToolsByGroupAndState(
currentState?: string,
): AgentTool[] {
const groups = requestedGroups ?? ["default"];
const state = currentState || "undefined";
const state = currentState ?? "undefined";
return tools.filter((tool) => isToolAvailable(tool, groups, state));
}
@ -31,12 +31,12 @@ function isToolAvailable(
// Get tool groups (default to ["default"])
let toolGroups = config["group"] as string[] | string | undefined;
if (!toolGroups) toolGroups = ["default"];
if (toolGroups === undefined) toolGroups = ["default"];
if (!Array.isArray(toolGroups)) toolGroups = [toolGroups];
// Get tool applicable states (default to ["*"] = all states)
let applicableStates = config["applicable-states"] as string[] | string | undefined;
if (!applicableStates) applicableStates = ["*"];
if (applicableStates === undefined) applicableStates = ["*"];
if (!Array.isArray(applicableStates)) applicableStates = [applicableStates];
// Group match: wildcard in requested groups, or intersection non-empty
@ -57,5 +57,5 @@ function isToolAvailable(
*/
export function getNextState(tool: AgentTool, currentState: string): string {
const nextState = tool.config?.["state"] as string | undefined;
return nextState || currentState;
return nextState ?? currentState;
}

View file

@ -16,10 +16,14 @@ import {
ParameterSpec,
type ProcessorConfig,
type FlowContext,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
type TextDocument,
type Chunk,
type Triples,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { Effect } from "effect";
import { recursiveSplit } from "./recursive-splitter.js";
const DEFAULT_CHUNK_SIZE = 2000;
@ -30,7 +34,10 @@ export class ChunkingService extends FlowProcessor {
super(config);
this.registerSpecification(
new ConsumerSpec<TextDocument>("chunk-input", this.onMessage.bind(this)),
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
"chunk-input",
this.onMessageEffect.bind(this),
),
);
this.registerSpecification(new ProducerSpec<Chunk>("chunk-output"));
this.registerSpecification(new ProducerSpec<Triples>("chunk-triples"));
@ -40,55 +47,55 @@ export class ChunkingService extends FlowProcessor {
console.log("[ChunkingService] Service initialized");
}
private async onMessage(
private onMessageEffect(
msg: TextDocument,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
) {
return Effect.gen(function* () {
const requestId = properties.id;
if (requestId === undefined || requestId.length === 0) return;
let chunkSize: number;
let chunkOverlap: number;
const chunkSize = yield* flowCtx.flow.parameterEffect<number>("chunk-size").pipe(
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_SIZE)),
);
const chunkOverlap = yield* flowCtx.flow.parameterEffect<number>("chunk-overlap").pipe(
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_OVERLAP)),
);
try {
chunkSize = flowCtx.flow.parameter<number>("chunk-size");
} catch {
chunkSize = DEFAULT_CHUNK_SIZE;
}
const text = msg.text;
if (text.trim().length === 0) {
yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`);
return;
}
try {
chunkOverlap = flowCtx.flow.parameter<number>("chunk-overlap");
} catch {
chunkOverlap = DEFAULT_CHUNK_OVERLAP;
}
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
const text = msg.text;
if (!text || text.trim().length === 0) {
console.warn(`[ChunkingService] Empty text received for document ${msg.documentId}`);
return;
}
yield* Effect.log(
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
);
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
const outputProducer = yield* flowCtx.flow.producerEffect<Chunk>("chunk-output");
console.log(
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
);
const outputProducer = flowCtx.flow.producer<Chunk>("chunk-output");
for (const chunkText of chunks) {
const chunk: Chunk = {
metadata: msg.metadata,
chunk: chunkText,
documentId: msg.documentId,
};
await outputProducer.send(requestId, chunk);
}
yield* Effect.forEach(
chunks,
(chunkText) =>
outputProducer.send(requestId, {
metadata: msg.metadata,
chunk: chunkText,
documentId: msg.documentId,
}),
{ discard: true },
);
});
}
}
export const program = makeProcessorProgram({
id: "chunking",
make: (config) => new ChunkingService(config),
});
export async function run(): Promise<void> {
await ChunkingService.launch("chunking");
}

View file

@ -11,17 +11,24 @@
* Python reference: trustgraph-flow/trustgraph/config/service/service.py
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { Effect } from "effect";
import * as S from "effect/Schema";
import {
AsyncProcessor,
type ProcessorConfig,
topics,
ConfigRequest as ConfigRequestSchema,
ConfigResponse as ConfigResponseSchema,
type ConfigRequest,
type ConfigResponse,
type ConfigOperation,
errorMessage,
loadProcessorRuntimeConfig,
makeProcessorProgram,
optionalStringConfig,
} from "@trustgraph/base";
import type { PubSubBackend, BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
import { readTextFile, writeTextFile } from "../runtime/effect-files.js";
export interface ConfigServiceConfig extends ProcessorConfig {
persistPath?: string;
@ -32,6 +39,11 @@ interface ConfigPush {
config: Record<string, unknown>;
}
const ConfigPushSchema = S.Struct({
version: S.Number,
config: S.Record(S.String, S.Unknown),
});
export class ConfigService extends AsyncProcessor {
private store = new Map<string, Map<string, unknown>>();
private version = 0;
@ -42,27 +54,30 @@ export class ConfigService extends AsyncProcessor {
constructor(config: ConfigServiceConfig) {
super(config);
this.persistPath = config.persistPath ?? process.env.CONFIG_PERSIST_PATH ?? null;
this.persistPath = config.persistPath ?? null;
}
protected override async run(): Promise<void> {
// Optionally load persisted state
if (this.persistPath) {
if (this.persistPath !== null) {
await this.loadFromDisk();
}
// Create producers
this.responseProducer = await this.pubsub.createProducer<ConfigResponse>({
topic: topics.configResponse,
schema: ConfigResponseSchema,
});
this.pushProducer = await this.pubsub.createProducer<ConfigPush>({
topic: topics.configPush,
schema: ConfigPushSchema,
});
// Create consumer for config requests
this.consumer = await this.pubsub.createConsumer<ConfigRequest>({
topic: topics.configRequest,
subscription: `${this.config.id}-config-request`,
schema: ConfigRequestSchema,
});
// Push initial config
@ -73,11 +88,14 @@ export class ConfigService extends AsyncProcessor {
// Main consume loop
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (!msg) continue;
const consumer = this.consumer;
if (consumer === null) throw new Error("Config consumer not started");
const msg = await consumer.receive(2000);
if (msg === null) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
await consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[ConfigService] Error in consume loop:", err);
@ -87,21 +105,25 @@ export class ConfigService extends AsyncProcessor {
}
private async handleMessage(msg: Message<ConfigRequest>): Promise<void> {
const request = msg.value();
const request = await Effect.runPromise(S.decodeUnknownEffect(ConfigRequestSchema)(msg.value()));
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
if (requestId === undefined || requestId.length === 0) {
console.warn("[ConfigService] Received request without id, ignoring");
return;
}
try {
const response = await this.handleOperation(request);
await this.responseProducer!.send(response, { id: requestId });
const responseProducer = this.responseProducer;
if (responseProducer === null) throw new Error("Config response producer not started");
await responseProducer.send(response, { id: requestId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
const message = errorMessage(err);
const responseProducer = this.responseProducer;
if (responseProducer === null) throw new Error("Config response producer not started");
await responseProducer.send(
{
error: { type: "config-error", message },
},
@ -146,7 +168,7 @@ export class ConfigService extends AsyncProcessor {
const namespace = keys[0];
const subMap = this.store.get(namespace);
if (subMap) {
if (subMap !== undefined) {
if (keys.length === 1) {
// Return entire namespace
for (const [k, v] of subMap) {
@ -176,7 +198,7 @@ export class ConfigService extends AsyncProcessor {
const namespace = keys[0];
let subMap = this.store.get(namespace);
if (!subMap) {
if (subMap === undefined) {
subMap = new Map<string, unknown>();
this.store.set(namespace, subMap);
}
@ -205,7 +227,7 @@ export class ConfigService extends AsyncProcessor {
} else {
// Delete specific keys within namespace
const subMap = this.store.get(namespace);
if (subMap) {
if (subMap !== undefined) {
for (let i = 1; i < keys.length; i++) {
subMap.delete(keys[i]);
}
@ -236,7 +258,7 @@ export class ConfigService extends AsyncProcessor {
return {
version: this.version,
directory: subMap ? [...subMap.keys()] : [],
directory: subMap !== undefined ? [...subMap.keys()] : [],
};
}
@ -246,7 +268,12 @@ export class ConfigService extends AsyncProcessor {
const values: { key: string; value: unknown }[] = [];
for (const [namespace, subMap] of this.store) {
if (!type || namespace === type || namespace.startsWith(`${type}.`) || namespace.startsWith(`${type}/`)) {
if (
type.length === 0 ||
namespace === type ||
namespace.startsWith(`${type}.`) ||
namespace.startsWith(`${type}/`)
) {
for (const [k, v] of subMap) {
values.push({ key: `${namespace}.${k}`, value: v });
}
@ -274,7 +301,8 @@ export class ConfigService extends AsyncProcessor {
}
private async pushConfig(): Promise<void> {
if (!this.pushProducer) return;
const pushProducer = this.pushProducer;
if (pushProducer === null) return;
const config: Record<string, unknown> = {};
for (const [namespace, subMap] of this.store) {
@ -285,7 +313,7 @@ export class ConfigService extends AsyncProcessor {
config[namespace] = obj;
}
await this.pushProducer.send({
await pushProducer.send({
version: this.version,
config,
});
@ -294,7 +322,8 @@ export class ConfigService extends AsyncProcessor {
}
private async persist(): Promise<void> {
if (!this.persistPath) return;
const persistPath = this.persistPath;
if (persistPath === null) return;
try {
const data: Record<string, Record<string, unknown>> = {};
@ -313,18 +342,18 @@ export class ConfigService extends AsyncProcessor {
2,
);
await mkdir(dirname(this.persistPath), { recursive: true });
await writeFile(this.persistPath, json, "utf-8");
await writeTextFile(persistPath, json);
} catch (err) {
console.error("[ConfigService] Failed to persist config:", err);
await Effect.runPromise(Effect.logError("[ConfigService] Failed to persist config", { error: errorMessage(err) }));
}
}
private async loadFromDisk(): Promise<void> {
if (!this.persistPath) return;
const persistPath = this.persistPath;
if (persistPath === null) return;
try {
const raw = await readFile(this.persistPath, "utf-8");
const raw = await readTextFile(persistPath);
const parsed = JSON.parse(raw) as {
version: number;
data: Record<string, Record<string, unknown>>;
@ -346,20 +375,20 @@ export class ConfigService extends AsyncProcessor {
);
} catch {
// File doesn't exist yet or is invalid — start fresh
console.log("[ConfigService] No persisted config found, starting fresh");
await Effect.runPromise(Effect.log("[ConfigService] No persisted config found, starting fresh"));
}
}
override async stop(): Promise<void> {
if (this.consumer) {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer) {
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
if (this.pushProducer) {
if (this.pushProducer !== null) {
await this.pushProducer.close();
this.pushProducer = null;
}
@ -371,6 +400,23 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const loadConfigServiceRuntimeConfig = Effect.fn("loadConfigServiceRuntimeConfig")(function* () {
const processorConfig = yield* loadProcessorRuntimeConfig("config-svc", {
manageProcessSignals: false,
});
const persistPath = yield* optionalStringConfig("CONFIG_PERSIST_PATH");
return {
...processorConfig,
...(persistPath !== undefined ? { persistPath } : {}),
} satisfies ConfigServiceConfig;
});
export const program = makeProcessorProgram({
id: "config-svc",
loadConfig: loadConfigServiceRuntimeConfig(),
make: (config) => new ConfigService(config),
});
export async function run(): Promise<void> {
await ConfigService.launch("config-svc");
await Effect.runPromise(program);
}

View file

@ -10,8 +10,6 @@
* Python reference: trustgraph-flow/trustgraph/knowledge/service/service.py
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
AsyncProcessor,
type ProcessorConfig,
@ -21,7 +19,9 @@ import {
type Triple,
type Term,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
import { joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
export interface KnowledgeCoreServiceConfig extends ProcessorConfig {
dataDir?: string;
@ -43,7 +43,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
constructor(config: KnowledgeCoreServiceConfig) {
super(config);
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge";
this.persistPath = join(dataDir, "knowledge-state.json");
this.persistPath = joinPath(dataDir, "knowledge-state.json");
}
private coreKey(user: string, id: string): string {
@ -71,7 +71,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (!msg) continue;
if (msg === null) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
@ -88,7 +88,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
if (requestId === undefined || requestId.length === 0) {
console.warn("[KnowledgeCoreService] Received request without id, ignoring");
return;
}
@ -123,11 +123,11 @@ export class KnowledgeCoreService extends AsyncProcessor {
private async listKgCores(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const prefix = user ? `${user}:` : "";
const prefix = user.length > 0 ? `${user}:` : "";
const ids: string[] = [];
for (const key of this.cores.keys()) {
if (!prefix || key.startsWith(prefix)) {
if (prefix.length === 0 || key.startsWith(prefix)) {
// Extract the ID portion after the user prefix
const id = key.slice(prefix.length);
ids.push(id);
@ -143,7 +143,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (!core) {
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
@ -196,18 +196,18 @@ export class KnowledgeCoreService extends AsyncProcessor {
const key = this.coreKey(user, coreId);
let core = this.cores.get(key);
if (!core) {
if (core === undefined) {
core = { triples: [], graphEmbeddings: [] };
this.cores.set(key, core);
}
// Append triples if provided
if (request.triples && request.triples.length > 0) {
if (request.triples !== undefined && request.triples.length > 0) {
core.triples.push(...request.triples);
}
// Append graph embeddings if provided
if (request.graphEmbeddings && request.graphEmbeddings.length > 0) {
if (request.graphEmbeddings !== undefined && request.graphEmbeddings.length > 0) {
core.graphEmbeddings.push(...request.graphEmbeddings);
}
@ -225,7 +225,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (!core) {
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
@ -248,8 +248,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
}
const json = JSON.stringify(data, null, 2);
await mkdir(dirname(this.persistPath), { recursive: true });
await writeFile(this.persistPath, json, "utf-8");
await writeTextFile(this.persistPath, json);
} catch (err) {
console.error("[KnowledgeCoreService] Failed to persist state:", err);
}
@ -257,7 +256,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
private async loadFromDisk(): Promise<void> {
try {
const raw = await readFile(this.persistPath, "utf-8");
const raw = await readTextFile(this.persistPath);
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore>;
this.cores.clear();
@ -272,11 +271,11 @@ export class KnowledgeCoreService extends AsyncProcessor {
}
override async stop(): Promise<void> {
if (this.consumer) {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer) {
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
@ -288,6 +287,11 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const program = makeProcessorProgram({
id: "knowledge-svc",
make: (config) => new KnowledgeCoreService(config),
});
export async function run(): Promise<void> {
await KnowledgeCoreService.launch("knowledge-svc");
}

View file

@ -30,13 +30,14 @@ import {
type LibrarianRequest,
type LibrarianResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class PdfDecoderService extends FlowProcessor {
constructor(config: ProcessorConfig) {
super(config);
this.registerSpecification(
new ConsumerSpec<Document>("decode-input", this.onMessage.bind(this)),
ConsumerSpec.fromPromise<Document>("decode-input", this.onMessage.bind(this)),
);
this.registerSpecification(new ProducerSpec<TextDocument>("decode-output"));
this.registerSpecification(new ProducerSpec<Triples>("decode-triples"));
@ -57,7 +58,7 @@ export class PdfDecoderService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const { documentId } = msg;
const user = msg.metadata.user;
@ -73,7 +74,7 @@ export class PdfDecoderService extends FlowProcessor {
user,
});
if (metadataResp.error) {
if (metadataResp.error !== undefined) {
console.error(
`[PdfDecoder] Failed to get metadata for ${documentId}:`,
metadataResp.error.message,
@ -96,7 +97,11 @@ export class PdfDecoderService extends FlowProcessor {
user,
});
if (contentResp.error || !contentResp.content) {
if (
contentResp.error !== undefined ||
contentResp.content === undefined ||
contentResp.content.length === 0
) {
console.error(
`[PdfDecoder] Failed to get content for ${documentId}:`,
contentResp.error?.message ?? "no content",
@ -123,7 +128,7 @@ export class PdfDecoderService extends FlowProcessor {
.map((item) => item.str)
.join(" ");
if (!pageText.trim()) {
if (pageText.trim().length === 0) {
console.log(
`[PdfDecoder] Skipping empty page ${i} of document ${documentId}`,
);
@ -147,7 +152,7 @@ export class PdfDecoderService extends FlowProcessor {
content: Buffer.from(pageText).toString("base64"),
});
if (childResp.error) {
if (childResp.error !== undefined) {
console.error(
`[PdfDecoder] Failed to save page ${i} of ${documentId}:`,
childResp.error.message,
@ -198,6 +203,11 @@ function literalTerm(value: string): Term {
return { type: "LITERAL", value };
}
export const program = makeProcessorProgram({
id: "pdf-decoder",
make: (config) => new PdfDecoderService(config),
});
export async function run(): Promise<void> {
await PdfDecoderService.launch("pdf-decoder");
}

View file

@ -1,77 +1,112 @@
/**
* Ollama embeddings service.
*
* Simple HTTP POST to a local Ollama instance to generate embeddings.
* Extends EmbeddingsService from @trustgraph/base so it plugs into the
* flow processor framework (consumer/producer wiring is handled by the base class).
* Ollama embeddings provider.
*
* Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py
*/
import { Effect, Layer } from "effect";
import * as S from "effect/Schema";
import {
Embeddings,
EmbeddingsService,
embeddingsError,
type EmbeddingsServiceShape,
type ProcessorConfig,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export interface OllamaEmbeddingsConfig extends ProcessorConfig {
model?: string;
ollamaHost?: string;
fetch?: typeof fetch;
}
interface OllamaEmbedResponse {
embeddings: number[][];
}
export function makeOllamaEmbeddings(config: OllamaEmbeddingsConfig): EmbeddingsServiceShape {
const defaultModel = config.model ?? "mxbai-embed-large";
const ollamaHost =
config.ollamaHost ??
process.env.OLLAMA_URL ??
process.env.OLLAMA_HOST ??
"http://localhost:11434";
const fetchImpl = config.fetch ?? globalThis.fetch;
return {
embed: Effect.fn("OllamaEmbeddings.embed")((texts: ReadonlyArray<string>, model?: string) => {
if (texts.length === 0) {
return Effect.succeed([]);
}
const useModel = model ?? defaultModel;
const url = `${ollamaHost}/api/embed`;
return Effect.gen(function* () {
const body = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)({
model: useModel,
input: Array.from(texts),
}).pipe(
Effect.mapError((error) => embeddingsError("ollama.encode-request", error, "ollama")),
);
return yield* Effect.tryPromise({
try: async () => {
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Ollama embeddings request failed (${response.status}): ${errorBody}`,
);
}
const data = (await response.json()) as OllamaEmbedResponse;
return data.embeddings;
},
catch: (error) => embeddingsError("ollama.embed", error, "ollama"),
});
});
}),
};
}
export function OllamaEmbeddingsLive(config: OllamaEmbeddingsConfig): Layer.Layer<Embeddings> {
return Layer.succeed(
Embeddings,
Embeddings.of(makeOllamaEmbeddings(config)),
);
}
export class OllamaEmbeddingsProcessor extends EmbeddingsService {
private defaultModel: string;
private ollamaHost: string;
private readonly embeddings: EmbeddingsServiceShape;
constructor(config: OllamaEmbeddingsConfig) {
super(config);
this.defaultModel = config.model ?? "mxbai-embed-large";
this.ollamaHost =
config.ollamaHost ??
process.env.OLLAMA_URL ??
process.env.OLLAMA_HOST ??
"http://localhost:11434";
this.embeddings = makeOllamaEmbeddings(config);
console.log(
`[OllamaEmbeddings] Initialized (host=${this.ollamaHost}, model=${this.defaultModel})`,
`[OllamaEmbeddings] Initialized (host=${config.ollamaHost ?? process.env.OLLAMA_URL ?? process.env.OLLAMA_HOST ?? "http://localhost:11434"}, model=${config.model ?? "mxbai-embed-large"})`,
);
}
async onEmbeddings(texts: string[], model?: string): Promise<number[][]> {
if (!texts || texts.length === 0) {
return [];
}
const useModel = model ?? this.defaultModel;
const url = `${this.ollamaHost}/api/embed`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: useModel,
input: texts,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Ollama embeddings request failed (${response.status}): ${body}`,
);
}
const data = (await response.json()) as OllamaEmbedResponse;
return data.embeddings;
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(Embeddings, Embeddings.of(this.embeddings)),
);
}
}
export const program = makeProcessorProgram({
id: "embeddings",
make: (config) => new OllamaEmbeddingsProcessor(config),
});
export async function run(): Promise<void> {
await OllamaEmbeddingsProcessor.launch("embeddings");
}

View file

@ -28,6 +28,7 @@ import {
type Triple,
type Term,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
// Well-known RDF/SKOS IRIs
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
@ -49,7 +50,7 @@ export class KnowledgeExtractService extends FlowProcessor {
super(config);
this.registerSpecification(
new ConsumerSpec<Chunk>("extract-input", this.onMessage.bind(this)),
ConsumerSpec.fromPromise<Chunk>("extract-input", this.onMessage.bind(this)),
);
this.registerSpecification(new ProducerSpec<Triples>("extract-triples"));
this.registerSpecification(new ProducerSpec<EntityContexts>("extract-entity-contexts"));
@ -78,10 +79,10 @@ export class KnowledgeExtractService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const text = msg.chunk;
if (!text || text.trim().length === 0) return;
if (text.trim().length === 0) return;
const promptClient = flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt-client");
const llmClient = flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm-client");
@ -98,7 +99,7 @@ export class KnowledgeExtractService extends FlowProcessor {
{ timeoutMs: 10_000 },
);
if (!relPrompt.error) {
if (relPrompt.error === undefined) {
let relationships: ExtractedRelationship[] | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
const relCompletion = await llmClient.request(
@ -106,18 +107,27 @@ export class KnowledgeExtractService extends FlowProcessor {
{ timeoutMs: 120_000 },
);
if (!relCompletion.error && relCompletion.response) {
if (
relCompletion.error === undefined &&
relCompletion.response.length > 0
) {
relationships = parseJsonResponse<ExtractedRelationship[]>(relCompletion.response);
if (relationships) break;
if (relationships !== null) break;
console.warn(`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`);
} else {
break; // LLM error, don't retry
}
}
if (relationships) {
if (relationships !== null) {
for (const rel of relationships) {
if (!rel.subject || !rel.predicate || !rel.object) continue;
if (
rel.subject.length === 0 ||
rel.predicate.length === 0 ||
rel.object.length === 0
) {
continue;
}
const subjectIri = toEntityIri(rel.subject);
const predicateIri = toEntityIri(rel.predicate);
@ -170,7 +180,7 @@ export class KnowledgeExtractService extends FlowProcessor {
{ timeoutMs: 10_000 },
);
if (!defPrompt.error) {
if (defPrompt.error === undefined) {
let definitions: ExtractedDefinition[] | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
const defCompletion = await llmClient.request(
@ -178,18 +188,21 @@ export class KnowledgeExtractService extends FlowProcessor {
{ timeoutMs: 120_000 },
);
if (!defCompletion.error && defCompletion.response) {
if (
defCompletion.error === undefined &&
defCompletion.response.length > 0
) {
definitions = parseJsonResponse<ExtractedDefinition[]>(defCompletion.response);
if (definitions) break;
if (definitions !== null) break;
console.warn(`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`);
} else {
break; // LLM error, don't retry
}
}
if (definitions) {
if (definitions !== null) {
for (const def of definitions) {
if (!def.entity || !def.definition) continue;
if (def.entity.length === 0 || def.definition.length === 0) continue;
const entityIri = toEntityIri(def.entity);
@ -265,8 +278,8 @@ export function parseJsonResponse<T>(raw: string): T | null {
// Attempt 1: direct parse after stripping fences
let cleaned = raw.trim();
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
if (fenceMatch) {
cleaned = fenceMatch[1].trim();
if (fenceMatch !== null) {
cleaned = (fenceMatch[1] ?? "").trim();
}
try {
@ -275,7 +288,7 @@ export function parseJsonResponse<T>(raw: string): T | null {
// Attempt 2: extract first JSON array from the text
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
if (arrayMatch) {
if (arrayMatch !== null) {
try {
return JSON.parse(arrayMatch[0]) as T;
} catch { /* fall through */ }
@ -293,7 +306,7 @@ export function parseJsonResponse<T>(raw: string): T | null {
// Attempt 4: extract first JSON object, wrap in array
const objMatch = cleaned.match(/\{[\s\S]*?\}/);
if (objMatch) {
if (objMatch !== null) {
try {
const obj = JSON.parse(objMatch[0]);
return [obj] as unknown as T;
@ -304,6 +317,11 @@ export function parseJsonResponse<T>(raw: string): T | null {
return null;
}
export const program = makeProcessorProgram({
id: "knowledge-extract",
make: (config) => new KnowledgeExtractService(config),
});
export async function run(): Promise<void> {
await KnowledgeExtractService.launch("knowledge-extract");
}

View file

@ -22,6 +22,7 @@ import {
type ConfigRequest,
type ConfigResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type {
BackendProducer,
BackendConsumer,
@ -136,7 +137,7 @@ export class FlowManagerService extends AsyncProcessor {
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (!msg) continue;
if (msg === null) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
@ -155,7 +156,7 @@ export class FlowManagerService extends AsyncProcessor {
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
if (requestId === undefined || requestId.length === 0) {
console.warn("[FlowManager] Received request without id, ignoring");
return;
}
@ -218,12 +219,12 @@ export class FlowManagerService extends AsyncProcessor {
request: Record<string, unknown>,
): Record<string, unknown> {
const name = request["blueprint-name"] as string | undefined;
if (!name) {
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
const blueprint = this.blueprints.get(name);
if (!blueprint) {
if (blueprint === undefined) {
throw new Error(`Blueprint not found: ${name}`);
}
@ -236,7 +237,7 @@ export class FlowManagerService extends AsyncProcessor {
request: Record<string, unknown>,
): Record<string, unknown> {
const name = request["blueprint-name"] as string | undefined;
if (!name) {
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
@ -264,12 +265,12 @@ export class FlowManagerService extends AsyncProcessor {
request: Record<string, unknown>,
): Record<string, unknown> {
const id = request["flow-id"] as string | undefined;
if (!id) {
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
const inst = this.flows.get(id);
if (!inst) {
if (inst === undefined) {
throw new Error(`Flow not found: ${id}`);
}
@ -290,7 +291,7 @@ export class FlowManagerService extends AsyncProcessor {
const description = (request["description"] as string) ?? "";
const parameters = (request["parameters"] as Record<string, string>) ?? {};
if (!id) {
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
@ -299,7 +300,7 @@ export class FlowManagerService extends AsyncProcessor {
}
const blueprint = this.blueprints.get(blueprintName);
if (!blueprint) {
if (blueprint === undefined) {
throw new Error(`Blueprint not found: ${blueprintName}`);
}
@ -327,12 +328,12 @@ export class FlowManagerService extends AsyncProcessor {
request: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const id = request["flow-id"] as string | undefined;
if (!id) {
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
const inst = this.flows.get(id);
if (!inst) {
if (inst === undefined) {
throw new Error(`Flow not found: ${id}`);
}
@ -353,12 +354,12 @@ export class FlowManagerService extends AsyncProcessor {
* to the config service via a PUT operation.
*/
private async pushFlowsConfig(): Promise<void> {
if (!this.configClient) return;
if (this.configClient === null) return;
const flowsConfig: Record<string, { topics: Record<string, string> }> = {};
for (const [id, inst] of this.flows) {
const blueprint = this.blueprints.get(inst.blueprintName);
if (blueprint) {
if (blueprint !== undefined) {
flowsConfig[id] = { topics: blueprint.topics };
}
}
@ -380,15 +381,15 @@ export class FlowManagerService extends AsyncProcessor {
// ---------- Lifecycle ----------
override async stop(): Promise<void> {
if (this.consumer) {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer) {
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
if (this.configClient) {
if (this.configClient !== null) {
await this.configClient.stop();
this.configClient = null;
}
@ -400,6 +401,11 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const program = makeProcessorProgram({
id: "flow-manager",
make: (config) => new FlowManagerService(config),
});
export async function run(): Promise<void> {
await FlowManagerService.launch("flow-manager");
}

View file

@ -94,7 +94,7 @@ export class DispatcherManager {
key: string,
): Promise<RequestResponse<unknown, unknown>> {
let pending = this.requestors.get(key);
if (!pending) {
if (pending === undefined) {
pending = (async () => {
const rr = new RequestResponse({
pubsub: this.pubsub,
@ -114,7 +114,7 @@ export class DispatcherManager {
kind: string,
): { requestTopic: string; responseTopic: string } {
const entry = GLOBAL_SERVICES.get(kind);
if (entry) {
if (entry !== undefined) {
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
@ -131,7 +131,7 @@ export class DispatcherManager {
kind: string,
): { requestTopic: string; responseTopic: string } {
const entry = FLOW_SERVICES.get(kind);
if (entry) {
if (entry !== undefined) {
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
@ -152,15 +152,15 @@ export class DispatcherManager {
if (typeof response !== "object" || response === null) return true;
const res = response as Record<string, unknown>;
return (
!!res.complete ||
!!res.endOfStream ||
!!res.endOfSession ||
!!res.end_of_stream ||
!!res.end_of_session ||
!!res.end_of_dialog ||
!!res.eos ||
res.complete === true ||
res.endOfStream === true ||
res.endOfSession === true ||
res.end_of_stream === true ||
res.end_of_session === true ||
res.end_of_dialog === true ||
res.eos === true ||
// error responses are always final
!!res.error
(res.error !== undefined && res.error !== null)
);
}

View file

@ -25,8 +25,11 @@ export class Mux {
private queue = new AsyncQueue<MuxRequest>();
private outstanding = 0;
private running = true;
private readonly handler: MuxHandler;
constructor(private readonly handler: MuxHandler) {}
constructor(handler: MuxHandler) {
this.handler = handler;
}
receive(request: MuxRequest): void {
if (this.queue.length >= MAX_QUEUE_SIZE) {

View file

@ -65,14 +65,18 @@ export function clientTermToInternal(wire: ClientTerm): Term {
return {
type: "LITERAL",
value: wire.v,
datatype: wire.dt,
language: wire.ln,
...(wire.dt !== undefined ? { datatype: wire.dt } : {}),
...(wire.ln !== undefined ? { language: wire.ln } : {}),
};
case "t":
case "t": {
if (wire.tr === undefined) {
throw new Error("Client triple term is missing tr");
}
return {
type: "TRIPLE",
triple: wire.tr ? clientTripleToInternal(wire.tr) : undefined!,
triple: clientTripleToInternal(wire.tr),
};
}
default:
// Defensive: pass through unknown term types
return wire as unknown as Term;
@ -105,14 +109,14 @@ export function internalTermToClient(term: Term): ClientTerm {
return { t: "b", d: term.id };
case "LITERAL": {
const lit: ClientLiteralTerm = { t: "l", v: term.value };
if (term.datatype) lit.dt = term.datatype;
if (term.language) lit.ln = term.language;
if (term.datatype !== undefined) lit.dt = term.datatype;
if (term.language !== undefined) lit.ln = term.language;
return lit;
}
case "TRIPLE":
return {
t: "t",
tr: term.triple ? internalTripleToClient(term.triple) : undefined,
tr: internalTripleToClient(term.triple),
};
default:
return term as unknown as ClientTerm;
@ -131,7 +135,10 @@ export function internalTripleToClient(triple: Triple): ClientTriple {
result.g = g;
} else {
// If g is a Term, convert it back to client wire format
result.g = (g as Record<string, unknown>).iri as string | undefined;
const iri = (g as Record<string, unknown>).iri;
if (typeof iri === "string") {
result.g = iri;
}
}
}
return result;

View file

@ -10,7 +10,9 @@
import Fastify from "fastify";
import websocketPlugin from "@fastify/websocket";
import { registry } from "@trustgraph/base";
import { Config, Effect } from "effect";
import * as O from "effect/Option";
import { errorMessage, optionalStringConfig, registry, toTgError } from "@trustgraph/base";
import { DispatcherManager } from "./dispatch/manager.js";
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
@ -33,9 +35,9 @@ export async function createGateway(config: GatewayConfig) {
if (request.url === "/api/v1/metrics") return;
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
if (config.secret) {
if (config.secret !== undefined && config.secret.length > 0) {
const auth = request.headers.authorization;
if (!auth || auth !== `Bearer ${config.secret}`) {
if (auth === undefined || auth !== `Bearer ${config.secret}`) {
reply.code(401).send({ error: "Unauthorized" });
}
}
@ -49,13 +51,13 @@ export async function createGateway(config: GatewayConfig) {
try {
const result = await dispatcher.dispatchGlobalService(kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
if (err) {
if (err !== undefined) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
return result;
} catch (err) {
reply.code(500).send({ error: { type: "internal", message: String(err) } });
reply.code(500).send({ error: toTgError(err) });
}
});
@ -69,13 +71,13 @@ export async function createGateway(config: GatewayConfig) {
try {
const result = await dispatcher.dispatchFlowService(flow, kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
if (err) {
if (err !== undefined) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
return result;
} catch (err) {
reply.code(500).send({ error: { type: "internal", message: String(err) } });
reply.code(500).send({ error: toTgError(err) });
}
},
);
@ -91,7 +93,7 @@ export async function createGateway(config: GatewayConfig) {
collection?: string;
};
if (!body.documentId) {
if (body.documentId === undefined || body.documentId.length === 0) {
return reply.code(400).send({
error: { type: "bad-request", message: "documentId is required" },
});
@ -116,7 +118,7 @@ export async function createGateway(config: GatewayConfig) {
return { status: "processing", documentId, flow };
} catch (err) {
reply.code(500).send({
error: { type: "internal", message: String(err) },
error: toTgError(err),
});
}
},
@ -128,14 +130,14 @@ export async function createGateway(config: GatewayConfig) {
// Auth via query param
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get("token");
if (config.secret && token !== config.secret) {
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
socket.close(4001, "Unauthorized");
return;
}
// Build the MuxHandler that dispatches to the DispatcherManager
const handler: MuxHandler = async (muxReq, respond) => {
if (muxReq.flow) {
if (muxReq.flow !== undefined && muxReq.flow.length > 0) {
await dispatcher.dispatchFlowServiceStreaming(
muxReq.flow,
muxReq.service,
@ -171,7 +173,13 @@ export async function createGateway(config: GatewayConfig) {
request?: Record<string, unknown>;
};
if (!msg.id || !msg.service || !msg.request) {
if (
msg.id === undefined ||
msg.id.length === 0 ||
msg.service === undefined ||
msg.service.length === 0 ||
msg.request === undefined
) {
socket.send(
JSON.stringify({
id: msg.id ?? null,
@ -185,15 +193,15 @@ export async function createGateway(config: GatewayConfig) {
const muxReq: MuxRequest = {
id: msg.id,
service: msg.service,
flow: msg.flow,
request: msg.request,
...(msg.flow !== undefined ? { flow: msg.flow } : {}),
};
mux.receive(muxReq);
} catch (err) {
socket.send(
JSON.stringify({
error: { type: "parse-error", message: String(err) },
error: { type: "parse-error", message: errorMessage(err) },
complete: true,
}),
);
@ -234,14 +242,36 @@ export async function createGateway(config: GatewayConfig) {
}
export async function run(): Promise<void> {
const config: GatewayConfig = {
port: parseInt(process.env.GATEWAY_PORT ?? "8088", 10),
metricsPort: parseInt(process.env.METRICS_PORT ?? "8000", 10),
secret: process.env.GATEWAY_SECRET,
natsUrl: process.env.NATS_URL,
};
const gateway = await createGateway(config);
await gateway.start();
console.log(`[Gateway] Listening on port ${config.port}`);
await Effect.runPromise(program);
}
export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () {
const secret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option));
const natsUrl = yield* optionalStringConfig("NATS_URL");
const port = yield* Config.number("GATEWAY_PORT").pipe(Config.withDefault(8088));
const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000));
return {
port,
metricsPort,
...(secret !== undefined ? { secret } : {}),
...(natsUrl !== undefined ? { natsUrl } : {}),
} 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;
}),
);

View file

@ -37,7 +37,12 @@ export {
} from "./query/embeddings/qdrant-graph.js";
// Embeddings services
export { OllamaEmbeddingsProcessor, type OllamaEmbeddingsConfig } from "./embeddings/ollama.js";
export {
OllamaEmbeddingsLive,
OllamaEmbeddingsProcessor,
makeOllamaEmbeddings,
type OllamaEmbeddingsConfig,
} from "./embeddings/ollama.js";
// Prompt template service
export { PromptTemplateService, type PromptTemplate, type PromptTemplateConfig } from "./prompt/template.js";

View file

@ -54,7 +54,7 @@ export class CollectionManager {
ensureCollectionExists(user: string, collection: string): CollectionEntry {
const existing = this.getCollection(user, collection);
if (existing) return existing;
if (existing !== undefined) return existing;
return this.updateCollection(user, collection, collection, "", []);
}

View file

@ -10,9 +10,6 @@
* Python reference: trustgraph-flow/trustgraph/librarian/service/service.py
*/
import { randomUUID } from "node:crypto";
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
AsyncProcessor,
type ProcessorConfig,
@ -24,8 +21,18 @@ import {
type DocumentMetadata,
type ProcessingMetadata,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
import { CollectionManager } from "./collection-manager.js";
import {
ensureDirectory,
joinPath,
readBinaryFile,
readTextFile,
removePath,
writeBinaryFile,
writeTextFile,
} from "../runtime/effect-files.js";
export interface LibrarianServiceConfig extends ProcessorConfig {
dataDir?: string;
@ -49,12 +56,12 @@ export class LibrarianService extends AsyncProcessor {
constructor(config: LibrarianServiceConfig) {
super(config);
this.dataDir = config.dataDir ?? process.env.LIBRARIAN_DATA_DIR ?? "./data/librarian";
this.persistPath = join(this.dataDir, "librarian-state.json");
this.persistPath = joinPath(this.dataDir, "librarian-state.json");
}
protected override async run(): Promise<void> {
// Ensure directories exist
await mkdir(join(this.dataDir, "docs"), { recursive: true });
await ensureDirectory(joinPath(this.dataDir, "docs"));
// Load persisted state
await this.loadFromDisk();
@ -84,14 +91,14 @@ export class LibrarianService extends AsyncProcessor {
try {
// Poll librarian requests
const libMsg = await this.libConsumer.receive(2000);
if (libMsg) {
if (libMsg !== null) {
await this.handleLibrarianMessage(libMsg);
await this.libConsumer.acknowledge(libMsg);
}
// Poll collection management requests
const colMsg = await this.colConsumer.receive(2000);
if (colMsg) {
if (colMsg !== null) {
await this.handleCollectionMessage(colMsg);
await this.colConsumer.acknowledge(colMsg);
}
@ -110,7 +117,7 @@ export class LibrarianService extends AsyncProcessor {
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
if (requestId === undefined || requestId.length === 0) {
console.warn("[LibrarianService] Received request without id, ignoring");
return;
}
@ -156,9 +163,9 @@ export class LibrarianService extends AsyncProcessor {
private async addDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
const meta = request.documentMetadata;
if (!meta) throw new Error("add-document requires documentMetadata");
if (meta === undefined) throw new Error("add-document requires documentMetadata");
const id = randomUUID();
const id = crypto.randomUUID();
const now = Date.now();
const doc: DocumentMetadata = {
@ -170,10 +177,10 @@ export class LibrarianService extends AsyncProcessor {
this.documents.set(id, doc);
// Store file content if provided
if (request.content) {
const filePath = join(this.dataDir, "docs", `${id}.bin`);
if (request.content !== undefined && request.content.length > 0) {
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
const buf = Buffer.from(request.content, "base64");
await writeFile(filePath, buf);
await writeBinaryFile(filePath, buf);
}
await this.persist();
@ -184,14 +191,16 @@ export class LibrarianService extends AsyncProcessor {
private async removeDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
const id = request.documentId;
if (!id) throw new Error("remove-document requires documentId");
if (id === undefined || id.length === 0) {
throw new Error("remove-document requires documentId");
}
// Remove the document itself
this.documents.delete(id);
// Remove the file
try {
await unlink(join(this.dataDir, "docs", `${id}.bin`));
await removePath(joinPath(this.dataDir, "docs", `${id}.bin`));
} catch {
// File may not exist — that's fine
}
@ -204,7 +213,7 @@ export class LibrarianService extends AsyncProcessor {
for (const childId of childIds) {
this.documents.delete(childId);
try {
await unlink(join(this.dataDir, "docs", `${childId}.bin`));
await removePath(joinPath(this.dataDir, "docs", `${childId}.bin`));
} catch {
// ignore
}
@ -231,9 +240,9 @@ export class LibrarianService extends AsyncProcessor {
for (const doc of this.documents.values()) {
// Filter by user
if (user && doc.user !== user) continue;
if (user.length > 0 && doc.user !== user) continue;
// Exclude children (only top-level documents) unless explicitly requested
if (doc.parentId) continue;
if (doc.parentId !== undefined && doc.parentId.length > 0) continue;
docs.push(doc);
}
@ -242,25 +251,29 @@ export class LibrarianService extends AsyncProcessor {
private getDocumentMetadata(request: LibrarianRequest): LibrarianResponse {
const id = request.documentId;
if (!id) throw new Error("get-document-metadata requires documentId");
if (id === undefined || id.length === 0) {
throw new Error("get-document-metadata requires documentId");
}
const doc = this.documents.get(id);
if (!doc) throw new Error(`Document not found: ${id}`);
if (doc === undefined) throw new Error(`Document not found: ${id}`);
return { documentMetadata: doc };
}
private async getDocumentContent(request: LibrarianRequest): Promise<LibrarianResponse> {
const id = request.documentId;
if (!id) throw new Error("get-document-content requires documentId");
if (id === undefined || id.length === 0) {
throw new Error("get-document-content requires documentId");
}
const doc = this.documents.get(id);
if (!doc) throw new Error(`Document not found: ${id}`);
if (doc === undefined) throw new Error(`Document not found: ${id}`);
try {
const filePath = join(this.dataDir, "docs", `${id}.bin`);
const buf = await readFile(filePath);
const content = buf.toString("base64");
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
const buf = await readBinaryFile(filePath);
const content = Buffer.from(buf).toString("base64");
return { documentMetadata: doc, content };
} catch {
throw new Error(`Document content not found on disk: ${id}`);
@ -269,15 +282,19 @@ export class LibrarianService extends AsyncProcessor {
private async addChildDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
const meta = request.documentMetadata;
if (!meta) throw new Error("add-child-document requires documentMetadata");
if (!meta.parentId) throw new Error("add-child-document requires parentId in metadata");
if (meta === undefined) {
throw new Error("add-child-document requires documentMetadata");
}
if (meta.parentId === undefined || meta.parentId.length === 0) {
throw new Error("add-child-document requires parentId in metadata");
}
// Verify parent exists
if (!this.documents.has(meta.parentId)) {
throw new Error(`Parent document not found: ${meta.parentId}`);
}
const id = randomUUID();
const id = crypto.randomUUID();
const now = Date.now();
const doc: DocumentMetadata = {
@ -289,10 +306,10 @@ export class LibrarianService extends AsyncProcessor {
this.documents.set(id, doc);
// Store file content if provided
if (request.content) {
const filePath = join(this.dataDir, "docs", `${id}.bin`);
if (request.content !== undefined && request.content.length > 0) {
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
const buf = Buffer.from(request.content, "base64");
await writeFile(filePath, buf);
await writeBinaryFile(filePath, buf);
}
await this.persist();
@ -303,7 +320,9 @@ export class LibrarianService extends AsyncProcessor {
private listChildren(request: LibrarianRequest): LibrarianResponse {
const parentId = request.documentId;
if (!parentId) throw new Error("list-children requires documentId");
if (parentId === undefined || parentId.length === 0) {
throw new Error("list-children requires documentId");
}
const children: DocumentMetadata[] = [];
for (const doc of this.documents.values()) {
@ -317,9 +336,9 @@ export class LibrarianService extends AsyncProcessor {
private async addProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
const proc = request.processingMetadata;
if (!proc) throw new Error("add-processing requires processingMetadata");
if (proc === undefined) throw new Error("add-processing requires processingMetadata");
const id = randomUUID();
const id = crypto.randomUUID();
const now = Date.now();
const record: ProcessingMetadata = {
@ -337,7 +356,9 @@ export class LibrarianService extends AsyncProcessor {
private async removeProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
const id = request.processingId;
if (!id) throw new Error("remove-processing requires processingId");
if (id === undefined || id.length === 0) {
throw new Error("remove-processing requires processingId");
}
this.processing.delete(id);
await this.persist();
@ -350,7 +371,9 @@ export class LibrarianService extends AsyncProcessor {
const records: ProcessingMetadata[] = [];
for (const proc of this.processing.values()) {
if (documentId && proc.documentId !== documentId) continue;
if (documentId !== undefined && documentId.length > 0 && proc.documentId !== documentId) {
continue;
}
records.push(proc);
}
@ -364,7 +387,7 @@ export class LibrarianService extends AsyncProcessor {
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
if (requestId === undefined || requestId.length === 0) {
console.warn("[LibrarianService] Received collection request without id, ignoring");
return;
}
@ -430,8 +453,7 @@ export class LibrarianService extends AsyncProcessor {
};
const json = JSON.stringify(data, null, 2);
await mkdir(dirname(this.persistPath), { recursive: true });
await writeFile(this.persistPath, json, "utf-8");
await writeTextFile(this.persistPath, json);
} catch (err) {
console.error("[LibrarianService] Failed to persist state:", err);
}
@ -439,7 +461,7 @@ export class LibrarianService extends AsyncProcessor {
private async loadFromDisk(): Promise<void> {
try {
const raw = await readFile(this.persistPath, "utf-8");
const raw = await readTextFile(this.persistPath);
const parsed = JSON.parse(raw) as {
documents?: Record<string, DocumentMetadata>;
processing?: Record<string, ProcessingMetadata>;
@ -447,20 +469,20 @@ export class LibrarianService extends AsyncProcessor {
};
this.documents.clear();
if (parsed.documents) {
if (parsed.documents !== undefined) {
for (const [id, doc] of Object.entries(parsed.documents)) {
this.documents.set(id, doc);
}
}
this.processing.clear();
if (parsed.processing) {
if (parsed.processing !== undefined) {
for (const [id, proc] of Object.entries(parsed.processing)) {
this.processing.set(id, proc);
}
}
if (parsed.collections) {
if (parsed.collections !== undefined) {
this.collectionManager.loadFromJSON(parsed.collections);
}
@ -473,19 +495,19 @@ export class LibrarianService extends AsyncProcessor {
}
override async stop(): Promise<void> {
if (this.libConsumer) {
if (this.libConsumer !== null) {
await this.libConsumer.close();
this.libConsumer = null;
}
if (this.libProducer) {
if (this.libProducer !== null) {
await this.libProducer.close();
this.libProducer = null;
}
if (this.colConsumer) {
if (this.colConsumer !== null) {
await this.colConsumer.close();
this.colConsumer = null;
}
if (this.colProducer) {
if (this.colProducer !== null) {
await this.colProducer.close();
this.colProducer = null;
}
@ -497,6 +519,11 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const program = makeProcessorProgram({
id: "librarian-svc",
make: (config) => new LibrarianService(config),
});
export async function run(): Promise<void> {
await LibrarianService.launch("librarian-svc");
}

View file

@ -14,8 +14,9 @@ import {
type ProcessorConfig,
type LlmResult,
type LlmChunk,
TooManyRequestsError,
tooManyRequestsError,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class AzureOpenAIProcessor extends LlmService {
private client: AzureOpenAI;
@ -40,10 +41,14 @@ export class AzureOpenAIProcessor extends LlmService {
this.maxOutput = config.maxOutput ?? 4096;
const apiKey = config.apiKey ?? process.env.AZURE_TOKEN;
if (!apiKey) throw new Error("Azure OpenAI API key not specified");
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("Azure OpenAI API key not specified");
}
const endpoint = config.endpoint ?? process.env.AZURE_ENDPOINT;
if (!endpoint) throw new Error("Azure OpenAI endpoint not specified");
if (endpoint === undefined || endpoint.length === 0) {
throw new Error("Azure OpenAI endpoint not specified");
}
const apiVersion =
config.apiVersion ??
@ -83,7 +88,7 @@ export class AzureOpenAIProcessor extends LlmService {
};
} catch (err) {
if ((err as any)?.status === 429) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
@ -119,9 +124,10 @@ export class AzureOpenAIProcessor extends LlmService {
let totalOutputTokens = 0;
for await (const chunk of stream) {
if (chunk.choices?.[0]?.delta?.content) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: chunk.choices[0].delta.content,
text: content,
inToken: null,
outToken: null,
model: modelName,
@ -129,7 +135,7 @@ export class AzureOpenAIProcessor extends LlmService {
};
}
if (chunk.usage) {
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
@ -144,13 +150,18 @@ export class AzureOpenAIProcessor extends LlmService {
};
} catch (err) {
if ((err as any)?.status === 429) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
}
}
export const program = makeProcessorProgram({
id: "text-completion",
make: (config) => new AzureOpenAIProcessor(config),
});
export async function run(): Promise<void> {
await AzureOpenAIProcessor.launch("text-completion");
}

View file

@ -5,7 +5,8 @@
*/
import Anthropic from "@anthropic-ai/sdk";
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, TooManyRequestsError } from "@trustgraph/base";
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class ClaudeProcessor extends LlmService {
private client: Anthropic;
@ -26,7 +27,9 @@ export class ClaudeProcessor extends LlmService {
this.maxOutput = config.maxOutput ?? 8192;
const apiKey = config.apiKey ?? process.env.CLAUDE_KEY;
if (!apiKey) throw new Error("Claude API key not specified");
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("Claude API key not specified");
}
this.client = new Anthropic({ apiKey });
@ -65,7 +68,7 @@ export class ClaudeProcessor extends LlmService {
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
@ -117,13 +120,18 @@ export class ClaudeProcessor extends LlmService {
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
}
}
export const program = makeProcessorProgram({
id: "text-completion",
make: (config) => new ClaudeProcessor(config),
});
export async function run(): Promise<void> {
await ClaudeProcessor.launch("text-completion");
}

View file

@ -12,8 +12,9 @@ import {
type ProcessorConfig,
type LlmResult,
type LlmChunk,
TooManyRequestsError,
tooManyRequestsError,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class MistralProcessor extends LlmService {
private client: Mistral;
@ -37,7 +38,9 @@ export class MistralProcessor extends LlmService {
this.maxOutput = config.maxOutput ?? 4096;
const apiKey = config.apiKey ?? process.env.MISTRAL_TOKEN;
if (!apiKey) throw new Error("Mistral API key not specified");
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("Mistral API key not specified");
}
this.client = new Mistral({ apiKey });
@ -72,7 +75,7 @@ export class MistralProcessor extends LlmService {
};
} catch (err) {
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
@ -107,9 +110,10 @@ export class MistralProcessor extends LlmService {
for await (const chunk of stream) {
const delta = chunk.data?.choices?.[0]?.delta;
if (delta?.content) {
const content = delta?.content;
if (typeof content === "string" && content.length > 0) {
yield {
text: delta.content as string,
text: content,
inToken: null,
outToken: null,
model: modelName,
@ -117,7 +121,7 @@ export class MistralProcessor extends LlmService {
};
}
if (chunk.data?.usage) {
if (chunk.data?.usage !== undefined) {
totalInputTokens = chunk.data.usage.promptTokens ?? 0;
totalOutputTokens = chunk.data.usage.completionTokens ?? 0;
}
@ -132,13 +136,18 @@ export class MistralProcessor extends LlmService {
};
} catch (err) {
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
}
}
export const program = makeProcessorProgram({
id: "text-completion",
make: (config) => new MistralProcessor(config),
});
export async function run(): Promise<void> {
await MistralProcessor.launch("text-completion");
}

View file

@ -8,6 +8,7 @@
import { Ollama } from "ollama";
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk } from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class OllamaProcessor extends LlmService {
private client: Ollama;
@ -90,7 +91,7 @@ export class OllamaProcessor extends LlmService {
totalOutputTokens = chunk.eval_count;
}
if (chunk.response) {
if (chunk.response.length > 0) {
yield {
text: chunk.response,
inToken: null,
@ -112,6 +113,11 @@ export class OllamaProcessor extends LlmService {
}
}
export const program = makeProcessorProgram({
id: "text-completion",
make: (config) => new OllamaProcessor(config),
});
export async function run(): Promise<void> {
await OllamaProcessor.launch("text-completion");
}

View file

@ -16,6 +16,7 @@ import {
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class OpenAICompatibleProcessor extends LlmService {
private client: OpenAI;
@ -40,10 +41,11 @@ export class OpenAICompatibleProcessor extends LlmService {
this.maxOutput = config.maxOutput ?? 4096;
const baseURL = config.baseUrl ?? process.env.OPENAI_COMPAT_URL;
if (!baseURL)
if (baseURL === undefined || baseURL.length === 0) {
throw new Error(
"OpenAI-compatible server URL not specified (set OPENAI_COMPAT_URL)",
);
}
const apiKey =
config.apiKey ?? process.env.OPENAI_COMPAT_KEY ?? "sk-no-key-required";
@ -108,9 +110,10 @@ export class OpenAICompatibleProcessor extends LlmService {
let totalOutputTokens = 0;
for await (const chunk of stream) {
if (chunk.choices?.[0]?.delta?.content) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: chunk.choices[0].delta.content,
text: content,
inToken: null,
outToken: null,
model: modelName,
@ -118,7 +121,7 @@ export class OpenAICompatibleProcessor extends LlmService {
};
}
if (chunk.usage) {
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
@ -134,6 +137,11 @@ export class OpenAICompatibleProcessor extends LlmService {
}
}
export const program = makeProcessorProgram({
id: "text-completion",
make: (config) => new OpenAICompatibleProcessor(config),
});
export async function run(): Promise<void> {
await OpenAICompatibleProcessor.launch("text-completion");
}

View file

@ -5,7 +5,8 @@
*/
import OpenAI from "openai";
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, TooManyRequestsError } from "@trustgraph/base";
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export class OpenAIProcessor extends LlmService {
private client: OpenAI;
@ -27,7 +28,9 @@ export class OpenAIProcessor extends LlmService {
this.maxOutput = config.maxOutput ?? 4096;
const apiKey = config.apiKey ?? process.env.OPENAI_TOKEN;
if (!apiKey) throw new Error("OpenAI API key not specified");
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("OpenAI API key not specified");
}
this.client = new OpenAI({
apiKey,
@ -65,7 +68,7 @@ export class OpenAIProcessor extends LlmService {
};
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
@ -101,9 +104,10 @@ export class OpenAIProcessor extends LlmService {
let totalOutputTokens = 0;
for await (const chunk of stream) {
if (chunk.choices?.[0]?.delta?.content) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: chunk.choices[0].delta.content,
text: content,
inToken: null,
outToken: null,
model: modelName,
@ -111,7 +115,7 @@ export class OpenAIProcessor extends LlmService {
};
}
if (chunk.usage) {
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
@ -126,13 +130,18 @@ export class OpenAIProcessor extends LlmService {
};
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
throw new TooManyRequestsError();
throw tooManyRequestsError();
}
throw err;
}
}
}
export const program = makeProcessorProgram({
id: "text-completion",
make: (config) => new OpenAIProcessor(config),
});
export async function run(): Promise<void> {
await OpenAIProcessor.launch("text-completion");
}

View file

@ -33,6 +33,7 @@ import {
type PromptRequest,
type PromptResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
export interface PromptTemplate {
system: string;
@ -53,7 +54,7 @@ export class PromptTemplateService extends FlowProcessor {
this.configKey = config.configKey ?? "prompt";
this.registerSpecification(
new ConsumerSpec<PromptRequest>(
ConsumerSpec.fromPromise<PromptRequest>(
"prompt-request",
this.onRequest.bind(this),
),
@ -75,7 +76,7 @@ export class PromptTemplateService extends FlowProcessor {
| Record<string, { system?: string; prompt?: string }>
| undefined;
if (!promptConfig) {
if (promptConfig === undefined) {
console.warn(`[PromptTemplate] No key "${this.configKey}" in config`);
return;
}
@ -104,13 +105,13 @@ export class PromptTemplateService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<PromptResponse>("prompt-response");
try {
const template = this.templates.get(msg.name);
if (!template) {
if (template === undefined) {
throw new Error(`Unknown prompt template: "${msg.name}"`);
}
@ -149,6 +150,11 @@ function renderTemplate(
});
}
export const program = makeProcessorProgram({
id: "prompt",
make: (config) => new PromptTemplateService(config),
});
export async function run(): Promise<void> {
await PromptTemplateService.launch("prompt");
}

View file

@ -16,6 +16,7 @@ import {
type DocumentEmbeddingsRequest,
type DocumentEmbeddingsResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { QdrantDocEmbeddingsQuery } from "./qdrant-doc.js";
export class DocEmbeddingsQueryService extends FlowProcessor {
@ -26,7 +27,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
this.query = new QdrantDocEmbeddingsQuery();
this.registerSpecification(
new ConsumerSpec<DocumentEmbeddingsRequest>(
ConsumerSpec.fromPromise<DocumentEmbeddingsRequest>(
"document-embeddings-request",
this.onMessage.bind(this),
),
@ -44,7 +45,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const producer = flowCtx.flow.producer<DocumentEmbeddingsResponse>("document-embeddings-response");
const collection = msg.collection ?? "default";
@ -64,7 +65,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
allChunks.push({
chunkId: match.chunkId,
score: match.score,
content: match.content,
...(match.content !== undefined ? { content: match.content } : {}),
});
}
}
@ -80,6 +81,11 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
}
}
export const program = makeProcessorProgram({
id: "doc-embeddings-query",
make: (config) => new DocEmbeddingsQueryService(config),
});
export async function run(): Promise<void> {
await DocEmbeddingsQueryService.launch("doc-embeddings-query");
}

View file

@ -34,7 +34,10 @@ export class QdrantDocEmbeddingsQuery {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
console.log("[QdrantDocQuery] Query service initialized");
}
@ -42,7 +45,7 @@ export class QdrantDocEmbeddingsQuery {
async query(request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> {
const { vector, user, collection, limit } = request;
if (!vector || vector.length === 0) {
if (vector.length === 0) {
return [];
}
@ -68,11 +71,11 @@ export class QdrantDocEmbeddingsQuery {
for (const point of searchResult) {
const payload = point.payload as Record<string, unknown> | undefined;
const chunkId = payload?.chunk_id as string | undefined;
if (chunkId) {
if (chunkId !== undefined && chunkId.length > 0) {
chunks.push({
chunkId,
score: point.score,
content: (payload?.content as string) ?? undefined,
...(typeof payload?.content === "string" ? { content: payload.content } : {}),
});
}
}

View file

@ -16,6 +16,7 @@ import {
type GraphEmbeddingsRequest,
type GraphEmbeddingsResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { QdrantGraphEmbeddingsQuery } from "./qdrant-graph.js";
export class GraphEmbeddingsQueryService extends FlowProcessor {
@ -26,7 +27,7 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
this.query = new QdrantGraphEmbeddingsQuery();
this.registerSpecification(
new ConsumerSpec<GraphEmbeddingsRequest>(
ConsumerSpec.fromPromise<GraphEmbeddingsRequest>(
"graph-embeddings-request",
this.onMessage.bind(this),
),
@ -44,7 +45,7 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response");
const user = msg.user ?? "default";
@ -79,6 +80,11 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
}
}
export const program = makeProcessorProgram({
id: "graph-embeddings-query",
make: (config) => new GraphEmbeddingsQueryService(config),
});
export async function run(): Promise<void> {
await GraphEmbeddingsQueryService.launch("graph-embeddings-query");
}

View file

@ -44,7 +44,10 @@ export class QdrantGraphEmbeddingsQuery {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
console.log("[QdrantGraphQuery] Query service initialized");
}
@ -52,7 +55,7 @@ export class QdrantGraphEmbeddingsQuery {
async query(request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> {
const { vector, user, collection, limit } = request;
if (!vector || vector.length === 0) {
if (vector.length === 0) {
return [];
}
@ -82,7 +85,7 @@ export class QdrantGraphEmbeddingsQuery {
for (const point of searchResult) {
const payload = point.payload as Record<string, unknown> | undefined;
const entityValue = payload?.entity as string | undefined;
if (!entityValue) continue;
if (entityValue === undefined || entityValue.length === 0) continue;
// Deduplicate by entity value, keeping the highest score (results are
// already sorted by score descending from Qdrant)

View file

@ -16,6 +16,7 @@ import {
type TriplesQueryRequest,
type TriplesQueryResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { FalkorDBTriplesQuery } from "./falkordb.js";
export class TriplesQueryService extends FlowProcessor {
@ -26,7 +27,7 @@ export class TriplesQueryService extends FlowProcessor {
this.query = new FalkorDBTriplesQuery();
this.registerSpecification(
new ConsumerSpec<TriplesQueryRequest>("triples-request", this.onMessage.bind(this)),
ConsumerSpec.fromPromise<TriplesQueryRequest>("triples-request", this.onMessage.bind(this)),
);
this.registerSpecification(new ProducerSpec<TriplesQueryResponse>("triples-response"));
@ -39,7 +40,7 @@ export class TriplesQueryService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const producer = flowCtx.flow.producer<TriplesQueryResponse>("triples-response");
@ -62,6 +63,11 @@ export class TriplesQueryService extends FlowProcessor {
}
}
export const program = makeProcessorProgram({
id: "triples-query",
make: (config) => new TriplesQueryService(config),
});
export async function run(): Promise<void> {
await TriplesQueryService.launch("triples-query");
}

View file

@ -15,7 +15,7 @@ export interface FalkorDBQueryConfig {
}
function termToValue(term: Term | undefined): string | null {
if (!term) return null;
if (term === undefined) return null;
switch (term.type) {
case "IRI": return term.iri;
case "LITERAL": return term.value;
@ -25,7 +25,7 @@ function termToValue(term: Term | undefined): string | null {
}
function createTerm(value: string): Term {
if (!value) {
if (value.length === 0) {
return { type: "LITERAL", value: "" };
}
if (value.startsWith("http://") || value.startsWith("https://")) {
@ -75,25 +75,25 @@ export class FalkorDBTriplesQuery {
const rawTriples: [string, string, string][] = [];
// Query both Node and Literal targets for each pattern
if (sv && pv && ov) {
if (sv !== null && pv !== null && ov !== null) {
// SPO — exact match
await this.matchPattern(rawTriples, sv, pv, ov, limit);
} else if (sv && pv) {
} else if (sv !== null && pv !== null) {
// SP — known subject + predicate
await this.matchSP(rawTriples, sv, pv, limit);
} else if (sv && ov) {
} else if (sv !== null && ov !== null) {
// SO — known subject + object
await this.matchSO(rawTriples, sv, ov, limit);
} else if (pv && ov) {
} else if (pv !== null && ov !== null) {
// PO — known predicate + object
await this.matchPO(rawTriples, pv, ov, limit);
} else if (sv) {
} else if (sv !== null) {
// S only
await this.matchS(rawTriples, sv, limit);
} else if (pv) {
} else if (pv !== null) {
// P only
await this.matchP(rawTriples, pv, limit);
} else if (ov) {
} else if (ov !== null) {
// O only
await this.matchO(rawTriples, ov, limit);
} else {
@ -102,7 +102,7 @@ export class FalkorDBTriplesQuery {
}
return rawTriples
.filter(([s, p, o]) => s != null && p != null && o != null)
.filter(([s, p, o]) => s !== null && p !== null && o !== null)
.slice(0, limit)
.map(([s, p, o]) => ({
s: createTerm(s),

View file

@ -27,6 +27,7 @@ import {
type PromptRequest,
type PromptResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { DocumentRag } from "./document-rag.js";
export class DocumentRagService extends FlowProcessor {
@ -35,7 +36,7 @@ export class DocumentRagService extends FlowProcessor {
// Consumer: document RAG requests
this.registerSpecification(
new ConsumerSpec<DocumentRagRequest>("document-rag-request", this.onRequest.bind(this)),
ConsumerSpec.fromPromise<DocumentRagRequest>("document-rag-request", this.onRequest.bind(this)),
);
// Producer: document RAG responses
@ -80,7 +81,7 @@ export class DocumentRagService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const producer = flowCtx.flow.producer<DocumentRagResponse>("document-rag-response");
@ -93,7 +94,7 @@ export class DocumentRagService extends FlowProcessor {
});
const response = await documentRag.query(msg.query, {
collection: msg.collection,
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
});
await producer.send(requestId, { response, endOfStream: true });
@ -107,6 +108,11 @@ export class DocumentRagService extends FlowProcessor {
}
}
export const program = makeProcessorProgram({
id: "document-rag",
make: (config) => new DocumentRagService(config),
});
export async function run(): Promise<void> {
await DocumentRagService.launch("document-rag");
}

View file

@ -8,7 +8,7 @@
*/
import type {
RequestResponse,
FlowRequestor,
TextCompletionRequest,
TextCompletionResponse,
EmbeddingsRequest,
@ -20,16 +20,20 @@ import type {
} from "@trustgraph/base";
export interface DocumentRagClients {
llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>;
embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>;
docEmbeddings: RequestResponse<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>;
prompt: RequestResponse<PromptRequest, PromptResponse>;
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
embeddings: FlowRequestor<EmbeddingsRequest, EmbeddingsResponse>;
docEmbeddings: FlowRequestor<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>;
prompt: FlowRequestor<PromptRequest, PromptResponse>;
}
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
export class DocumentRag {
constructor(private readonly clients: DocumentRagClients) {}
private readonly clients: DocumentRagClients;
constructor(clients: DocumentRagClients) {
this.clients = clients;
}
async query(
queryText: string,
@ -57,8 +61,9 @@ export class DocumentRag {
// Step 3: Build context from chunks
const context = chunks
.filter((c) => c.content)
.map((c) => c.content)
.flatMap((c) =>
c.content !== undefined && c.content.length > 0 ? [c.content] : [],
)
.join("\n\n---\n\n");
// Step 4: Synthesize answer

View file

@ -31,6 +31,7 @@ import {
type PromptRequest,
type PromptResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { GraphRag } from "./graph-rag.js";
export class GraphRagService extends FlowProcessor {
@ -39,7 +40,7 @@ export class GraphRagService extends FlowProcessor {
// Consumer: graph RAG requests
this.registerSpecification(
new ConsumerSpec<GraphRagRequest>("graph-rag-request", this.onRequest.bind(this)),
ConsumerSpec.fromPromise<GraphRagRequest>("graph-rag-request", this.onRequest.bind(this)),
);
// Producer: graph RAG responses
@ -91,7 +92,7 @@ export class GraphRagService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
@ -107,15 +108,17 @@ export class GraphRagService extends FlowProcessor {
prompt: flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt"),
},
{
entityLimit: msg.entityLimit,
tripleLimit: msg.tripleLimit,
maxSubgraphSize: msg.maxSubgraphSize,
maxPathLength: msg.maxPathLength,
...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}),
...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}),
...(msg.maxSubgraphSize !== undefined
? { maxSubgraphSize: msg.maxSubgraphSize }
: {}),
...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}),
},
);
const result = await graphRag.query(msg.query, {
collection: msg.collection,
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
});
// Send answer with explain data embedded in a SINGLE message.
@ -145,6 +148,11 @@ export class GraphRagService extends FlowProcessor {
}
}
export const program = makeProcessorProgram({
id: "graph-rag",
make: (config) => new GraphRagService(config),
});
export async function run(): Promise<void> {
await GraphRagService.launch("graph-rag");
}

View file

@ -16,9 +16,9 @@ import type {
EmbeddingsResponse,
GraphEmbeddingsRequest,
GraphEmbeddingsResponse,
FlowRequestor,
PromptRequest,
PromptResponse,
RequestResponse,
Term,
TextCompletionRequest,
TextCompletionResponse,
@ -37,11 +37,11 @@ export interface GraphRagConfig {
}
export interface GraphRagClients {
llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>;
embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>;
graphEmbeddings: RequestResponse<GraphEmbeddingsRequest, GraphEmbeddingsResponse>;
triples: RequestResponse<TriplesQueryRequest, TriplesQueryResponse>;
prompt: RequestResponse<PromptRequest, PromptResponse>;
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
embeddings: FlowRequestor<EmbeddingsRequest, EmbeddingsResponse>;
graphEmbeddings: FlowRequestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>;
triples: FlowRequestor<TriplesQueryRequest, TriplesQueryResponse>;
prompt: FlowRequestor<PromptRequest, PromptResponse>;
}
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
@ -52,12 +52,14 @@ export interface GraphRagResult {
}
export class GraphRag {
private readonly clients: GraphRagClients;
private config: Required<GraphRagConfig>;
constructor(
private readonly clients: GraphRagClients,
clients: GraphRagClients,
config: GraphRagConfig = {},
) {
this.clients = clients;
this.config = {
entityLimit: config.entityLimit ?? 50,
tripleLimit: config.tripleLimit ?? 30,
@ -125,7 +127,7 @@ export class GraphRag {
return (llmResp as TextCompletionResponse).response
.split("\n")
.map((c) => c.trim())
.filter(Boolean);
.filter((c) => c.length > 0);
}
private async getVectors(concepts: string[]): Promise<number[][]> {
@ -166,11 +168,12 @@ export class GraphRag {
// Query each entity as subject to get outgoing edges
const queries = unvisited.map((entityStr) => {
const term = stringToTerm(entityStr);
return this.clients.triples.request({
const request: TriplesQueryRequest = {
s: term,
collection,
limit: this.config.tripleLimit,
});
...(collection !== undefined ? { collection } : {}),
};
return this.clients.triples.request(request);
});
const results = await Promise.all(queries);
@ -257,7 +260,12 @@ export class GraphRag {
const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>;
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item && typeof item.id === "string" && typeof item.score === "number") {
if (
typeof item === "object" &&
item !== null &&
typeof item.id === "string" &&
typeof item.score === "number"
) {
scored.push({ id: item.id, score: item.score });
}
}
@ -266,10 +274,15 @@ export class GraphRag {
// Fall back to parsing line-by-line JSON objects
for (const line of responseText.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.length === 0) continue;
try {
const obj = JSON.parse(trimmed) as { id?: string; score?: number };
if (obj && typeof obj.id === "string" && typeof obj.score === "number") {
if (
typeof obj === "object" &&
obj !== null &&
typeof obj.id === "string" &&
typeof obj.score === "number"
) {
scored.push({ id: obj.id, score: obj.score });
}
} catch {
@ -281,8 +294,6 @@ export class GraphRag {
// Sort by score descending and keep top N
scored.sort((a, b) => b.score - a.score);
const topN = scored.slice(0, this.config.edgeLimit);
const selectedIds = new Set(topN.map((e) => e.id));
// Map back to triples
const result: Triple[] = [];
for (const entry of topN) {
@ -317,7 +328,7 @@ export class GraphRag {
variables: { query, context },
});
if (chunkCallback) {
if (chunkCallback !== undefined) {
// Streaming response
let fullText = "";
await this.clients.llm.request(
@ -329,11 +340,11 @@ export class GraphRag {
{
recipient: async (resp) => {
const r = resp as TextCompletionResponse;
if (r.response) {
if (r.response.length > 0) {
fullText += r.response;
await chunkCallback(r.response, !!r.endOfStream);
await chunkCallback(r.response, r.endOfStream === true);
}
return !!r.endOfStream;
return r.endOfStream === true;
},
},
);

View file

@ -0,0 +1,40 @@
export function joinPath(...segments: string[]): string {
const joined = segments
.filter((segment) => segment.length > 0)
.join("/");
return joined.replace(/\/+/g, "/");
}
export function dirnamePath(path: string): string {
const normalized = path.replace(/\/+$/, "");
const index = normalized.lastIndexOf("/");
if (index < 0) return ".";
if (index === 0) return "/";
return normalized.slice(0, index);
}
export function ensureDirectory(path: string): Promise<void> {
return Bun.$`mkdir -p ${path}`.quiet().then(() => undefined);
}
export function readTextFile(path: string): Promise<string> {
return Bun.file(path).text();
}
export async function readBinaryFile(path: string): Promise<Uint8Array> {
return new Uint8Array(await Bun.file(path).arrayBuffer());
}
export function writeTextFile(path: string, data: string): Promise<void> {
return Bun.write(path, data).then(() => undefined);
}
export function writeBinaryFile(path: string, data: Uint8Array): Promise<void> {
return Bun.write(path, data).then(() => undefined);
}
export function removePath(path: string): Promise<void> {
return Bun.file(path).delete();
}

View file

@ -19,6 +19,7 @@ import {
type EmbeddingsRequest,
type EmbeddingsResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { QdrantGraphEmbeddingsStore } from "./qdrant-graph.js";
export class GraphEmbeddingsStoreService extends FlowProcessor {
@ -29,7 +30,7 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
this.store = new QdrantGraphEmbeddingsStore();
this.registerSpecification(
new ConsumerSpec<EntityContexts>(
ConsumerSpec.fromPromise<EntityContexts>(
"store-graph-embeddings-input",
this.onMessage.bind(this),
),
@ -47,10 +48,10 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
private async onMessage(
msg: EntityContexts,
properties: Record<string, string>,
_properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
if (!msg.entities || msg.entities.length === 0) return;
if (msg.entities.length === 0) return;
const embeddingsClient =
flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings-client");
@ -63,7 +64,7 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
// Call embeddings service
const embResponse = await embeddingsClient.request({ text: texts });
if (embResponse.error) {
if (embResponse.error !== undefined) {
console.error(
"[GraphEmbeddingsStore] Embeddings error:",
embResponse.error.message,
@ -86,6 +87,11 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
}
}
export const program = makeProcessorProgram({
id: "graph-embeddings-store",
make: (config) => new GraphEmbeddingsStoreService(config),
});
export async function run(): Promise<void> {
await GraphEmbeddingsStoreService.launch("graph-embeddings-store");
}

View file

@ -9,7 +9,6 @@
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "node:crypto";
export interface QdrantDocEmbeddingsConfig {
url?: string;
@ -36,7 +35,10 @@ export class QdrantDocEmbeddingsStore {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
console.log("[QdrantDocEmbeddings] Store initialized");
}
@ -61,8 +63,8 @@ export class QdrantDocEmbeddingsStore {
async store(message: DocEmbeddingsMessage): Promise<void> {
for (const chunk of message.chunks) {
if (!chunk.chunkId || chunk.chunkId === "") continue;
if (!chunk.vector || chunk.vector.length === 0) continue;
if (chunk.chunkId.length === 0) continue;
if (chunk.vector.length === 0) continue;
const dim = chunk.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
@ -72,11 +74,13 @@ export class QdrantDocEmbeddingsStore {
await this.client.upsert(name, {
points: [
{
id: randomUUID(),
id: crypto.randomUUID(),
vector: chunk.vector,
payload: {
chunk_id: chunk.chunkId,
...(chunk.content ? { content: chunk.content } : {}),
...(chunk.content !== undefined && chunk.content.length > 0
? { content: chunk.content }
: {}),
},
},
],

View file

@ -9,7 +9,6 @@
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "node:crypto";
import type { Term } from "@trustgraph/base";
export interface QdrantGraphEmbeddingsConfig {
@ -50,7 +49,10 @@ export class QdrantGraphEmbeddingsStore {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
console.log("[QdrantGraphEmbeddings] Store initialized");
}
@ -76,8 +78,8 @@ export class QdrantGraphEmbeddingsStore {
async store(message: GraphEmbeddingsMessage): Promise<void> {
for (const entry of message.entities) {
const entityValue = getTermValue(entry.entity);
if (!entityValue || entityValue === "") continue;
if (!entry.vector || entry.vector.length === 0) continue;
if (entityValue === null || entityValue.length === 0) continue;
if (entry.vector.length === 0) continue;
const dim = entry.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
@ -85,14 +87,14 @@ export class QdrantGraphEmbeddingsStore {
await this.ensureCollection(name, dim);
const payload: Record<string, unknown> = { entity: entityValue };
if (entry.chunkId) {
if (entry.chunkId !== undefined && entry.chunkId.length > 0) {
payload.chunk_id = entry.chunkId;
}
await this.client.upsert(name, {
points: [
{
id: randomUUID(),
id: crypto.randomUUID(),
vector: entry.vector,
payload,
},

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