Advance TS port Effect workbench

This commit is contained in:
elpresidank 2026-06-01 16:22:25 -05:00
parent 92dae8c374
commit 3515106670
116 changed files with 12286 additions and 9584 deletions

View file

@ -0,0 +1,192 @@
import { Context, Data, Effect, Exit, Layer, Scope, Stream } from "effect";
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
import * as RpcClient from "effect/unstable/rpc/RpcClient";
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import * as Socket from "effect/unstable/socket/Socket";
import { DispatchPayload, DispatchError, TrustGraphRpcs, type DispatchStreamChunk } from "../rpc/contract.js";
type TrustGraphRpcClient = RpcClient.RpcClient<
RpcGroup.Rpcs<typeof TrustGraphRpcs>,
RpcClientError
>;
class TrustGraphRpcClientService extends Context.Service<
TrustGraphRpcClientService,
TrustGraphRpcClient
>()("@trustgraph/client/socket/effect-rpc-client/TrustGraphRpcClientService") {}
export type RpcConnectionStatus = "connecting" | "connected" | "failed" | "closed";
export interface RpcConnectionState {
status: RpcConnectionStatus;
lastError?: string;
}
export interface DispatchInput {
scope: "global" | "flow";
service: string;
flow?: string;
request: Record<string, unknown>;
}
export class EffectRpcClient {
private readonly url: string;
private readonly onConnect: (() => void) | undefined;
private readonly onDisconnect: (() => void) | undefined;
private readonly scopePromise: Promise<Scope.Scope>;
private readonly clientPromise: Promise<TrustGraphRpcClient>;
private readonly listeners = new Set<(state: RpcConnectionState) => void>();
private state: RpcConnectionState = { status: "connecting" };
private closed = false;
constructor(
url: string,
onConnect?: () => void,
onDisconnect?: () => void,
) {
this.url = url;
this.onConnect = onConnect;
this.onDisconnect = onDisconnect;
this.scopePromise = Effect.runPromise(Scope.make());
this.clientPromise = this.scopePromise.then((scope) =>
Effect.runPromise(this.makeClient().pipe(Scope.provide(scope))),
);
this.clientPromise.catch((cause) => {
this.setState({
status: "failed",
lastError: errorMessage(cause),
});
});
}
subscribe(listener: (state: RpcConnectionState) => void): () => void {
this.listeners.add(listener);
listener(this.state);
return () => {
this.listeners.delete(listener);
};
}
async dispatch(input: DispatchInput): Promise<unknown> {
const client = await this.clientPromise;
return await Effect.runPromise(client.Dispatch(new DispatchPayload(input)));
}
async dispatchStream(
input: DispatchInput,
receiver: (chunk: DispatchStreamChunk) => boolean,
): Promise<DispatchStreamChunk | undefined> {
const client = await this.clientPromise;
let last: DispatchStreamChunk | undefined;
await Effect.runPromise(
client.DispatchStream(new DispatchPayload(input)).pipe(
Stream.runForEach((chunk) =>
Effect.suspend(() => {
last = chunk;
if (receiver(chunk)) return Effect.fail(new StopStreaming());
return Effect.void;
}),
),
Effect.catchIf(
(cause): cause is StopStreaming => cause instanceof StopStreaming,
() => Effect.void,
),
),
);
return last;
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.setState({ status: "closed" });
const scope = await this.scopePromise;
await Effect.runPromise(Scope.close(scope, Exit.void));
}
private makeClient(): Effect.Effect<TrustGraphRpcClient, never, Scope.Scope> {
const socketLayer = Layer.effect(
Socket.Socket,
Socket.makeWebSocket(this.url, {
closeCodeIsError: (code) => code !== 1000,
openTimeout: "10 seconds",
}),
).pipe(Layer.provide(webSocketConstructorLayer));
const hooksLayer = Layer.succeed(
RpcClient.ConnectionHooks,
RpcClient.ConnectionHooks.of({
onConnect: Effect.sync(() => {
this.setState({ status: "connected" });
this.onConnect?.();
}),
onDisconnect: Effect.sync(() => {
if (!this.closed) {
this.setState({
status: "connecting",
lastError: "Disconnected from gateway",
});
}
this.onDisconnect?.();
}),
}),
);
const protocolLayer = RpcClient.layerProtocolSocket({
retryTransientErrors: true,
}).pipe(
Layer.provide(socketLayer),
Layer.provide(RpcSerialization.layerNdjson),
Layer.provide(hooksLayer),
);
const clientLayer = Layer.effect(
TrustGraphRpcClientService,
RpcClient.make(TrustGraphRpcs),
).pipe(Layer.provide(protocolLayer));
return Effect.map(
Layer.build(clientLayer),
(context) => Context.get(context, TrustGraphRpcClientService),
);
}
private setState(state: RpcConnectionState): void {
this.state = state;
for (const listener of this.listeners) {
listener(state);
}
}
}
class StopStreaming extends Data.TaggedError("StopStreaming")<{}> {}
const webSocketConstructorLayer: Layer.Layer<Socket.WebSocketConstructor> = Layer.effect(
Socket.WebSocketConstructor,
Effect.promise(async () => {
if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) {
return (url, protocols) => new globalThis.WebSocket(url, protocols);
}
try {
const mod = await import("ws");
const WS = mod.WebSocket;
return (url, protocols) => new WS(url, protocols) as unknown as globalThis.WebSocket;
} catch (cause) {
throw new DispatchError({
message: `WebSocket is not available: ${errorMessage(cause)}`,
});
}
}),
);
function errorMessage(cause: unknown): string {
if (cause instanceof Error) return cause.message;
if (typeof cause === "string") return cause;
if (cause !== null && typeof cause === "object" && "message" in cause) {
const message = (cause as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return String(cause);
}

View file

@ -1,179 +0,0 @@
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
// (2 seconds)
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 | null | undefined;
inflight: {
[key: string]: {
onReceived: (resp: object) => void;
retryNow: () => void;
error: (err: object | string) => void;
};
};
reopen: () => void;
getNextId?: () => string;
user?: string;
}
export class ServiceCallMulti {
constructor(
mid: string,
msg: RequestMessage,
success: (resp: unknown) => void,
error: (err: object | string) => void,
timeout: number,
retries: number,
socket: Socket,
receiver: (resp: unknown) => boolean,
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false;
this.receiver = receiver;
}
mid: string;
msg: RequestMessage;
success: (resp: unknown) => void;
error: (err: object | string) => void;
receiver: (resp: unknown) => boolean;
timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
timeout: number;
retries: number;
socket: Socket;
complete: boolean;
start() {
this.socket.inflight[this.mid] = this;
this.attempt();
}
onReceived(resp: object) {
if (this.complete == true)
console.log(this.mid, "should not happen, request is already complete");
const fin = this.receiver(resp);
if (fin) {
this.complete = true;
// console.log("Received for", this.mid);
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
delete this.socket.inflight[this.mid];
this.success(resp);
}
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
onTimeout() {
if (this.complete == true)
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
console.log("Request", this.mid, "timed out");
clearTimeout(this.timeoutId);
this.attempt();
}
attempt() {
// console.log("attempt:", this.mid);
if (this.complete == true)
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
this.retries--;
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
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);
return;
} catch (e) {
console.log("Error:", e);
console.log("Message send failure, retry...");
// Calculate backoff delay with jitter
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay);
console.log("Reopen...");
// Attempt to reopen the WebSocket connection
this.socket.reopen();
}
} else {
// No WebSocket connection available or not ready
// Check if socket is connecting
if (
this.socket.ws !== null &&
this.socket.ws !== undefined &&
this.socket.ws.readyState === WS_CONNECTING
) {
// Wait a bit longer for connection to establish
setTimeout(this.attempt.bind(this), 500);
} else {
// Socket is closed or closing, trigger reopen
console.log("Socket not ready, reopening...");
this.socket.reopen();
// Calculate backoff delay
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000,
);
setTimeout(this.attempt.bind(this), backoffDelay);
}
}
}
}

View file

@ -1,252 +0,0 @@
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
// (2 seconds)
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 | null | undefined;
inflight: {
[key: string]: {
onReceived: (resp: object) => void;
retryNow: () => void;
error: (err: object | string) => void;
};
};
reopen: () => void;
getNextId?: () => string;
user?: string;
}
/**
* ServiceCall represents a single request/response cycle over a WebSocket
* connection with built-in retry logic, timeout handling, and completion
* tracking.
*
* This class manages the lifecycle of a service call including:
* - Sending the initial request
* - Handling timeouts and retries
* - Managing completion state
* - Cleaning up resources
*/
export class ServiceCall {
constructor(
mid: string, // Message ID - unique identifier for this request
msg: RequestMessage, // The actual message/request to send
success: (resp: unknown) => void, // Callback function called on
// successful response
error: (err: object | string) => void, // Callback function called on error/failure
timeout: number, // Timeout duration in milliseconds
retries: number, // Number of retry attempts allowed
socket: Socket, // WebSocket instance to send the message through
) {
this.mid = mid;
this.msg = msg;
this.success = success;
this.error = error;
this.timeout = timeout;
this.retries = retries;
this.socket = socket;
this.complete = false; // Track if this request has completed
}
// Properties
mid: string; // Message identifier
msg: RequestMessage; // The request message
success: (resp: unknown) => void; // Success callback
error: (err: object | string) => void; // Error callback
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
complete: boolean; // Flag indicating if request is complete
/**
* Initiates the service call by registering it with the socket's inflight
* requests and making the first attempt to send the message
*/
start() {
// Register this request as "in-flight" so responses can be matched to it
this.socket.inflight[this.mid] = this;
// Make the first attempt to send the message
this.attempt();
}
/**
* Called when a response is received for this request
* Handles cleanup and calls the success or error callback based on response
*
* @param resp - The response object received from the server
*/
onReceived(resp: object) {
// Guard: ignore duplicate responses after completion
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;
// Clean up timeout timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Remove from inflight requests tracker
delete this.socket.inflight[this.mid];
// Check if the response contains an error (error can be directly in resp or nested under response)
let errorToHandle: unknown = null;
// Check for direct error in response
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 !== null && typeof resp === "object" && "response" in resp) {
const response = (resp as Record<string, unknown>).response;
if (response !== null && typeof response === "object" && "error" in response) {
errorToHandle = (response as Record<string, unknown>).error;
}
}
if (errorToHandle !== null && errorToHandle !== undefined) {
// Response contains an error - call error callback
const errorObj = errorToHandle as Record<string, unknown>;
const errorMessage =
(typeof errorObj.message === "string" ? errorObj.message : null) ||
(typeof errorObj.type === "string" ? errorObj.type : null) ||
"Unknown error";
console.log(
"ServiceCall: API error detected in response:",
errorMessage,
"Full error:",
errorToHandle,
);
this.error(new Error(errorMessage));
return;
}
// Extract the response field from the message object
// The resp parameter is the full message: {id, response, complete}
// We need to pass just the response field to the success callback
const responseData = (resp as { response?: unknown }).response;
this.success(responseData);
}
/**
* Called when socket connects - immediately retry if we were waiting
*/
retryNow() {
if (this.complete) return;
// Clear any pending backoff timer
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
// Restore retry count since we didn't actually fail
this.retries++;
// Attempt immediately
this.attempt();
}
/**
* Called when the request times out
* Triggers another attempt if retries are available
*/
onTimeout() {
// Guard: ignore timeout after completion
if (this.complete) {
console.log(
this.mid,
"timeout should not happen, request is already complete",
);
return;
}
console.log("Request", this.mid, "timed out");
// Clear the current timeout
clearTimeout(this.timeoutId);
// Try again (this will check retry count)
this.attempt();
}
/**
* Calculates exponential backoff delay with jitter
* @returns backoff delay in milliseconds
*/
calculateBackoff() {
return Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) +
Math.random() * 1000,
30000, // Max 30 seconds
);
}
/**
* Core retry logic - attempts to send the message over the WebSocket
* Handles retries and waits for BaseApi to handle reconnection
*/
attempt() {
// Guard: don't retry completed requests
if (this.complete) {
console.log(
this.mid,
"attempt should not be called, request is already complete",
);
return;
}
// Decrement retry counter
this.retries--;
// Check if we've exhausted all retries
if (this.retries < 0) {
console.log("Request", this.mid, "ran out of retries");
// Clean up and call error callback
clearTimeout(this.timeoutId);
delete this.socket.inflight[this.mid];
this.error("Ran out of retries");
return; // Exit early - no more attempts
}
// Check if WebSocket connection is available and ready
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));
// Set up timeout for this attempt
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
return; // Success - message sent, waiting for response or timeout
} catch (e) {
// Handle send failure - wait for BaseApi to handle reconnection
console.log("Error:", e);
console.log(
"Message send failure, waiting for socket reconnection...",
);
// Schedule retry with backoff - let BaseApi handle the reconnection
this.timeoutId = setTimeout(
this.attempt.bind(this),
this.calculateBackoff(),
);
}
} else {
// No WebSocket connection available or not ready
// Let BaseApi handle reconnection, just wait and retry
console.log("Request", this.mid, "waiting for socket reconnection...");
// Use consistent backoff for all waiting scenarios
setTimeout(this.attempt.bind(this), this.calculateBackoff());
}
}
}

View file

@ -1,19 +1,7 @@
// Import core types and classes for the TrustGraph API
import type { Term, Triple } from "../models/Triple.js";
import { ServiceCallMulti } from "./service-call-multi.js";
import { ServiceCall } from "./service-call.js";
import {
getWebSocketConstructor,
getDefaultSocketUrl,
getRandomValues,
WS_CONNECTING,
WS_OPEN,
WS_CLOSED,
type IsomorphicWebSocket,
type WsMessageEvent,
type WsCloseEvent,
type WsEvent,
} from "./websocket-adapter.js";
import { EffectRpcClient, type DispatchInput, type RpcConnectionState } from "./effect-rpc-client.js";
import { getDefaultSocketUrl, getRandomValues } from "./websocket-adapter.js";
// Import all message types for different services
import type {
@ -51,7 +39,6 @@ import type {
PromptRequest,
PromptResponse,
// ProcessingMetadata,
RequestMessage,
ResponseError,
StructuredQueryRequest,
StructuredQueryResponse,
@ -107,8 +94,6 @@ export interface ExplainEvent {
}
// Configuration constants
const SOCKET_RECONNECTION_TIMEOUT = 2000; // 2 seconds between reconnection
// attempts
const SOCKET_URL = getDefaultSocketUrl(); // WebSocket endpoint path (isomorphic)
function isNonEmptyString(value: string | undefined): value is string {
@ -165,6 +150,38 @@ function throwIfResponseError(error: ResponseError | undefined): void {
}
}
interface ConfigValueEntry {
workspace?: string;
type?: string;
key: string;
value: unknown;
}
function asConfigValues(response: unknown): ConfigValueEntry[] {
if (response === null || typeof response !== "object") return [];
const values = (response as { values?: unknown }).values;
if (!Array.isArray(values)) return [];
return values.flatMap((value) => {
if (value === null || typeof value !== "object") return [];
const item = value as Record<string, unknown>;
const key = item.key;
if (typeof key !== "string") return [];
const entry: ConfigValueEntry = { key, value: item.value };
if (typeof item.workspace === "string") entry.workspace = item.workspace;
if (typeof item.type === "string") entry.type = item.type;
return [entry];
});
}
function parseConfigJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
/**
* Socket interface defining all available operations for the TrustGraph API
* This provides a unified interface for various AI/ML and knowledge graph
@ -297,22 +314,17 @@ export interface ConnectionState {
}
export class BaseApi {
ws: IsomorphicWebSocket | undefined = undefined; // WebSocket connection instance
tag: string; // Unique client identifier
id: number; // Counter for generating unique message IDs
token: string | undefined; // Optional authentication token
user: string; // User identifier for API requests
socketUrl: string; // WebSocket URL
inflight: { [key: string]: ServiceCall | ServiceCallMulti } = {}; // Track active requests by
// message ID
reconnectAttempts: number = 0; // Track reconnection attempts
maxReconnectAttempts: number = 10; // Maximum reconnection attempts
reconnectTimer: number | undefined = undefined; // Timer for reconnection attempts
reconnectionState: "idle" | "reconnecting" | "failed" = "idle"; // Connection state
private readonly rpc: EffectRpcClient;
// Connection state tracking for UI
private connectionStateListeners: ((state: ConnectionState) => void)[] = [];
private lastError: string | undefined = undefined;
private rpcState: RpcConnectionState = { status: "connecting" };
constructor(user: string, token?: string, socketUrl?: string) {
this.tag = makeid(16); // Generate unique client tag
@ -320,6 +332,12 @@ export class BaseApi {
this.token = token; // Store authentication token
this.user = user; // Store user identifier
this.socketUrl = withDefault(socketUrl, SOCKET_URL); // Use provided URL or default
this.rpc = new EffectRpcClient(this.socketUrlWithToken());
this.rpc.subscribe((state) => {
this.rpcState = state;
this.lastError = state.lastError;
this.notifyStateChange();
});
console.log(
"SOCKET: opening socket...",
@ -327,8 +345,6 @@ export class BaseApi {
"user:",
user,
);
this.openSocket(); // Establish WebSocket connection
console.log("SOCKET: socket opened");
}
/**
@ -353,25 +369,7 @@ export class BaseApi {
*/
private getConnectionState(): ConnectionState {
const hasApiKey = isNonEmptyString(this.token);
// Determine status based on WebSocket state and reconnection state
let status: ConnectionState["status"];
if (this.ws === undefined || this.ws.readyState === WS_CLOSED) {
if (this.reconnectionState === "failed") {
status = "failed";
} else if (this.reconnectionState === "reconnecting") {
status = "reconnecting";
} else {
status = "connecting";
}
} else if (this.ws.readyState === WS_CONNECTING) {
status = "connecting";
} else if (this.ws.readyState === WS_OPEN) {
status = hasApiKey ? "authenticated" : "unauthenticated";
} else {
status = "connecting";
}
const status = this.connectionStatusFromRpc(hasApiKey);
const state: ConnectionState = {
status,
@ -381,12 +379,6 @@ export class BaseApi {
state.lastError = this.lastError;
}
// Add reconnection details if applicable
if (status === "reconnecting") {
state.reconnectAttempt = this.reconnectAttempts;
state.maxAttempts = this.maxReconnectAttempts;
}
return state;
}
@ -404,208 +396,13 @@ export class BaseApi {
});
}
/**
* Establishes WebSocket connection and sets up event handlers
*/
openSocket() {
// Don't create multiple connections
if (
this.ws !== undefined &&
(this.ws.readyState === WS_CONNECTING ||
this.ws.readyState === WS_OPEN)
) {
return;
}
// Clean up old socket if exists
if (this.ws !== undefined) {
this.ws.removeEventListener("message", this.onMessage);
this.ws.removeEventListener("close", this.onClose);
this.ws.removeEventListener("open", this.onOpen);
this.ws.removeEventListener("error", this.onError);
this.ws = undefined;
}
try {
// Build WebSocket URL with optional token parameter
const wsUrl = isNonEmptyString(this.token)
? `${this.socketUrl}?token=${this.token}`
: this.socketUrl;
console.log(
"SOCKET: connecting to",
wsUrl.replace(/token=[^&]*/, "token=***"),
);
const WS = getWebSocketConstructor();
this.ws = new WS(wsUrl);
} catch (e) {
console.error("[socket creation error]", e);
this.scheduleReconnect();
return;
}
// Bind event handlers to maintain proper 'this' context
this.onMessage = this.onMessage.bind(this);
this.onClose = this.onClose.bind(this);
this.onOpen = this.onOpen.bind(this);
this.onError = this.onError.bind(this);
// Attach event listeners
this.ws.addEventListener("message", this.onMessage);
this.ws.addEventListener("close", this.onClose);
this.ws.addEventListener("open", this.onOpen);
this.ws.addEventListener("error", this.onError);
}
// Handle incoming messages from server
onMessage(message: WsMessageEvent) {
if (message.data === undefined || message.data === null || message.data === "") return;
try {
const obj: unknown = JSON.parse(String(message.data));
// Skip messages without ID (can't route them)
if (obj === null || typeof obj !== "object" || !("id" in obj)) return;
const id = (obj as { id?: unknown }).id;
if (typeof id !== "string" || id.length === 0) return;
// Route response to the corresponding inflight request
const call = this.inflight[id];
if (call !== undefined) {
// Pass the whole message object so receiver can access 'complete' flag
call.onReceived(obj);
}
} catch (e) {
console.error("[socket message parse error]", e);
}
}
// Handle connection closure - automatically attempt reconnection
onClose(event: WsCloseEvent) {
console.log("[socket close]", event.code, event.reason);
this.lastError = `Connection closed: ${event.reason.length > 0 ? event.reason : "Unknown reason"}`;
this.ws = undefined;
this.notifyStateChange();
this.scheduleReconnect();
}
// Handle successful connection
onOpen(_event: WsEvent) {
console.log("[socket open]");
this.reconnectAttempts = 0; // Reset reconnection attempts on success
this.reconnectionState = "idle"; // Reset connection state
this.lastError = undefined; // Clear any previous errors
// Clear any pending reconnect timer
if (this.reconnectTimer !== undefined) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
// Notify UI of successful connection
this.notifyStateChange();
// Immediately retry any pending requests that were waiting for connection
for (const mid in this.inflight) {
this.inflight[mid].retryNow();
}
}
// Handle socket errors
onError(event: WsEvent) {
console.error("[socket error]", event);
this.lastError = "Connection error occurred";
this.notifyStateChange();
}
/**
* Schedules a reconnection attempt with exponential backoff
*/
scheduleReconnect() {
// Prevent concurrent reconnection attempts
if (this.reconnectionState === "reconnecting") {
console.log("[socket] Reconnection already in progress, skipping");
return;
}
// Don't schedule if already scheduled
if (this.reconnectTimer !== undefined) return;
this.reconnectionState = "reconnecting";
this.reconnectAttempts++;
this.notifyStateChange(); // Notify UI of reconnection attempt
if (this.reconnectAttempts > this.maxReconnectAttempts) {
console.error("[socket] Max reconnection attempts reached");
this.reconnectionState = "failed";
this.lastError = "Max reconnection attempts exceeded";
this.notifyStateChange();
// Notify all pending requests of the failure
for (const mid in this.inflight) {
this.inflight[mid].error(new Error("WebSocket connection failed"));
}
return;
}
// Calculate exponential backoff with jitter
const backoffDelay = Math.min(
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, this.reconnectAttempts - 1) +
Math.random() * 1000,
30000, // Max 30 seconds
);
console.log(
`[socket] Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
this.reopen();
}, backoffDelay) as unknown as number;
}
/**
* Reopens the WebSocket connection (used after connection failures)
*/
reopen() {
console.log("[socket reopen]");
// Check if we're already connected or connecting
if (
this.ws !== undefined &&
(this.ws.readyState === WS_OPEN ||
this.ws.readyState === WS_CONNECTING)
) {
return;
}
this.openSocket();
}
/**
* Closes the WebSocket connection and cleans up
*/
close() {
// Clear reconnection timer
if (this.reconnectTimer !== undefined) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
// Clean up WebSocket
if (this.ws !== undefined) {
// Remove event listeners to prevent memory leaks
this.ws.removeEventListener("message", this.onMessage);
this.ws.removeEventListener("close", this.onClose);
this.ws.removeEventListener("open", this.onOpen);
this.ws.removeEventListener("error", this.onError);
this.ws.close();
this.ws = undefined;
}
// Clear any remaining inflight requests
for (const mid in this.inflight) {
this.inflight[mid].error(new Error("Socket closed"));
}
this.inflight = {};
this.rpc.close().catch((err) => {
console.error("[socket close error]", err);
});
}
/**
@ -630,42 +427,11 @@ export class BaseApi {
makeRequest<RequestType extends object, ResponseType>(
service: string,
request: RequestType,
timeout?: number,
retries?: number,
_timeout?: number,
_retries?: number,
flow?: string,
) {
const mid = this.getNextId();
// Set default values
if (timeout === undefined) timeout = 10000;
if (retries === undefined) retries = 3;
// Construct the request message
const msg: RequestMessage = {
id: mid,
service: service,
request: request,
};
// Add flow identifier if provided
if (isNonEmptyString(flow)) msg.flow = flow;
// Return a Promise that will be resolved/rejected by the ServiceCall
return new Promise<ResponseType>((resolve, reject) => {
const call = new ServiceCall(
mid,
msg,
resolve as (resp: unknown) => void,
reject as (err: object | string) => void,
timeout,
retries,
this,
);
call.start();
// Commented out debug logging: console.log("-->", msg);
}).then((obj) => {
// Commented out success logging: console.log("Success for", mid);
return this.rpc.dispatch(this.dispatchInput(service, request, flow)).then((obj) => {
return obj as ResponseType;
});
}
@ -678,38 +444,12 @@ export class BaseApi {
service: string,
request: RequestType,
receiver: (resp: unknown) => boolean, // Callback to handle each response chunk
timeout?: number,
retries?: number,
_timeout?: number,
_retries?: number,
flow?: string,
) {
const mid = this.getNextId();
// Set defaults
if (timeout === undefined) timeout = 10000;
if (retries === undefined) retries = 3;
// Construct request message
const msg: RequestMessage = {
id: mid,
service: service,
request: request,
};
if (isNonEmptyString(flow)) msg.flow = flow;
return new Promise<ResponseType>((resolve, reject) => {
const call = new ServiceCallMulti(
mid,
msg,
resolve as (resp: unknown) => void,
reject as (err: object | string) => void,
timeout,
retries,
this,
receiver,
);
call.start();
return this.rpc.dispatchStream(this.dispatchInput(service, request, flow), (chunk) => {
return receiver({ response: chunk.response, complete: chunk.complete });
}).then((obj) => {
return obj as ResponseType;
});
@ -737,6 +477,45 @@ export class BaseApi {
);
}
private connectionStatusFromRpc(hasApiKey: boolean): ConnectionState["status"] {
switch (this.rpcState.status) {
case "connected":
return hasApiKey ? "authenticated" : "unauthenticated";
case "failed":
return "failed";
case "closed":
return "failed";
case "connecting":
return this.lastError === undefined ? "connecting" : "reconnecting";
}
}
private dispatchInput<RequestType extends object>(
service: string,
request: RequestType,
flow?: string,
): DispatchInput {
if (isNonEmptyString(flow)) {
return {
scope: "flow",
service,
flow,
request: request as Record<string, unknown>,
};
}
return {
scope: "global",
service,
request: request as Record<string, unknown>,
};
}
private socketUrlWithToken(): string {
if (!isNonEmptyString(this.token)) return this.socketUrl;
const separator = this.socketUrl.includes("?") ? "&" : "?";
return `${this.socketUrl}${separator}token=${encodeURIComponent(this.token)}`;
}
// Factory methods for creating specialized API instances
librarian() {
return new LibrarianApi(this);
@ -787,7 +566,7 @@ export class LibrarianApi {
},
60000, // 60 second timeout for potentially large lists
)
.then((r) => r["document-metadatas"] ?? []);
.then((r) => r["document-metadatas"] ?? r.documents ?? []);
}
/**
@ -803,7 +582,7 @@ export class LibrarianApi {
},
60000,
)
.then((r) => r["processing-metadata"] ?? []);
.then((r) => r["processing-metadatas"] ?? r.processing ?? r["processing-metadata"] ?? []);
}
/**
@ -818,6 +597,7 @@ export class LibrarianApi {
{
operation: "get-document-metadata",
"document-id": documentId,
documentId,
user: this.api.user,
},
30000,
@ -851,6 +631,8 @@ export class LibrarianApi {
comments,
user: this.api.user,
tags,
"document-type": "source",
documentType: "source",
};
if (id !== undefined) {
documentMetadata.id = id;
@ -863,6 +645,7 @@ export class LibrarianApi {
"librarian",
{
operation: "add-document",
"document-metadata": documentMetadata,
documentMetadata,
content: document,
},
@ -879,6 +662,7 @@ export class LibrarianApi {
{
operation: "remove-document",
"document-id": id,
documentId: id,
user: this.api.user,
collection: withDefault(collection, "default"),
},
@ -908,6 +692,7 @@ export class LibrarianApi {
"processing-metadata": {
id: id,
"document-id": doc_id,
documentId: doc_id,
time: Math.floor(Date.now() / 1000),
flow: flow,
user: this.api.user,
@ -935,6 +720,7 @@ export class LibrarianApi {
): Promise<BeginUploadResponse> {
const request: BeginUploadRequest = {
operation: "begin-upload",
"document-metadata": metadata,
documentMetadata: metadata,
"total-size": totalSize,
};
@ -1200,32 +986,17 @@ export class FlowsApi {
}
/**
* Updates configuration values. Items are grouped by `type` (the namespace);
* one put request is issued per distinct type.
* Updates configuration values using the Python-compatible values array.
*/
putConfig(items: { type: string; key: string; value: string }[]) {
const byType = new Map<string, Record<string, unknown>>();
for (const item of items) {
let group = byType.get(item.type);
if (group === undefined) {
group = {};
byType.set(item.type, group);
}
group[item.key] = item.value;
}
return Promise.all(
[...byType.entries()].map(([type, values]) =>
this.api.makeRequest<ConfigRequest, ConfigResponse>(
"config",
{
operation: "put",
keys: [type],
values,
},
60000,
),
),
).then((responses) => responses[responses.length - 1]);
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
"config",
{
operation: "put",
values: items,
},
60000,
);
}
/**
@ -1233,13 +1004,13 @@ export class FlowsApi {
*/
deleteConfig(target: { type: string; key: string }) {
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
"config",
{
operation: "delete",
keys: [target.type, target.key],
},
30000,
);
"config",
{
operation: "delete",
keys: [target],
},
30000,
);
}
// Prompt management - specialized config operations for AI prompts
@ -2154,32 +1925,17 @@ export class ConfigApi {
}
/**
* Updates configuration values. Items are grouped by `type` (the namespace);
* one put request is issued per distinct type.
* Updates configuration values using the Python-compatible values array.
*/
putConfig(items: { type: string; key: string; value: string }[]) {
const byType = new Map<string, Record<string, unknown>>();
for (const item of items) {
let group = byType.get(item.type);
if (group === undefined) {
group = {};
byType.set(item.type, group);
}
group[item.key] = item.value;
}
return Promise.all(
[...byType.entries()].map(([type, values]) =>
this.api.makeRequest<ConfigRequest, ConfigResponse>(
"config",
{
operation: "put",
keys: [type],
values,
},
60000,
),
),
).then((responses) => responses[responses.length - 1]);
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
"config",
{
operation: "put",
values: items,
},
60000,
);
}
/**
@ -2187,13 +1943,13 @@ export class ConfigApi {
*/
deleteConfig(target: { type: string; key: string }) {
return this.api.makeRequest<ConfigRequest, ConfigResponse>(
"config",
{
operation: "delete",
keys: [target.type, target.key],
},
30000,
);
"config",
{
operation: "delete",
keys: [target],
},
30000,
);
}
// Specialized prompt management methods
@ -2267,7 +2023,7 @@ export class ConfigApi {
},
60000,
)
.then((r) => (r as RowsQueryResponse).values);
.then((r) => asConfigValues(r));
}
/**
@ -2285,12 +2041,10 @@ export class ConfigApi {
60000,
)
.then((r) => {
// Parse JSON values and restructure data
const response = r as RowsQueryResponse;
return (response.values ?? []).map((x: unknown) => {
const item = x as Record<string, string>;
return { key: item.key, value: JSON.parse(item.value) };
});
return asConfigValues(r).map((item) => ({
key: item.key,
value: parseConfigJson(item.value),
}));
})
.then((r) =>
// Transform to more usable format
@ -2334,6 +2088,19 @@ export class KnowledgeApi {
.then((r) => r.ids ?? []);
}
getDocumentEmbeddingCores() {
return this.api
.makeRequest<FlowRequest, FlowResponse>(
"knowledge",
{
operation: "list-de-cores",
user: this.api.user,
},
60000,
)
.then((r) => r.ids ?? []);
}
/**
* Deletes a knowledge graph core
*/
@ -2367,6 +2134,45 @@ export class KnowledgeApi {
);
}
unloadKgCore(id: string, flow: string) {
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
"knowledge",
{
operation: "unload-kg-core",
id,
flow,
user: this.api.user,
},
30000,
);
}
deleteDeCore(id: string) {
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
"knowledge",
{
operation: "delete-de-core",
id,
user: this.api.user,
},
30000,
);
}
loadDeCore(id: string, flow: string, collection?: string) {
return this.api.makeRequest<LibraryRequest, LibraryResponse>(
"knowledge",
{
operation: "load-de-core",
id,
flow,
user: this.api.user,
collection: withDefault(collection, "default"),
},
30000,
);
}
/**
* Retrieves a knowledge graph core with streaming data
* Uses multi-request pattern for large datasets
@ -2512,7 +2318,7 @@ export class CollectionManagementApi {
* This is the main entry point for using the TrustGraph API
* @param user - User identifier for API requests
* @param token - Optional authentication token for secure connections
* @param socketUrl - Optional WebSocket URL (defaults to /api/socket for browser, provide full URL for Node.js)
* @param socketUrl - Optional WebSocket URL (defaults to /api/v1/rpc for browser, provide full URL for Node.js)
*/
export const createTrustGraphSocket = (
user: string,

View file

@ -97,16 +97,16 @@ export function getWebSocketConstructor(): IsomorphicWebSocketConstructor {
/**
* Returns the default WebSocket URL for the current environment.
*
* - Browser: returns the relative path `"/api/socket"` (resolved by the
* - Browser: returns the relative path `"/api/v1/rpc"` (resolved by the
* browser against the current page origin).
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/socket"` since
* - Node.js: returns a full URL `"ws://localhost:8088/api/v1/rpc"` since
* relative URLs are not meaningful outside a browser.
*/
export function getDefaultSocketUrl(): string {
if (typeof window !== "undefined") {
return "/api/socket";
return "/api/v1/rpc";
}
return "ws://localhost:8088/api/v1/socket";
return "ws://localhost:8088/api/v1/rpc";
}
/**