trustgraph/ts/packages/flow/src/gateway/dispatch/manager.ts

303 lines
10 KiB
TypeScript
Raw Normal View History

2026-04-05 21:09:33 -05:00
/**
* Dispatcher manager routes requests to backend services via pub/sub.
*
2026-04-05 22:44:45 -05:00
* Maintains a service registry mapping service names to NATS topic pairs.
* Applies wire format translation on requests (client internal) and
* reverse translation on responses (internal client).
*
2026-04-05 21:09:33 -05:00
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
*/
2026-06-01 20:26:47 -05:00
import { makeNatsBackend, makeRequestResponse, type PubSubBackend, type RequestResponse } from "@trustgraph/base";
2026-04-05 21:09:33 -05:00
import type { GatewayConfig } from "../server.js";
2026-04-05 22:44:45 -05:00
import { translateRequest, translateResponse } from "./serialize.js";
2026-04-05 21:09:33 -05:00
export type Responder = (response: unknown, complete: boolean) => Promise<void>;
2026-04-05 22:44:45 -05:00
// ---------- Service registry ----------
/**
* Flow-scoped request/response services.
* These are resolved within a specific flow's interface definitions.
* Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response
*/
const FLOW_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
["agent", { request: "agent-request", response: "agent-response" }],
["text-completion", { request: "text-completion-request", response: "text-completion-response" }],
["prompt", { request: "prompt-request", response: "prompt-response" }],
["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }],
["document-rag", { request: "document-rag-request", response: "document-rag-response" }],
["embeddings", { request: "embeddings-request", response: "embeddings-response" }],
["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }],
["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }],
["triples", { request: "triples-request", response: "triples-response" }],
["mcp-tool", { request: "mcp-tool-request", response: "mcp-tool-response" }],
2026-04-05 22:44:45 -05:00
]);
/**
* Global services (not flow-scoped).
* These always use fixed topics regardless of which flow is active.
*/
const GLOBAL_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
["config", { request: "config-request", response: "config-response" }],
["flow", { request: "flow-request", response: "flow-response" }],
["librarian", { request: "librarian-request", response: "librarian-response" }],
["knowledge", { request: "knowledge-request", response: "knowledge-response" }],
["collection-management", { request: "collection-management-request", response: "collection-management-response" }],
]);
/**
* Services that support streaming responses (multiple messages per request).
* The completion flag is determined by checking for end-of-stream markers.
*/
const STREAMING_SERVICES = new Set([
"agent",
"text-completion",
"graph-rag",
"document-rag",
"triples",
"knowledge",
"librarian",
]);
function topicName(name: string): string {
return `tg.flow.${name}`;
}
// ---------- Manager ----------
2026-06-01 20:26:47 -05:00
export interface DispatcherManager {
readonly start: () => Promise<void>;
readonly stop: () => Promise<void>;
readonly dispatchGlobalService: (
kind: string,
request: Record<string, unknown>,
) => Promise<unknown>;
readonly dispatchGlobalServiceStreaming: (
kind: string,
request: Record<string, unknown>,
responder: Responder,
) => Promise<void>;
readonly dispatchFlowService: (
flow: string,
kind: string,
request: Record<string, unknown>,
) => Promise<unknown>;
readonly dispatchFlowServiceStreaming: (
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
) => Promise<void>;
readonly publishToTopic: (
topic: string,
message: unknown,
id?: string,
) => Promise<void>;
}
export const dispatcherManagerFlowServiceNames = (): readonly string[] => [
...FLOW_SERVICES.keys(),
];
export const dispatcherManagerGlobalServiceNames = (): readonly string[] => [
...GLOBAL_SERVICES.keys(),
];
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
export const dispatcherManagerIsStreamingService = (kind: string): boolean =>
STREAMING_SERVICES.has(kind);
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
export function makeDispatcherManager(config: GatewayConfig): DispatcherManager {
const pubsub: PubSubBackend = makeNatsBackend(config.natsUrl ?? "nats://localhost:4222");
const requestors = new Map<string, Promise<RequestResponse<unknown, unknown>>>();
const start = async (): Promise<void> => {
2026-04-05 22:44:45 -05:00
// Requestors are created on demand when first accessed
2026-06-01 20:26:47 -05:00
};
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
const stop = async (): Promise<void> => {
for (const pending of requestors.values()) {
const rr = await pending;
2026-04-05 21:09:33 -05:00
await rr.stop();
}
2026-06-01 20:26:47 -05:00
await pubsub.close();
};
2026-04-05 21:09:33 -05:00
2026-04-05 22:44:45 -05:00
// ---------- Internal helpers ----------
2026-06-01 20:26:47 -05:00
const getRequestor = (
2026-04-05 21:09:33 -05:00
requestTopic: string,
responseTopic: string,
key: string,
2026-06-01 20:26:47 -05:00
): Promise<RequestResponse<unknown, unknown>> => {
let pending = requestors.get(key);
2026-05-12 08:06:58 -05:00
if (pending === undefined) {
pending = (async () => {
2026-06-01 20:26:47 -05:00
const rr = makeRequestResponse({
pubsub,
requestTopic,
responseTopic,
subscription: `gateway-${key}`,
});
await rr.start();
return rr;
})();
2026-06-01 20:26:47 -05:00
requestors.set(key, pending);
2026-04-05 21:09:33 -05:00
}
return pending;
2026-06-01 20:26:47 -05:00
};
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
const resolveGlobalTopics = (
2026-04-05 21:09:33 -05:00
kind: string,
2026-06-01 20:26:47 -05:00
): { requestTopic: string; responseTopic: string } => {
2026-04-05 22:44:45 -05:00
const entry = GLOBAL_SERVICES.get(kind);
2026-05-12 08:06:58 -05:00
if (entry !== undefined) {
2026-04-05 22:44:45 -05:00
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
};
}
// Fallback: derive from kind name directly
return {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
2026-06-01 20:26:47 -05:00
};
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
const resolveFlowTopics = (
2026-04-05 22:44:45 -05:00
kind: string,
2026-06-01 20:26:47 -05:00
): { requestTopic: string; responseTopic: string } => {
2026-04-05 22:44:45 -05:00
const entry = FLOW_SERVICES.get(kind);
2026-05-12 08:06:58 -05:00
if (entry !== undefined) {
2026-04-05 22:44:45 -05:00
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
};
}
// Fallback: derive from kind name directly
return {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
2026-06-01 20:26:47 -05:00
};
2026-04-05 22:44:45 -05:00
/**
* Determine whether a response is the final one in a streaming sequence.
* Checks for various end-of-stream markers used by different services.
*/
2026-06-01 20:26:47 -05:00
const isComplete = (response: unknown): boolean => {
2026-04-05 22:44:45 -05:00
if (typeof response !== "object" || response === null) return true;
const res = response as Record<string, unknown>;
return (
2026-05-12 08:06:58 -05:00
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 ||
2026-04-05 22:44:45 -05:00
// error responses are always final
2026-05-12 08:06:58 -05:00
(res.error !== undefined && res.error !== null)
2026-04-05 22:44:45 -05:00
);
2026-06-01 20:26:47 -05:00
};
2026-04-05 22:44:45 -05:00
// ---------- Global service dispatch ----------
2026-06-01 20:26:47 -05:00
const dispatchGlobalService = async (
2026-04-05 21:09:33 -05:00
kind: string,
request: Record<string, unknown>,
2026-06-01 20:26:47 -05:00
): Promise<unknown> => {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const rr = await getRequestor(requestTopic, responseTopic, `global:${kind}`);
2026-04-05 22:44:45 -05:00
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
2026-06-01 20:26:47 -05:00
};
2026-04-05 21:09:33 -05:00
2026-06-01 20:26:47 -05:00
const dispatchGlobalServiceStreaming = async (
2026-04-05 21:09:33 -05:00
kind: string,
request: Record<string, unknown>,
responder: Responder,
2026-06-01 20:26:47 -05:00
): Promise<void> => {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const rr = await getRequestor(requestTopic, responseTopic, `global:${kind}`);
2026-04-05 22:44:45 -05:00
const translated = translateRequest(kind, request);
2026-04-05 21:09:33 -05:00
2026-04-05 22:44:45 -05:00
await rr.request(translated, {
2026-04-05 21:09:33 -05:00
recipient: async (response) => {
2026-04-05 22:44:45 -05:00
const translatedRes = translateResponse(kind, response);
2026-06-01 20:26:47 -05:00
const complete = isComplete(translatedRes);
2026-04-05 22:44:45 -05:00
await responder(translatedRes, complete);
2026-04-05 21:09:33 -05:00
return complete;
},
});
2026-06-01 20:26:47 -05:00
};
2026-04-05 21:09:33 -05:00
2026-04-05 22:44:45 -05:00
// ---------- Flow-scoped service dispatch ----------
2026-06-01 20:26:47 -05:00
const dispatchFlowService = async (
2026-04-05 22:44:45 -05:00
flow: string,
kind: string,
request: Record<string, unknown>,
2026-06-01 20:26:47 -05:00
): Promise<unknown> => {
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
const rr = await getRequestor(
2026-04-05 22:44:45 -05:00
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
);
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
2026-06-01 20:26:47 -05:00
};
2026-04-05 22:44:45 -05:00
2026-06-01 20:26:47 -05:00
const dispatchFlowServiceStreaming = async (
2026-04-05 21:09:33 -05:00
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
2026-06-01 20:26:47 -05:00
): Promise<void> => {
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
const rr = await getRequestor(
2026-04-05 22:44:45 -05:00
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
);
const translated = translateRequest(kind, request);
2026-04-05 21:09:33 -05:00
2026-04-05 22:44:45 -05:00
await rr.request(translated, {
2026-04-05 21:09:33 -05:00
recipient: async (response) => {
2026-04-05 22:44:45 -05:00
const translatedRes = translateResponse(kind, response);
2026-06-01 20:26:47 -05:00
const complete = isComplete(translatedRes);
2026-04-05 22:44:45 -05:00
await responder(translatedRes, complete);
2026-04-05 21:09:33 -05:00
return complete;
},
});
2026-06-01 20:26:47 -05:00
};
2026-04-05 22:44:45 -05:00
// ---------- Fire-and-forget publish ----------
/**
* Publish a single message to an arbitrary topic (no request/response).
* Used for injecting documents into the processing pipeline.
*/
2026-06-01 20:26:47 -05:00
const publishToTopic = async (topic: string, message: unknown, id?: string): Promise<void> => {
const producer = await pubsub.createProducer<unknown>({ topic });
const messageId = id ?? `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await producer.send(message, { id: messageId });
await producer.close();
2026-06-01 20:26:47 -05:00
};
return {
start,
stop,
dispatchGlobalService,
dispatchGlobalServiceStreaming,
dispatchFlowService,
dispatchFlowServiceStreaming,
publishToTopic,
};
2026-04-05 21:09:33 -05:00
}