mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
Advance TS port Effect workbench
This commit is contained in:
parent
92dae8c374
commit
3515106670
116 changed files with 12286 additions and 9584 deletions
|
|
@ -1,285 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ServiceCallMulti } from "../socket/service-call-multi";
|
||||
|
||||
// Mock WebSocket constants
|
||||
vi.stubGlobal("WebSocket", {
|
||||
OPEN: 1,
|
||||
CONNECTING: 0,
|
||||
CLOSING: 2,
|
||||
CLOSED: 3,
|
||||
});
|
||||
|
||||
// Mock Socket interface
|
||||
const mockSocket = {
|
||||
inflight: {} as Record<string, unknown>,
|
||||
ws: {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
},
|
||||
reopen: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock setTimeout and clearTimeout
|
||||
const mockSetTimeout = vi.fn();
|
||||
const mockClearTimeout = vi.fn();
|
||||
|
||||
vi.stubGlobal("setTimeout", mockSetTimeout);
|
||||
vi.stubGlobal("clearTimeout", mockClearTimeout);
|
||||
|
||||
describe("ServiceCallMulti", () => {
|
||||
let mockSuccess: ReturnType<typeof vi.fn>;
|
||||
let mockError: ReturnType<typeof vi.fn>;
|
||||
let mockReceiver: ReturnType<typeof vi.fn>;
|
||||
let serviceCallMulti: ServiceCallMulti;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSuccess = vi.fn();
|
||||
mockError = vi.fn();
|
||||
mockReceiver = vi.fn();
|
||||
mockSocket.inflight = {} as Record<string, unknown>;
|
||||
mockSocket.ws = {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
};
|
||||
mockSocket.reopen.mockClear();
|
||||
|
||||
serviceCallMulti = new ServiceCallMulti(
|
||||
"test-mid",
|
||||
{ id: "test-id", service: "test-service", request: { test: "data" } },
|
||||
mockSuccess,
|
||||
mockError,
|
||||
5000, // 5 second timeout
|
||||
3, // 3 retries
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockSocket as any,
|
||||
mockReceiver,
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize with correct properties", () => {
|
||||
expect(serviceCallMulti.mid).toBe("test-mid");
|
||||
expect(serviceCallMulti.timeout).toBe(5000);
|
||||
expect(serviceCallMulti.retries).toBe(3);
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(serviceCallMulti.socket).toBe(mockSocket);
|
||||
expect(serviceCallMulti.receiver).toBe(mockReceiver);
|
||||
});
|
||||
|
||||
it("should register itself in socket inflight when started", () => {
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
|
||||
});
|
||||
|
||||
it("should send message on successful attempt", () => {
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockSocket.ws.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
id: "test-id",
|
||||
service: "test-service",
|
||||
request: { test: "data" },
|
||||
}),
|
||||
);
|
||||
expect(mockSetTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle response when receiver returns true (completion)", () => {
|
||||
mockReceiver.mockReturnValue(true); // Signal completion
|
||||
const response = { result: "success" };
|
||||
|
||||
serviceCallMulti.start();
|
||||
serviceCallMulti.onReceived(response);
|
||||
|
||||
expect(mockReceiver).toHaveBeenCalledWith(response);
|
||||
expect(serviceCallMulti.complete).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith(response);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle response when receiver returns false (continue)", () => {
|
||||
mockReceiver.mockReturnValue(false); // Signal to continue
|
||||
const response = { partial: "data" };
|
||||
|
||||
serviceCallMulti.start();
|
||||
serviceCallMulti.onReceived(response);
|
||||
|
||||
expect(mockReceiver).toHaveBeenCalledWith(response);
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
expect(mockClearTimeout).not.toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti);
|
||||
});
|
||||
|
||||
it("should handle timeout and retry", () => {
|
||||
serviceCallMulti.start();
|
||||
|
||||
// Initial retries should be 3, but start() calls attempt() which decrements to 2
|
||||
expect(serviceCallMulti.retries).toBe(2);
|
||||
|
||||
// Simulate timeout
|
||||
serviceCallMulti.onTimeout();
|
||||
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(serviceCallMulti.retries).toBe(1); // Should decrement from 2 to 1
|
||||
});
|
||||
|
||||
it("should exhaust retries and call error callback", () => {
|
||||
// Set retries to 0 to force immediate failure
|
||||
serviceCallMulti.retries = 0;
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle WebSocket send failure", () => {
|
||||
mockSocket.ws.send.mockImplementation(() => {
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(mockSocket.reopen).toHaveBeenCalled();
|
||||
|
||||
// With exponential backoff, the delay should be calculated as:
|
||||
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
|
||||
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
|
||||
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
|
||||
// The delay should be between 4000 and 5000ms (capped at 30000)
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should handle missing WebSocket connection", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockSocket as any).ws = null;
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
// Should trigger reopen and schedule with exponential backoff
|
||||
expect(mockSocket.reopen).toHaveBeenCalled();
|
||||
|
||||
// Same calculation as above - base delay 4000ms + random up to 1000ms
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should not process response if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.complete = true;
|
||||
serviceCallMulti.onReceived({ result: "test" });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not timeout if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.complete = true;
|
||||
serviceCallMulti.onTimeout();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not attempt if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.complete = true;
|
||||
serviceCallMulti.attempt();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle streaming responses correctly", () => {
|
||||
mockReceiver
|
||||
.mockReturnValueOnce(false) // First response - continue
|
||||
.mockReturnValueOnce(false) // Second response - continue
|
||||
.mockReturnValueOnce(true); // Third response - complete
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
// First response
|
||||
serviceCallMulti.onReceived({ chunk: 1 });
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
|
||||
// Second response
|
||||
serviceCallMulti.onReceived({ chunk: 2 });
|
||||
expect(serviceCallMulti.complete).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
|
||||
// Third response (final)
|
||||
serviceCallMulti.onReceived({ chunk: 3, final: true });
|
||||
expect(serviceCallMulti.complete).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith({ chunk: 3, final: true });
|
||||
});
|
||||
|
||||
it("should handle receiver function errors gracefully", () => {
|
||||
mockReceiver.mockImplementation(() => {
|
||||
throw new Error("Receiver error");
|
||||
});
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
expect(() => {
|
||||
serviceCallMulti.onReceived({ test: "data" });
|
||||
}).toThrow("Receiver error");
|
||||
});
|
||||
|
||||
it("should handle multiple timeout scenarios", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
// After start, retries should be 2 (decremented from 3)
|
||||
expect(serviceCallMulti.retries).toBe(2);
|
||||
|
||||
// First timeout
|
||||
serviceCallMulti.onTimeout();
|
||||
expect(serviceCallMulti.retries).toBe(1);
|
||||
|
||||
// Second timeout
|
||||
serviceCallMulti.onTimeout();
|
||||
expect(serviceCallMulti.retries).toBe(0);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should clean up properly when receiver signals completion", () => {
|
||||
mockReceiver.mockReturnValue(true);
|
||||
|
||||
serviceCallMulti.start();
|
||||
|
||||
const response = { final: true };
|
||||
serviceCallMulti.onReceived(response);
|
||||
|
||||
expect(serviceCallMulti.complete).toBe(true);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
expect(mockSuccess).toHaveBeenCalledWith(response);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ServiceCall } from "../socket/service-call";
|
||||
|
||||
// Mock WebSocket constants
|
||||
vi.stubGlobal("WebSocket", {
|
||||
OPEN: 1,
|
||||
CONNECTING: 0,
|
||||
CLOSING: 2,
|
||||
CLOSED: 3,
|
||||
});
|
||||
|
||||
// Mock Socket interface
|
||||
const mockSocket = {
|
||||
inflight: {} as Record<string, unknown>,
|
||||
ws: {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
},
|
||||
reopen: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock setTimeout and clearTimeout
|
||||
const mockSetTimeout = vi.fn();
|
||||
const mockClearTimeout = vi.fn();
|
||||
|
||||
vi.stubGlobal("setTimeout", mockSetTimeout);
|
||||
vi.stubGlobal("clearTimeout", mockClearTimeout);
|
||||
|
||||
describe("ServiceCall", () => {
|
||||
let mockSuccess: ReturnType<typeof vi.fn>;
|
||||
let mockError: ReturnType<typeof vi.fn>;
|
||||
let serviceCall: ServiceCall;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSuccess = vi.fn();
|
||||
mockError = vi.fn();
|
||||
mockSocket.inflight = {} as Record<string, unknown>;
|
||||
mockSocket.ws = {
|
||||
send: vi.fn(),
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
};
|
||||
mockSocket.reopen.mockClear();
|
||||
|
||||
serviceCall = new ServiceCall(
|
||||
"test-mid",
|
||||
{ id: "test-id", service: "test-service", request: { test: "data" } },
|
||||
mockSuccess,
|
||||
mockError,
|
||||
5000, // 5 second timeout
|
||||
3, // 3 retries
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockSocket as any,
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize with correct properties", () => {
|
||||
expect(serviceCall.mid).toBe("test-mid");
|
||||
expect(serviceCall.timeout).toBe(5000);
|
||||
expect(serviceCall.retries).toBe(3);
|
||||
expect(serviceCall.complete).toBe(false);
|
||||
expect(serviceCall.socket).toBe(mockSocket);
|
||||
});
|
||||
|
||||
it("should register itself in socket inflight when started", () => {
|
||||
serviceCall.start();
|
||||
|
||||
expect(mockSocket.inflight["test-mid"]).toBe(serviceCall);
|
||||
});
|
||||
|
||||
it("should send message on successful attempt", () => {
|
||||
serviceCall.start();
|
||||
|
||||
expect(mockSocket.ws.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({
|
||||
id: "test-id",
|
||||
service: "test-service",
|
||||
request: { test: "data" },
|
||||
}),
|
||||
);
|
||||
expect(mockSetTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle successful response", () => {
|
||||
const responseData = { result: "success" };
|
||||
const message = { response: responseData };
|
||||
|
||||
serviceCall.start();
|
||||
serviceCall.onReceived(message);
|
||||
|
||||
expect(serviceCall.complete).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith(responseData);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle timeout and retry", () => {
|
||||
serviceCall.start();
|
||||
|
||||
// Initial retries should be 3, but start() calls attempt() which decrements to 2
|
||||
expect(serviceCall.retries).toBe(2);
|
||||
|
||||
// Simulate timeout
|
||||
serviceCall.onTimeout();
|
||||
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(serviceCall.retries).toBe(1); // Should decrement from 2 to 1
|
||||
});
|
||||
|
||||
it("should exhaust retries and call error callback", () => {
|
||||
// Set retries to 0 to force immediate failure
|
||||
serviceCall.retries = 0;
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle WebSocket send failure", () => {
|
||||
mockSocket.ws.send.mockImplementation(() => {
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
// Should NOT call reopen anymore - BaseApi handles reconnection
|
||||
expect(mockSocket.reopen).not.toHaveBeenCalled();
|
||||
|
||||
// With exponential backoff, the delay should be calculated as:
|
||||
// SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random
|
||||
// Since retries is decremented to 2 after start(), it's 3 - 2 = 1
|
||||
// So base delay is 2000 * 2^1 = 4000, plus random up to 1000
|
||||
// The delay should be between 4000 and 5000ms (capped at 30000)
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should handle missing WebSocket connection", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockSocket as any).ws = null;
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
// Should NOT trigger reopen - just wait for BaseApi to reconnect
|
||||
expect(mockSocket.reopen).not.toHaveBeenCalled();
|
||||
|
||||
// Same calculation as above - base delay 4000ms + random up to 1000ms
|
||||
const callArgs = mockSetTimeout.mock.calls[0];
|
||||
expect(callArgs[0]).toEqual(expect.any(Function));
|
||||
expect(callArgs[1]).toBeGreaterThanOrEqual(4000);
|
||||
expect(callArgs[1]).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it("should not process response if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCall.complete = true;
|
||||
serviceCall.onReceived({ result: "test" });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not timeout if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCall.complete = true;
|
||||
serviceCall.onTimeout();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"timeout should not happen, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not attempt if already complete", () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
serviceCall.complete = true;
|
||||
serviceCall.attempt();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"test-mid",
|
||||
"attempt should not be called, request is already complete",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle multiple retries correctly", () => {
|
||||
mockSocket.ws.send.mockImplementation(() => {
|
||||
throw new Error("Connection failed");
|
||||
});
|
||||
|
||||
serviceCall.start();
|
||||
|
||||
// Should have decremented retries and scheduled a retry
|
||||
expect(serviceCall.retries).toBe(2);
|
||||
// Should NOT call reopen - BaseApi handles reconnection
|
||||
expect(mockSocket.reopen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clean up properly on successful response", () => {
|
||||
serviceCall.start();
|
||||
|
||||
const responseData = { success: true };
|
||||
const message = { response: responseData };
|
||||
serviceCall.onReceived(message);
|
||||
|
||||
expect(serviceCall.complete).toBe(true);
|
||||
expect(mockClearTimeout).toHaveBeenCalled();
|
||||
expect(mockSocket.inflight["test-mid"]).toBeUndefined();
|
||||
expect(mockSuccess).toHaveBeenCalledWith(responseData);
|
||||
});
|
||||
|
||||
it("should handle edge case of negative retries", () => {
|
||||
serviceCall.retries = -1;
|
||||
|
||||
serviceCall.attempt();
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith("Ran out of retries");
|
||||
});
|
||||
|
||||
it("should bind timeout callbacks correctly", () => {
|
||||
serviceCall.start();
|
||||
|
||||
// Verify that setTimeout was called with a bound function
|
||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
|
||||
});
|
||||
});
|
||||
195
ts/packages/client/src/__tests__/workbench-contracts.test.ts
Normal file
195
ts/packages/client/src/__tests__/workbench-contracts.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BaseApi,
|
||||
ConfigApi,
|
||||
KnowledgeApi,
|
||||
LibrarianApi,
|
||||
} from "../socket/trustgraph-socket";
|
||||
|
||||
function makeApi() {
|
||||
const makeRequest = vi.fn();
|
||||
const base = {
|
||||
user: "alice",
|
||||
makeRequest,
|
||||
} as unknown as BaseApi;
|
||||
return { base, makeRequest };
|
||||
}
|
||||
|
||||
describe("workbench API contracts", () => {
|
||||
describe("ConfigApi", () => {
|
||||
it("returns Python-style getvalues entries", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({
|
||||
values: [{ type: "prompt", key: "welcome", value: "hello" }],
|
||||
});
|
||||
|
||||
const result = await new ConfigApi(base).getValues("prompt");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"config",
|
||||
{ operation: "getvalues", type: "prompt" },
|
||||
60000,
|
||||
);
|
||||
expect(result).toEqual([{ type: "prompt", key: "welcome", value: "hello" }]);
|
||||
});
|
||||
|
||||
it("parses token-cost values stored as config JSON strings", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({
|
||||
values: [
|
||||
{
|
||||
type: "token-cost",
|
||||
key: "gpt-test",
|
||||
value: JSON.stringify({ input_price: 0.1, output_price: 0.2 }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await new ConfigApi(base).getTokenCosts();
|
||||
|
||||
expect(result).toEqual([
|
||||
{ model: "gpt-test", input_price: 0.1, output_price: 0.2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes and deletes config using Python-style key/value arrays", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
const config = new ConfigApi(base);
|
||||
|
||||
await config.putConfig([{ type: "tool", key: "search", value: "{}" }]);
|
||||
await config.deleteConfig({ type: "tool", key: "search" });
|
||||
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"config",
|
||||
{
|
||||
operation: "put",
|
||||
values: [{ type: "tool", key: "search", value: "{}" }],
|
||||
},
|
||||
60000,
|
||||
);
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"config",
|
||||
{
|
||||
operation: "delete",
|
||||
keys: [{ type: "tool", key: "search" }],
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LibrarianApi", () => {
|
||||
it("reads Python-style document and processing list responses", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const document = { id: "doc-1", title: "Document" };
|
||||
const processing = { id: "proc-1", "document-id": "doc-1" };
|
||||
const librarian = new LibrarianApi(base);
|
||||
|
||||
makeRequest
|
||||
.mockResolvedValueOnce({ "document-metadatas": [document] })
|
||||
.mockResolvedValueOnce({ "processing-metadatas": [processing] });
|
||||
|
||||
await expect(librarian.getDocuments()).resolves.toEqual([document]);
|
||||
await expect(librarian.getProcessing()).resolves.toEqual([processing]);
|
||||
});
|
||||
|
||||
it("sends both kebab-case and camel-case document identifiers", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const document = { id: "doc-1", title: "Document" };
|
||||
makeRequest.mockResolvedValue({ "document-metadata": document });
|
||||
|
||||
const result = await new LibrarianApi(base).getDocumentMetadata("doc-1");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"librarian",
|
||||
{
|
||||
operation: "get-document-metadata",
|
||||
"document-id": "doc-1",
|
||||
documentId: "doc-1",
|
||||
user: "alice",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
expect(result).toEqual(document);
|
||||
});
|
||||
|
||||
it("uploads documents with Python and TypeScript metadata aliases", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
|
||||
await new LibrarianApi(base).loadDocument(
|
||||
"SGVsbG8=",
|
||||
"text/plain",
|
||||
"Hello",
|
||||
"comment",
|
||||
["tag"],
|
||||
"doc-1",
|
||||
);
|
||||
|
||||
const request = makeRequest.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(request["document-metadata"]).toMatchObject({
|
||||
id: "doc-1",
|
||||
kind: "text/plain",
|
||||
title: "Hello",
|
||||
user: "alice",
|
||||
"document-type": "source",
|
||||
documentType: "source",
|
||||
});
|
||||
expect(request.documentMetadata).toEqual(request["document-metadata"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KnowledgeApi", () => {
|
||||
it("lists and loads document embedding cores", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
const knowledge = new KnowledgeApi(base);
|
||||
|
||||
makeRequest
|
||||
.mockResolvedValueOnce({ ids: ["de-core"] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await expect(knowledge.getDocumentEmbeddingCores()).resolves.toEqual(["de-core"]);
|
||||
await knowledge.loadDeCore("de-core", "default", "library");
|
||||
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"knowledge",
|
||||
{ operation: "list-de-cores", user: "alice" },
|
||||
60000,
|
||||
);
|
||||
expect(makeRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"knowledge",
|
||||
{
|
||||
operation: "load-de-core",
|
||||
id: "de-core",
|
||||
flow: "default",
|
||||
user: "alice",
|
||||
collection: "library",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
it("unloads knowledge graph cores from a flow", async () => {
|
||||
const { base, makeRequest } = makeApi();
|
||||
makeRequest.mockResolvedValue({});
|
||||
|
||||
await new KnowledgeApi(base).unloadKgCore("kg-core", "default");
|
||||
|
||||
expect(makeRequest).toHaveBeenCalledWith(
|
||||
"knowledge",
|
||||
{
|
||||
operation: "unload-kg-core",
|
||||
id: "kg-core",
|
||||
flow: "default",
|
||||
user: "alice",
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ export * from "./models/namespaces.js";
|
|||
|
||||
// Export socket client
|
||||
export * from "./socket/trustgraph-socket.js";
|
||||
export * from "./rpc/contract.js";
|
||||
|
||||
// Export WebSocket adapter (isomorphic helpers and types)
|
||||
export * from "./socket/websocket-adapter.js";
|
||||
|
|
|
|||
|
|
@ -280,12 +280,16 @@ export interface DocumentMetadata {
|
|||
metadata?: Triple[];
|
||||
user?: string;
|
||||
tags?: string[];
|
||||
parentId?: string;
|
||||
documentType?: string;
|
||||
"parent-id"?: string;
|
||||
"document-type"?: string;
|
||||
}
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id?: string;
|
||||
"document-id"?: string;
|
||||
documentId?: string;
|
||||
time?: number;
|
||||
flow?: string;
|
||||
user?: string;
|
||||
|
|
@ -295,7 +299,9 @@ export interface ProcessingMetadata {
|
|||
|
||||
export interface LibraryRequest {
|
||||
operation: string;
|
||||
documentId?: string;
|
||||
"document-id"?: string;
|
||||
processingId?: string;
|
||||
"processing-id"?: string;
|
||||
"document-metadata"?: DocumentMetadata;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
|
|
@ -309,12 +315,15 @@ export interface LibraryRequest {
|
|||
}
|
||||
|
||||
export interface LibraryResponse {
|
||||
error: Error;
|
||||
error?: Error;
|
||||
"document-metadata"?: DocumentMetadata;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
content?: string;
|
||||
"document-metadatas"?: DocumentMetadata[];
|
||||
documents?: DocumentMetadata[];
|
||||
"processing-metadata"?: ProcessingMetadata;
|
||||
"processing-metadatas"?: ProcessingMetadata[];
|
||||
processing?: ProcessingMetadata[];
|
||||
}
|
||||
|
||||
export interface KnowledgeRequest {
|
||||
|
|
@ -325,6 +334,9 @@ export interface KnowledgeRequest {
|
|||
collection?: string;
|
||||
triples?: Triple[];
|
||||
"graph-embeddings"?: GraphEmbeddings;
|
||||
graphEmbeddings?: GraphEmbeddings;
|
||||
"document-embeddings"?: unknown;
|
||||
documentEmbeddings?: unknown;
|
||||
}
|
||||
|
||||
export interface KnowledgeResponse {
|
||||
|
|
@ -333,6 +345,9 @@ export interface KnowledgeResponse {
|
|||
eos?: boolean;
|
||||
triples?: Triple[];
|
||||
"graph-embeddings"?: GraphEmbeddings;
|
||||
graphEmbeddings?: GraphEmbeddings;
|
||||
"document-embeddings"?: unknown;
|
||||
documentEmbeddings?: unknown;
|
||||
}
|
||||
|
||||
export interface FlowRequest {
|
||||
|
|
|
|||
35
ts/packages/client/src/rpc/contract.ts
Normal file
35
ts/packages/client/src/rpc/contract.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Schema as S } from "effect";
|
||||
import * as Rpc from "effect/unstable/rpc/Rpc";
|
||||
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||
|
||||
export class DispatchPayload extends S.Class<DispatchPayload>("DispatchPayload")({
|
||||
scope: S.Literals(["global", "flow"]),
|
||||
service: S.String,
|
||||
flow: S.optionalKey(S.String),
|
||||
request: S.Record(S.String, S.Unknown),
|
||||
}) {}
|
||||
|
||||
export class DispatchStreamChunk extends S.Class<DispatchStreamChunk>("DispatchStreamChunk")({
|
||||
response: S.Unknown,
|
||||
complete: S.Boolean,
|
||||
}) {}
|
||||
|
||||
export class DispatchError extends S.ErrorClass<DispatchError>("DispatchError")({
|
||||
_tag: S.tag("DispatchError"),
|
||||
message: S.String,
|
||||
}) {}
|
||||
|
||||
export class Dispatch extends Rpc.make("Dispatch", {
|
||||
payload: DispatchPayload,
|
||||
success: S.Unknown,
|
||||
error: DispatchError,
|
||||
}) {}
|
||||
|
||||
export class DispatchStream extends Rpc.make("DispatchStream", {
|
||||
payload: DispatchPayload,
|
||||
success: DispatchStreamChunk,
|
||||
error: DispatchError,
|
||||
stream: true,
|
||||
}) {}
|
||||
|
||||
export const TrustGraphRpcs = RpcGroup.make(Dispatch, DispatchStream);
|
||||
192
ts/packages/client/src/socket/effect-rpc-client.ts
Normal file
192
ts/packages/client/src/socket/effect-rpc-client.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue