From ffd97375a85658efae4bc8ab970582ada479fb00 Mon Sep 17 00:00:00 2001 From: elpresidank Date: Tue, 12 May 2026 08:06:58 -0500 Subject: [PATCH] saving --- ts/Containerfile | 15 +- ts/bun.lock | 255 +++++-- ts/deploy/docker-compose.dev.yml | 2 +- ts/deploy/docker-compose.yml | 50 +- ts/entrypoints/agent.mjs | 7 +- ts/entrypoints/chunker.mjs | 7 +- ts/entrypoints/config.mjs | 7 +- ts/entrypoints/cores.mjs | 7 +- ts/entrypoints/doc-embeddings-query.mjs | 7 +- ts/entrypoints/document-rag.mjs | 7 +- ts/entrypoints/embeddings.mjs | 7 +- ts/entrypoints/extractor.mjs | 7 +- ts/entrypoints/flow-manager.mjs | 7 +- ts/entrypoints/gateway.mjs | 7 +- ts/entrypoints/graph-embeddings-query.mjs | 7 +- ts/entrypoints/graph-embeddings-store.mjs | 7 +- ts/entrypoints/graph-rag.mjs | 7 +- ts/entrypoints/librarian.mjs | 7 +- ts/entrypoints/mcp-tool.mjs | 7 +- ts/entrypoints/pdf-decoder.mjs | 7 +- ts/entrypoints/prompt.mjs | 7 +- .../text-completion-azure-openai.mjs | 7 +- ts/entrypoints/text-completion-claude.mjs | 7 +- ts/entrypoints/text-completion-mistral.mjs | 7 +- ts/entrypoints/text-completion-ollama.mjs | 7 +- .../text-completion-openai-compatible.mjs | 7 +- ts/entrypoints/text-completion-openai.mjs | 7 +- ts/entrypoints/triples-query.mjs | 7 +- ts/entrypoints/triples-store.mjs | 7 +- ts/package.json | 82 ++- ts/packages/base/package.json | 17 +- .../base/src/__tests__/consumer.test.ts | 4 +- .../src/__tests__/embeddings-service.test.ts | 266 +++++++ .../__tests__/flow-processor-runtime.test.ts | 215 ++++++ .../src/__tests__/flow-spec-runtime.test.ts | 298 ++++++++ .../src/__tests__/messaging-runtime.test.ts | 277 +++++++ .../src/__tests__/runtime-services.test.ts | 240 +++++++ .../base/src/__tests__/schema-effect.test.ts | 95 +++ ts/packages/base/src/backend/index.ts | 8 + ts/packages/base/src/backend/nats.ts | 102 ++- ts/packages/base/src/backend/pubsub.ts | 101 +++ ts/packages/base/src/backend/types.ts | 4 + ts/packages/base/src/errors.ts | 319 ++++++++- ts/packages/base/src/index.ts | 1 + ts/packages/base/src/messaging/consumer.ts | 29 +- ts/packages/base/src/messaging/index.ts | 34 + ts/packages/base/src/messaging/producer.ts | 38 +- .../base/src/messaging/request-response.ts | 4 +- ts/packages/base/src/messaging/runtime.ts | 612 ++++++++++++++++ ts/packages/base/src/messaging/subscriber.ts | 34 +- ts/packages/base/src/metrics/prometheus.ts | 12 +- .../base/src/processor/async-processor.ts | 135 +++- .../base/src/processor/flow-processor.ts | 368 +++++++--- ts/packages/base/src/processor/flow.ts | 240 +++++-- ts/packages/base/src/processor/index.ts | 23 +- ts/packages/base/src/processor/program.ts | 139 ++++ ts/packages/base/src/runtime/config.ts | 33 + ts/packages/base/src/runtime/index.ts | 10 + .../base/src/runtime/messaging-config.ts | 41 ++ ts/packages/base/src/schema/messages.ts | 677 ++++++++++-------- ts/packages/base/src/schema/primitives.ts | 138 ++-- .../base/src/services/embeddings-service.ts | 88 ++- ts/packages/base/src/services/index.ts | 6 +- ts/packages/base/src/services/llm-service.ts | 70 +- ts/packages/base/src/spec/consumer-spec.ts | 89 ++- ts/packages/base/src/spec/index.ts | 2 +- ts/packages/base/src/spec/parameter-spec.ts | 18 +- ts/packages/base/src/spec/producer-spec.ts | 27 +- .../base/src/spec/request-response-spec.ts | 50 +- ts/packages/base/src/spec/types.ts | 21 +- ts/packages/base/tsconfig.json | 1 + ts/packages/base/vitest.config.ts | 2 + ts/packages/cli/package.json | 8 +- ts/packages/cli/src/commands/agent.ts | 6 +- ts/packages/cli/src/commands/flow.ts | 5 +- ts/packages/cli/src/commands/graph-rag.ts | 11 +- ts/packages/cli/src/commands/library.ts | 14 +- ts/packages/cli/src/commands/triples.ts | 15 +- ts/packages/cli/src/commands/util.ts | 2 +- ts/packages/cli/tsconfig.json | 1 + ts/packages/client/package.json | 10 +- ts/packages/client/src/models/messages.ts | 2 +- .../client/src/socket/service-call-multi.ts | 19 +- ts/packages/client/src/socket/service-call.ts | 45 +- .../client/src/socket/trustgraph-socket.ts | 586 +++++++++------ .../client/src/socket/websocket-adapter.ts | 4 +- ts/packages/client/tsconfig.json | 1 + ts/packages/flow/package.json | 9 +- .../src/__tests__/chunking-service.test.ts | 230 ++++++ .../src/__tests__/ollama-embeddings.test.ts | 82 +++ .../flow/src/agent/mcp-tool/service.ts | 24 +- ts/packages/flow/src/agent/react/parser.ts | 19 +- ts/packages/flow/src/agent/react/service.ts | 65 +- ts/packages/flow/src/agent/react/tools.ts | 78 +- ts/packages/flow/src/agent/tool-filter.ts | 8 +- ts/packages/flow/src/chunking/service.ts | 83 ++- ts/packages/flow/src/config/service.ts | 108 ++- ts/packages/flow/src/cores/service.ts | 38 +- ts/packages/flow/src/decoding/pdf-decoder.ts | 22 +- ts/packages/flow/src/embeddings/ollama.ts | 121 ++-- .../flow/src/extract/knowledge-extract.ts | 52 +- ts/packages/flow/src/flow-manager/service.ts | 38 +- .../flow/src/gateway/dispatch/manager.ts | 22 +- ts/packages/flow/src/gateway/dispatch/mux.ts | 5 +- .../flow/src/gateway/dispatch/serialize.ts | 23 +- ts/packages/flow/src/gateway/server.ts | 78 +- ts/packages/flow/src/index.ts | 7 +- .../flow/src/librarian/collection-manager.ts | 2 +- ts/packages/flow/src/librarian/service.ts | 121 ++-- .../src/model/text-completion/azure-openai.ts | 27 +- .../flow/src/model/text-completion/claude.ts | 16 +- .../flow/src/model/text-completion/mistral.ts | 23 +- .../flow/src/model/text-completion/ollama.ts | 8 +- .../text-completion/openai-compatible.ts | 16 +- .../flow/src/model/text-completion/openai.ts | 23 +- ts/packages/flow/src/prompt/template.ts | 14 +- .../query/embeddings/qdrant-doc-service.ts | 12 +- .../flow/src/query/embeddings/qdrant-doc.ts | 11 +- .../query/embeddings/qdrant-graph-service.ts | 10 +- .../flow/src/query/embeddings/qdrant-graph.ts | 9 +- .../src/query/triples/falkordb-service.ts | 10 +- .../flow/src/query/triples/falkordb.ts | 20 +- .../src/retrieval/document-rag-service.ts | 12 +- .../flow/src/retrieval/document-rag.ts | 21 +- .../flow/src/retrieval/graph-rag-service.ts | 22 +- ts/packages/flow/src/retrieval/graph-rag.ts | 51 +- ts/packages/flow/src/runtime/effect-files.ts | 40 ++ .../embeddings/graph-embeddings-service.ts | 14 +- .../flow/src/storage/embeddings/qdrant-doc.ts | 16 +- .../src/storage/embeddings/qdrant-graph.ts | 14 +- .../src/storage/triples/falkordb-service.ts | 14 +- ts/packages/flow/tsconfig.json | 1 + ts/packages/flow/vitest.config.ts | 2 + ts/packages/mcp/package.json | 8 +- ts/packages/mcp/src/server.ts | 15 +- ts/packages/mcp/tsconfig.json | 1 + ts/packages/workbench/package.json | 1 + .../src/components/chat/explain-graph.tsx | 30 +- .../src/components/error-boundary.tsx | 8 +- .../workbench/src/components/ui/dialog.tsx | 10 +- .../workbench/src/components/ui/textarea.tsx | 2 +- ts/packages/workbench/src/hooks/use-chat.ts | 154 ++-- .../workbench/src/hooks/use-conversation.ts | 6 +- ts/packages/workbench/src/lib/graph-utils.ts | 2 +- ts/packages/workbench/src/main.tsx | 6 +- ts/packages/workbench/src/pages/chat.tsx | 36 +- ts/packages/workbench/src/pages/flows.tsx | 52 +- ts/packages/workbench/src/pages/graph.tsx | 51 +- .../workbench/src/pages/knowledge-cores.tsx | 6 +- ts/packages/workbench/src/pages/library.tsx | 91 +-- ts/packages/workbench/src/pages/mcp-tools.tsx | 53 +- ts/packages/workbench/src/pages/prompts.tsx | 12 +- ts/packages/workbench/src/pages/settings.tsx | 61 +- .../workbench/src/pages/token-cost.tsx | 6 +- .../src/providers/notification-provider.tsx | 7 +- .../src/providers/settings-provider.tsx | 4 +- .../src/providers/socket-provider.tsx | 4 +- ts/packages/workbench/tsconfig.json | 1 - ts/tsconfig.base.json | 124 +++- ts/tsconfig.json | 11 +- 160 files changed, 6704 insertions(+), 1895 deletions(-) create mode 100644 ts/packages/base/src/__tests__/embeddings-service.test.ts create mode 100644 ts/packages/base/src/__tests__/flow-processor-runtime.test.ts create mode 100644 ts/packages/base/src/__tests__/flow-spec-runtime.test.ts create mode 100644 ts/packages/base/src/__tests__/messaging-runtime.test.ts create mode 100644 ts/packages/base/src/__tests__/runtime-services.test.ts create mode 100644 ts/packages/base/src/__tests__/schema-effect.test.ts create mode 100644 ts/packages/base/src/backend/pubsub.ts create mode 100644 ts/packages/base/src/messaging/runtime.ts create mode 100644 ts/packages/base/src/processor/program.ts create mode 100644 ts/packages/base/src/runtime/config.ts create mode 100644 ts/packages/base/src/runtime/index.ts create mode 100644 ts/packages/base/src/runtime/messaging-config.ts create mode 100644 ts/packages/flow/src/__tests__/chunking-service.test.ts create mode 100644 ts/packages/flow/src/__tests__/ollama-embeddings.test.ts create mode 100644 ts/packages/flow/src/runtime/effect-files.ts diff --git a/ts/Containerfile b/ts/Containerfile index 388992a7..1bcb0e16 100644 --- a/ts/Containerfile +++ b/ts/Containerfile @@ -1,15 +1,14 @@ -# TrustGraph TypeScript — multi-stage build for all Node.js services. +# TrustGraph TypeScript — multi-stage build for all Bun services. # A single image is built once; each service overrides CMD to pick its entrypoint. # --------------------------------------------------------------------------- # Stage 1: Build # --------------------------------------------------------------------------- -FROM node:22-slim AS builder -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +FROM oven/bun:1.3.13-slim AS builder WORKDIR /app # Copy workspace config first for layer caching -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.json ./ +COPY package.json bun.lock pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.json ./ COPY packages/base/package.json packages/base/tsconfig.json packages/base/ COPY packages/client/package.json packages/client/tsconfig.json packages/client/ COPY packages/flow/package.json packages/flow/tsconfig.json packages/flow/ @@ -17,16 +16,16 @@ COPY packages/cli/package.json packages/cli/tsconfig.json packages/cli/ COPY packages/mcp/package.json packages/mcp/tsconfig.json packages/mcp/ COPY packages/workbench/package.json packages/workbench/tsconfig.json packages/workbench/ -RUN pnpm install --frozen-lockfile +RUN bun install --frozen-lockfile # Copy source and build COPY packages/ packages/ -RUN pnpm build --filter=@trustgraph/base --filter=@trustgraph/client --filter=@trustgraph/flow +RUN bunx --bun turbo build --filter=@trustgraph/base --filter=@trustgraph/client --filter=@trustgraph/flow # --------------------------------------------------------------------------- # Stage 2: Runtime # --------------------------------------------------------------------------- -FROM node:22-slim AS runtime +FROM oven/bun:1.3.13-slim AS runtime WORKDIR /app # Copy built output and production deps @@ -41,4 +40,4 @@ ENV NODE_ENV=production ENV NATS_URL=nats://nats:4222 EXPOSE 8088 -CMD ["node", "entrypoints/gateway.mjs"] +CMD ["bun", "entrypoints/gateway.mjs"] diff --git a/ts/bun.lock b/ts/bun.lock index 58196b5b..29307d92 100644 --- a/ts/bun.lock +++ b/ts/bun.lock @@ -4,23 +4,38 @@ "workspaces": { "": { "name": "trustgraph-ts", + "dependencies": { + "effect": "4.0.0-beta.65", + }, "devDependencies": { + "@effect/platform-bun": "4.0.0-beta.65", + "@effect/tsgo": "0.6.0", + "@effect/vitest": "4.0.0-beta.65", + "@types/bun": "^1.3.13", + "@types/node": "^25.7.0", + "@typescript/native-preview": "^7.0.0-dev.20260511.1", + "falkordb": "^5.0.0", + "nats": "^2.29.0", + "pdf-lib": "^1.17.1", "tsx": "^4.21.0", "turbo": "^2.5.0", "typescript": "^5.8.0", + "vitest": "^4.1.6", }, }, "packages/base": { "name": "@trustgraph/base", "version": "0.1.0", "dependencies": { + "effect": "4.0.0-beta.65", "nats": "^2.29.0", "prom-client": "^15.1.0", }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0", + "vitest": "^4.1.6", }, }, "packages/cli": { @@ -33,23 +48,29 @@ "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", "commander": "^13.1.0", + "effect": "4.0.0-beta.65", "ws": "^8.18.0", }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/ws": "^8.5.0", "typescript": "^5.8.0", - "vitest": "^3.1.0", + "vitest": "^4.1.6", }, }, "packages/client": { "name": "@trustgraph/client", "version": "0.1.0", + "dependencies": { + "effect": "4.0.0-beta.65", + }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "happy-dom": "^20.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0", + "vitest": "^4.1.6", }, "peerDependencies": { "ws": "^8.0.0", @@ -63,16 +84,24 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@effect/platform-bun": "4.0.0-beta.65", "@fastify/websocket": "^11.0.0", + "@mistralai/mistralai": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.12.0", "@qdrant/js-client-rest": "^1.13.0", "@trustgraph/base": "workspace:*", + "effect": "4.0.0-beta.65", "falkordb": "^5.0.0", "fastify": "^5.2.0", + "ollama": "^0.6.3", "openai": "^4.85.0", + "pdfjs-dist": "^5.6.205", }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", + "@types/node": "^22.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0", + "vitest": "^4.1.6", }, }, "packages/mcp": { @@ -82,12 +111,14 @@ "@modelcontextprotocol/sdk": "^1.8.0", "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", + "effect": "4.0.0-beta.65", "zod": "^3.23.0", }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0", + "vitest": "^4.1.6", }, }, "packages/workbench": { @@ -96,7 +127,6 @@ "dependencies": { "@tanstack/react-query": "^5.75.0", "@trustgraph/client": "workspace:*", - "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "lucide-react": "^0.513.0", "react": "^19.1.0", @@ -108,6 +138,7 @@ "zustand": "^5.0.0", }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@tailwindcss/vite": "^4.1.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", @@ -159,6 +190,28 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-6BgEjVDibeOgGj0pvYRx+mBLSWVBPlvuqa6o4kuyrRIdgn92nbKrbglEiIRe4sn7yYmeKei4N/kd7fRzN3rEwA=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="], + + "@effect/tsgo": ["@effect/tsgo@0.6.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.6.0", "@effect/tsgo-darwin-x64": "0.6.0", "@effect/tsgo-linux-arm": "0.6.0", "@effect/tsgo-linux-arm64": "0.6.0", "@effect/tsgo-linux-x64": "0.6.0", "@effect/tsgo-win32-arm64": "0.6.0", "@effect/tsgo-win32-x64": "0.6.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.js" } }, "sha512-suCUiGQ4Nkuw08kx3HJsS4PDtoc7h9erjTym8D8jdnlERt9RJ8bFfg5TnFwbriyyTZhjZCr7W0WctWyEMayotg=="], + + "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.6.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mV9GI5wy6pAVssXV8awYCafr/AWhCoO6/xUJD2yv4MWEycP1/k9ZLR2mPvPMXqt51Zs9rkRGaCANkCKcRoxs3w=="], + + "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.6.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-B/7V34BZqMqCYbP+TBz6ucTvc49gKhkoUnebELxK6imBymBS7fRkgsFd+trlst62bCblH774T2GCCx7gkyKmZw=="], + + "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.6.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ovehHAdBbuxQi9eOon012JmcaVkxufWNSVJTpknlD+cC4jawiWDGIr51abkvH72GnTLgyfjjq0e/ibeJ5n9I0Q=="], + + "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.6.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-xClS3A78/uM18ndxoGJDTxIe5AEbm5kuZ0ERJ/9wuQNRqITYW6ug93QMqsyDkp0VmFXxdfQbt81y4mpeSN1voQ=="], + + "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.6.0", "", { "os": "linux", "cpu": "x64" }, "sha512-un+yA+AQShNSKxYJibhgY90c9bNPkjOZr0ecsmDB+S76STKQHOag/KW8G2EwpRg/eqWqn5GV04VEhP6Cq4QFMQ=="], + + "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.6.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5Ymu5FzNHA/YpDJkE67zD/KDPKm81e3BdlsGJ50ZOQI0YDnFlUWtENOjV3ScfW8g17pM3COnBJKJYYhMuek6wA=="], + + "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.6.0", "", { "os": "win32", "cpu": "x64" }, "sha512-vD02OcS3zzRY7/vmGZiJaAttXymicpSY19FdSKHgvT9RvRlffbAFj90DSP6lFIrpZPMB2r18tmI2QrE0ZPzWWw=="], + + "@effect/vitest": ["@effect/vitest@4.0.0-beta.65", "", { "peerDependencies": { "effect": "^4.0.0-beta.65", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-dJdZlQhB+AtMlSgrJ0QiprRhDnGVTcKmPe699+f5qkQjCKauogaAKekWV3xEM1envAMntwqeFUD4QHVnE+cFPg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -241,10 +294,52 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mistralai/mistralai": ["@mistralai/mistralai@1.15.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-fb995eiz3r0KsBGtRjFV+/iLbX+UpfalxpF+YitT3R6ukrPD4PN+FGwwmYcRFhNAzVzDUtTVxQYnjQWEnwV5nw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "1.19.12", "ajv": "8.18.0", "ajv-formats": "3.0.1", "content-type": "1.0.5", "cors": "2.8.6", "cross-spawn": "7.0.6", "eventsource": "3.0.7", "eventsource-parser": "3.0.6", "express": "5.2.1", "express-rate-limit": "8.3.2", "hono": "4.12.10", "jose": "6.2.2", "json-schema-typed": "8.0.2", "pkce-challenge": "5.0.1", "raw-body": "3.0.2", "zod-to-json-schema": "3.25.2" }, "peerDependencies": { "zod": "3.25.76" } }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "6.24.1" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="], @@ -303,6 +398,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "enhanced-resolve": "5.20.1", "jiti": "2.6.1", "lightningcss": "1.32.0", "magic-string": "0.30.21", "source-map-js": "1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], @@ -371,6 +468,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "7.29.0" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "4.0.2", "assertion-error": "2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -387,7 +486,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "22.19.17", "form-data": "4.0.5" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -401,23 +500,39 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "22.19.17" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260511.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260511.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260511.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-cUyY4Sr6065280lB6hCwTMCBMTxlEIGjSLzHym28yikA5sFiEsAzlwiU0i+XkTUIqr5K5M/SzSJiioDN+vpjtA=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260511.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SYrqVOlapDxDG7FzHBIJbfgaix+mXPkYzYGqwpz/TAhoPA7sgbfAoGLaqi3ut9N88C/OYNhEX4tjz/0PC9i1nw=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260511.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zIe31OYgBvkgTIQEwJtKim6SYyuVTkr+9fK/87hVwKN15X3Ikjeh0C0g2W/Vl4rXeMvy95wBGDN1jpW11DIvgg=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260511.1", "", { "os": "linux", "cpu": "arm" }, "sha512-02b45lpPmYf125PvcnK67WW93N55qwKmtInwfVefV997S17Ib3h6hlCW4e24BDhNsGRCSLhPA4Lu7ZvTq5pLkw=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260511.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-YbmCQXGYkDChGFG7hXJzIgmRjtU1kE5VK/+k322nGnbq4ePqSjS3dS0+ehPATmvfO1XjCDfh3ekED+AtmWk6aQ=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260511.1", "", { "os": "linux", "cpu": "x64" }, "sha512-e+TweaVJFaM96tV1UM1kRfk2y8QBkZtz7+0wcxrDGmyJz3IIRUlg1btocaBkhsmVtQPXMr37RutBBMgpl3vgUg=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260511.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-zgkoGiCpOrly5h8ghcuu6ZNSfrnRqtHoCq584Q92+s4D/j1MU3oKkGPvmkezp5Mj2v7ffR9AjU+lWRDkrfm6eA=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260511.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SUm7iVYzKaflol+QwH0Ny5jZtco6PJduI+h/TEg0sgBJzVBa+9RN4I9+Xu9v+EJ1bci3XI7835IRdSP36lCgCw=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "7.29.0", "@babel/plugin-transform-react-jsx-self": "7.27.1", "@babel/plugin-transform-react-jsx-source": "7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "7.20.5", "react-refresh": "0.17.0" }, "peerDependencies": { "vite": "6.4.1" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "5.2.3", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "5.3.3", "tinyrainbow": "2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="], - "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "3.0.3", "magic-string": "0.30.21" }, "optionalDependencies": { "vite": "6.4.1" } }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.6", "", { "dependencies": { "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="], - "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "2.0.3", "strip-literal": "3.1.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + "@vitest/runner": ["@vitest/runner@4.1.6", "", { "dependencies": { "@vitest/utils": "4.1.6", "pathe": "^2.0.3" } }, "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA=="], - "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw=="], - "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "4.0.4" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/spy": ["@vitest/spy@4.1.6", "", {}, "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg=="], - "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "3.2.1", "tinyrainbow": "2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "5.0.1" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -453,9 +568,9 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "2.10.15", "caniuse-lite": "1.0.30001785", "electron-to-chromium": "1.5.331", "node-releases": "2.0.37", "update-browserslist-db": "1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -467,7 +582,7 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "2.0.1", "check-error": "2.1.3", "deep-eql": "5.0.2", "loupe": "3.2.1", "pathval": "2.0.1" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], @@ -477,10 +592,6 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], @@ -549,8 +660,6 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "2.0.2" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -567,6 +676,8 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], + "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -581,7 +692,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], @@ -615,6 +726,8 @@ "falkordb": ["falkordb@5.0.1", "", { "dependencies": { "@falkordb/client": "1.6.0", "@falkordb/graph": "2.0.1" } }, "sha512-cG/reBmAF5DJx0HnkLF+tV1deTcufx+dBEbYl3gZmBiZmlWk3YYM1IDcEA8ZeAm1Zw6NxaEjoqepwZNYedTm3Q=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -637,6 +750,8 @@ "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-querystring": "1.1.2", "safe-regex2": "5.1.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "float-tooltip": ["float-tooltip@1.7.5", "", { "dependencies": { "d3-selection": "3.0.0", "kapsule": "1.16.3", "preact": "10.29.1" } }, "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg=="], "force-graph": ["force-graph@1.51.2", "", { "dependencies": { "@tweenjs/tween.js": "25.0.0", "accessor-fn": "1.5.3", "bezier-js": "6.1.4", "canvas-color-tracker": "1.3.2", "d3-array": "3.2.4", "d3-drag": "3.0.0", "d3-force-3d": "3.0.6", "d3-scale": "4.0.2", "d3-scale-chromatic": "3.1.0", "d3-selection": "3.0.0", "d3-zoom": "3.0.0", "float-tooltip": "1.7.5", "index-array-by": "1.4.2", "kapsule": "1.16.3", "lodash-es": "4.18.1" } }, "sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng=="], @@ -695,6 +810,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -723,7 +840,7 @@ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], - "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -737,6 +854,8 @@ "kapsule": ["kapsule@1.16.3", "", { "dependencies": { "lodash-es": "4.18.1" } }, "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "1.1.1", "process-warning": "4.0.1", "set-cookie-parser": "2.7.2" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -769,8 +888,6 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "3.1.1" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.513.0", "", { "peerDependencies": { "react": "19.2.4" } }, "sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg=="], @@ -847,6 +964,12 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nats": ["nats@2.29.3", "", { "dependencies": { "nkeys.js": "1.1.0" } }, "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA=="], @@ -859,12 +982,18 @@ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "5.0.0" } }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -873,6 +1002,8 @@ "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "18.19.130", "@types/node-fetch": "2.6.13", "abort-controller": "3.0.0", "agentkeepalive": "4.6.0", "form-data-encoder": "1.7.2", "formdata-node": "4.4.1", "node-fetch": "2.7.0" }, "optionalDependencies": { "ws": "8.20.0", "zod": "3.25.76" }, "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "2.0.11", "character-entities-legacy": "3.0.0", "character-reference-invalid": "2.0.1", "decode-named-character-reference": "1.3.0", "is-alphanumerical": "2.0.1", "is-decimal": "2.0.1", "is-hexadecimal": "2.0.1" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -883,7 +1014,9 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], + + "pdfjs-dist": ["pdfjs-dist@5.7.284", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.100" } }, "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -911,6 +1044,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -1005,7 +1140,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], @@ -1013,8 +1148,6 @@ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "2.1.0", "character-entities-legacy": "3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -1033,26 +1166,26 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.4" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - - "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "0.27.7", "get-tsconfig": "4.13.7" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], "turbo": ["turbo@2.9.4", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.4", "@turbo/darwin-arm64": "2.9.4", "@turbo/linux-64": "2.9.4", "@turbo/linux-arm64": "2.9.4", "@turbo/windows-64": "2.9.4", "@turbo/windows-arm64": "2.9.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wZ/kMcZCuK5oEp7sXSSo/5fzKjP9I2EhoiarZjyCm2Ixk0WxFrC/h0gF3686eHHINoFQOOSWgB/pGfvkR8rkgQ=="], @@ -1065,7 +1198,7 @@ "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "3.0.3", "bail": "2.0.2", "devlop": "1.1.0", "extend": "3.0.2", "is-plain-obj": "4.1.0", "trough": "2.2.0", "vfile": "6.0.3" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1085,6 +1218,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "vfile-message": "4.0.3" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1093,14 +1228,14 @@ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "0.25.12", "fdir": "6.5.0", "picomatch": "4.0.4", "postcss": "8.5.8", "rollup": "4.60.1", "tinyglobby": "0.2.15" }, "optionalDependencies": { "@types/node": "22.19.17", "fsevents": "2.3.3", "jiti": "2.6.1", "lightningcss": "1.32.0", "tsx": "4.21.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.3", "es-module-lexer": "1.7.0", "pathe": "2.0.3", "vite": "6.4.1" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - - "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "5.2.3", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "5.3.3", "debug": "4.4.3", "expect-type": "1.3.0", "magic-string": "0.30.21", "pathe": "2.0.3", "picomatch": "4.0.4", "std-env": "3.10.0", "tinybench": "2.9.0", "tinyexec": "0.3.2", "tinyglobby": "0.2.15", "tinypool": "1.1.1", "tinyrainbow": "2.0.0", "vite": "6.4.1", "vite-node": "3.2.4", "why-is-node-running": "2.3.0" }, "optionalDependencies": { "@types/debug": "4.1.13", "@types/node": "22.19.17", "happy-dom": "20.8.9" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "vitest": ["vitest@4.1.6", "", { "dependencies": { "@vitest/expect": "4.1.6", "@vitest/mocker": "4.1.6", "@vitest/pretty-format": "4.1.6", "@vitest/runner": "4.1.6", "@vitest/snapshot": "4.1.6", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.6", "@vitest/browser-preview": "4.1.6", "@vitest/browser-webdriverio": "4.1.6", "@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-v8": "4.1.6", "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "0.0.3", "webidl-conversions": "3.0.1" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -1115,6 +1250,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "3.25.76" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1125,19 +1262,29 @@ "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@trustgraph/base/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@trustgraph/client/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@trustgraph/flow/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@trustgraph/mcp/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@types/node-fetch/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@types/ws/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + "happy-dom/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], - "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1147,14 +1294,32 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "vite/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@trustgraph/base/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@trustgraph/client/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@trustgraph/flow/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@trustgraph/mcp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/node-fetch/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], diff --git a/ts/deploy/docker-compose.dev.yml b/ts/deploy/docker-compose.dev.yml index ca1c5469..6fe0c463 100644 --- a/ts/deploy/docker-compose.dev.yml +++ b/ts/deploy/docker-compose.dev.yml @@ -31,7 +31,7 @@ services: # Override text-completion to use Ollama (no API key needed for local dev) text-completion: - command: ["node", "entrypoints/text-completion-ollama.mjs"] + command: ["bun", "entrypoints/text-completion-ollama.mjs"] environment: - NATS_URL=nats://nats:4222 - OLLAMA_URL=http://ollama:11434 diff --git a/ts/deploy/docker-compose.yml b/ts/deploy/docker-compose.yml index a57b8d78..b5f97360 100644 --- a/ts/deploy/docker-compose.yml +++ b/ts/deploy/docker-compose.yml @@ -211,7 +211,7 @@ services: build: context: ../ dockerfile: Containerfile - command: ["node", "entrypoints/gateway.mjs"] + command: ["bun", "entrypoints/gateway.mjs"] ports: - "${GATEWAY_PORT:-8088}:8088" environment: @@ -227,7 +227,7 @@ services: config-service: image: trustgraph-ts:local - command: ["node", "entrypoints/config.mjs"] + command: ["bun", "entrypoints/config.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -239,7 +239,7 @@ services: text-completion: image: trustgraph-ts:local - command: ["node", "entrypoints/text-completion-openai.mjs"] + command: ["bun", "entrypoints/text-completion-openai.mjs"] environment: - NATS_URL=nats://nats:4222 - OPENAI_TOKEN=${OPENAI_TOKEN:-} @@ -253,7 +253,7 @@ services: prompt: image: trustgraph-ts:local - command: ["node", "entrypoints/prompt.mjs"] + command: ["bun", "entrypoints/prompt.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -265,7 +265,7 @@ services: embeddings: image: trustgraph-ts:local - command: ["node", "entrypoints/embeddings.mjs"] + command: ["bun", "entrypoints/embeddings.mjs"] environment: - NATS_URL=nats://nats:4222 - OLLAMA_URL=http://ollama:11434 @@ -292,7 +292,7 @@ services: agent: image: trustgraph-ts:local - command: ["node", "entrypoints/agent.mjs"] + command: ["bun", "entrypoints/agent.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -304,7 +304,7 @@ services: mcp-tool: image: trustgraph-ts:local - command: ["node", "entrypoints/mcp-tool.mjs"] + command: ["bun", "entrypoints/mcp-tool.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -316,7 +316,7 @@ services: librarian: image: trustgraph-ts:local - command: ["node", "entrypoints/librarian.mjs"] + command: ["bun", "entrypoints/librarian.mjs"] environment: - NATS_URL=nats://nats:4222 volumes: @@ -330,7 +330,7 @@ services: flow-manager: image: trustgraph-ts:local - command: ["node", "entrypoints/flow-manager.mjs"] + command: ["bun", "entrypoints/flow-manager.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -342,7 +342,7 @@ services: knowledge-cores: image: trustgraph-ts:local - command: ["node", "entrypoints/cores.mjs"] + command: ["bun", "entrypoints/cores.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -358,7 +358,7 @@ services: pdf-decoder: image: trustgraph-ts:local - command: ["node", "entrypoints/pdf-decoder.mjs"] + command: ["bun", "entrypoints/pdf-decoder.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -370,7 +370,7 @@ services: chunker: image: trustgraph-ts:local - command: ["node", "entrypoints/chunker.mjs"] + command: ["bun", "entrypoints/chunker.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -382,7 +382,7 @@ services: extractor: image: trustgraph-ts:local - command: ["node", "entrypoints/extractor.mjs"] + command: ["bun", "entrypoints/extractor.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -394,7 +394,7 @@ services: triples-store: image: trustgraph-ts:local - command: ["node", "entrypoints/triples-store.mjs"] + command: ["bun", "entrypoints/triples-store.mjs"] environment: - NATS_URL=nats://nats:4222 - FALKORDB_URL=redis://falkordb:6379 @@ -409,7 +409,7 @@ services: graph-embeddings-store: image: trustgraph-ts:local - command: ["node", "entrypoints/graph-embeddings-store.mjs"] + command: ["bun", "entrypoints/graph-embeddings-store.mjs"] environment: - NATS_URL=nats://nats:4222 - QDRANT_URL=http://qdrant:6333 @@ -428,7 +428,7 @@ services: triples-query: image: trustgraph-ts:local - command: ["node", "entrypoints/triples-query.mjs"] + command: ["bun", "entrypoints/triples-query.mjs"] environment: - NATS_URL=nats://nats:4222 - FALKORDB_URL=redis://falkordb:6379 @@ -443,7 +443,7 @@ services: graph-embeddings-query: image: trustgraph-ts:local - command: ["node", "entrypoints/graph-embeddings-query.mjs"] + command: ["bun", "entrypoints/graph-embeddings-query.mjs"] environment: - NATS_URL=nats://nats:4222 - QDRANT_URL=http://qdrant:6333 @@ -458,7 +458,7 @@ services: doc-embeddings-query: image: trustgraph-ts:local - command: ["node", "entrypoints/doc-embeddings-query.mjs"] + command: ["bun", "entrypoints/doc-embeddings-query.mjs"] environment: - NATS_URL=nats://nats:4222 - QDRANT_URL=http://qdrant:6333 @@ -477,7 +477,7 @@ services: graph-rag: image: trustgraph-ts:local - command: ["node", "entrypoints/graph-rag.mjs"] + command: ["bun", "entrypoints/graph-rag.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -489,7 +489,7 @@ services: document-rag: image: trustgraph-ts:local - command: ["node", "entrypoints/document-rag.mjs"] + command: ["bun", "entrypoints/document-rag.mjs"] environment: - NATS_URL=nats://nats:4222 depends_on: @@ -505,7 +505,7 @@ services: # text-completion-ollama: # image: trustgraph-ts:local - # command: ["node", "entrypoints/text-completion-ollama.mjs"] + # command: ["bun", "entrypoints/text-completion-ollama.mjs"] # environment: # - NATS_URL=nats://nats:4222 # - OLLAMA_URL=http://ollama:11434 @@ -521,7 +521,7 @@ services: # text-completion-claude: # image: trustgraph-ts:local - # command: ["node", "entrypoints/text-completion-claude.mjs"] + # command: ["bun", "entrypoints/text-completion-claude.mjs"] # environment: # - NATS_URL=nats://nats:4222 # - CLAUDE_KEY=${CLAUDE_KEY:-} @@ -534,7 +534,7 @@ services: # text-completion-azure-openai: # image: trustgraph-ts:local - # command: ["node", "entrypoints/text-completion-azure-openai.mjs"] + # command: ["bun", "entrypoints/text-completion-azure-openai.mjs"] # environment: # - NATS_URL=nats://nats:4222 # - AZURE_TOKEN=${AZURE_TOKEN:-} @@ -550,7 +550,7 @@ services: # text-completion-openai-compatible: # image: trustgraph-ts:local - # command: ["node", "entrypoints/text-completion-openai-compatible.mjs"] + # command: ["bun", "entrypoints/text-completion-openai-compatible.mjs"] # environment: # - NATS_URL=nats://nats:4222 # - OPENAI_COMPAT_URL=${OPENAI_COMPAT_URL:-http://localhost:1234/v1} @@ -565,7 +565,7 @@ services: # text-completion-mistral: # image: trustgraph-ts:local - # command: ["node", "entrypoints/text-completion-mistral.mjs"] + # command: ["bun", "entrypoints/text-completion-mistral.mjs"] # environment: # - NATS_URL=nats://nats:4222 # - MISTRAL_TOKEN=${MISTRAL_TOKEN:-} diff --git a/ts/entrypoints/agent.mjs b/ts/entrypoints/agent.mjs index 855b8942..c6d62649 100644 --- a/ts/entrypoints/agent.mjs +++ b/ts/entrypoints/agent.mjs @@ -1,6 +1,9 @@ // Will work once the agent service is merged. -import("../packages/flow/dist/agent/react/service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/agent/react/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/chunker.mjs b/ts/entrypoints/chunker.mjs index 5e377208..b11094cf 100644 --- a/ts/entrypoints/chunker.mjs +++ b/ts/entrypoints/chunker.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/chunking/service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/chunking/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/config.mjs b/ts/entrypoints/config.mjs index 4b1f79f4..3a63d3a0 100644 --- a/ts/entrypoints/config.mjs +++ b/ts/entrypoints/config.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/config/service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/config/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/cores.mjs b/ts/entrypoints/cores.mjs index a5df1bb1..276d7f6f 100644 --- a/ts/entrypoints/cores.mjs +++ b/ts/entrypoints/cores.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/cores/service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/cores/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/doc-embeddings-query.mjs b/ts/entrypoints/doc-embeddings-query.mjs index 02424210..97ef6601 100644 --- a/ts/entrypoints/doc-embeddings-query.mjs +++ b/ts/entrypoints/doc-embeddings-query.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/query/embeddings/qdrant-doc-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/query/embeddings/qdrant-doc-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/document-rag.mjs b/ts/entrypoints/document-rag.mjs index fcb6b4b1..5d4eed9f 100644 --- a/ts/entrypoints/document-rag.mjs +++ b/ts/entrypoints/document-rag.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/retrieval/document-rag-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/retrieval/document-rag-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/embeddings.mjs b/ts/entrypoints/embeddings.mjs index fdcf25fe..79aed52a 100644 --- a/ts/entrypoints/embeddings.mjs +++ b/ts/entrypoints/embeddings.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/embeddings/ollama.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/embeddings/ollama.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/extractor.mjs b/ts/entrypoints/extractor.mjs index a58f6aa1..0beb86e6 100644 --- a/ts/entrypoints/extractor.mjs +++ b/ts/entrypoints/extractor.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/extract/knowledge-extract.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/extract/knowledge-extract.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/flow-manager.mjs b/ts/entrypoints/flow-manager.mjs index 91f3f308..0958a088 100644 --- a/ts/entrypoints/flow-manager.mjs +++ b/ts/entrypoints/flow-manager.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/flow-manager/service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/flow-manager/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/gateway.mjs b/ts/entrypoints/gateway.mjs index 796ab165..aeedf971 100644 --- a/ts/entrypoints/gateway.mjs +++ b/ts/entrypoints/gateway.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/gateway/server.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/gateway/server.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/graph-embeddings-query.mjs b/ts/entrypoints/graph-embeddings-query.mjs index 449b0002..4dc9244c 100644 --- a/ts/entrypoints/graph-embeddings-query.mjs +++ b/ts/entrypoints/graph-embeddings-query.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/query/embeddings/qdrant-graph-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/query/embeddings/qdrant-graph-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/graph-embeddings-store.mjs b/ts/entrypoints/graph-embeddings-store.mjs index e883b49a..4d1d042b 100644 --- a/ts/entrypoints/graph-embeddings-store.mjs +++ b/ts/entrypoints/graph-embeddings-store.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/storage/embeddings/graph-embeddings-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/storage/embeddings/graph-embeddings-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/graph-rag.mjs b/ts/entrypoints/graph-rag.mjs index 62c1b755..979c3522 100644 --- a/ts/entrypoints/graph-rag.mjs +++ b/ts/entrypoints/graph-rag.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/retrieval/graph-rag-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/retrieval/graph-rag-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/librarian.mjs b/ts/entrypoints/librarian.mjs index 3dd466fd..fdda0588 100644 --- a/ts/entrypoints/librarian.mjs +++ b/ts/entrypoints/librarian.mjs @@ -1,6 +1,9 @@ // Will work once the librarian service is merged. -import("../packages/flow/dist/librarian/service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/librarian/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/mcp-tool.mjs b/ts/entrypoints/mcp-tool.mjs index 3418eb71..d01da99a 100644 --- a/ts/entrypoints/mcp-tool.mjs +++ b/ts/entrypoints/mcp-tool.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/agent/mcp-tool/service.js") - .then((m) => m.McpToolService.launch("mcp-tool")) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/agent/mcp-tool/service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/pdf-decoder.mjs b/ts/entrypoints/pdf-decoder.mjs index a00ed8de..60df1ae5 100644 --- a/ts/entrypoints/pdf-decoder.mjs +++ b/ts/entrypoints/pdf-decoder.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/decoding/pdf-decoder.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/decoding/pdf-decoder.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/prompt.mjs b/ts/entrypoints/prompt.mjs index 4f9984c7..aad588db 100644 --- a/ts/entrypoints/prompt.mjs +++ b/ts/entrypoints/prompt.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/prompt/template.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/prompt/template.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/text-completion-azure-openai.mjs b/ts/entrypoints/text-completion-azure-openai.mjs index 1ad91468..d733941b 100644 --- a/ts/entrypoints/text-completion-azure-openai.mjs +++ b/ts/entrypoints/text-completion-azure-openai.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/model/text-completion/azure-openai.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/model/text-completion/azure-openai.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/text-completion-claude.mjs b/ts/entrypoints/text-completion-claude.mjs index 6f5bec18..eb049cf0 100644 --- a/ts/entrypoints/text-completion-claude.mjs +++ b/ts/entrypoints/text-completion-claude.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/model/text-completion/claude.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/model/text-completion/claude.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/text-completion-mistral.mjs b/ts/entrypoints/text-completion-mistral.mjs index 5073e926..bd677ee4 100644 --- a/ts/entrypoints/text-completion-mistral.mjs +++ b/ts/entrypoints/text-completion-mistral.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/model/text-completion/mistral.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/model/text-completion/mistral.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/text-completion-ollama.mjs b/ts/entrypoints/text-completion-ollama.mjs index 31f4384a..85ac9f3f 100644 --- a/ts/entrypoints/text-completion-ollama.mjs +++ b/ts/entrypoints/text-completion-ollama.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/model/text-completion/ollama.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/model/text-completion/ollama.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/text-completion-openai-compatible.mjs b/ts/entrypoints/text-completion-openai-compatible.mjs index d02e0d85..b51bfdaa 100644 --- a/ts/entrypoints/text-completion-openai-compatible.mjs +++ b/ts/entrypoints/text-completion-openai-compatible.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/model/text-completion/openai-compatible.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/model/text-completion/openai-compatible.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/text-completion-openai.mjs b/ts/entrypoints/text-completion-openai.mjs index 04360884..b0519186 100644 --- a/ts/entrypoints/text-completion-openai.mjs +++ b/ts/entrypoints/text-completion-openai.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/model/text-completion/openai.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/model/text-completion/openai.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/triples-query.mjs b/ts/entrypoints/triples-query.mjs index ac3f7c0b..503056f0 100644 --- a/ts/entrypoints/triples-query.mjs +++ b/ts/entrypoints/triples-query.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/query/triples/falkordb-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/query/triples/falkordb-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/entrypoints/triples-store.mjs b/ts/entrypoints/triples-store.mjs index a0f76cad..3f317358 100644 --- a/ts/entrypoints/triples-store.mjs +++ b/ts/entrypoints/triples-store.mjs @@ -1,5 +1,8 @@ -import("../packages/flow/dist/storage/triples/falkordb-service.js") - .then((m) => m.run()) +import("@effect/platform-bun/BunRuntime") + .then(async (BunRuntime) => { + const m = await import("../packages/flow/dist/storage/triples/falkordb-service.js"); + BunRuntime.runMain(m.program); + }) .catch((err) => { console.error(err); process.exit(1); diff --git a/ts/package.json b/ts/package.json index d5154a96..68859da8 100644 --- a/ts/package.json +++ b/ts/package.json @@ -2,50 +2,62 @@ "name": "trustgraph-ts", "private": true, "scripts": { - "build": "turbo build", - "dev": "turbo dev", - "lint": "turbo lint", - "test": "turbo test", + "build": "bunx --bun turbo build", + "dev": "bunx --bun turbo dev", + "lint": "bunx --bun turbo lint", + "test": "bunx --bun turbo test", + "check:tsgo": "tsgo -b tsconfig.json", + "prepare": "effect-tsgo patch", "clean": "turbo clean", - "gateway": "tsx scripts/run-gateway.ts", - "config-svc": "tsx scripts/run-config.ts", - "llm:claude": "tsx scripts/run-llm-claude.ts", - "llm:openai": "tsx scripts/run-llm-openai.ts", - "test:pipeline": "tsx scripts/test-pipeline.ts", - "seed": "tsx scripts/seed-config.ts", - "prompt": "tsx scripts/run-prompt.ts", - "agent": "tsx scripts/run-agent.ts", - "librarian": "tsx scripts/run-librarian.ts", - "knowledge": "tsx scripts/run-knowledge.ts", - "flow-manager": "tsx scripts/run-flow-manager.ts", - "llm:ollama": "tsx scripts/run-ollama.ts", - "pdf-decoder": "tsx scripts/run-pdf-decoder.ts", - "triples-store": "tsx scripts/run-triples-store.ts", - "graph-embeddings-store": "tsx scripts/run-graph-embeddings-store.ts", - "chunker": "tsx scripts/run-chunker.ts", - "extractor": "tsx scripts/run-extractor.ts", - "embeddings": "tsx scripts/run-embeddings.ts", - "triples-query": "tsx scripts/run-triples-query.ts", - "graph-embeddings-query": "tsx scripts/run-graph-embeddings-query.ts", - "doc-embeddings-query": "tsx scripts/run-doc-embeddings-query.ts", - "graph-rag": "tsx scripts/run-graph-rag.ts", - "document-rag": "tsx scripts/run-document-rag.ts", - "create-test-pdf": "tsx scripts/create-test-pdf.ts", - "seed:demo": "tsx scripts/seed-demo.ts", - "mcp-tool": "tsx scripts/run-mcp-tool.ts", - "llm:azure-openai": "tsx scripts/run-llm-azure-openai.ts", - "llm:openai-compat": "tsx scripts/run-llm-openai-compatible.ts", - "llm:mistral": "tsx scripts/run-llm-mistral.ts" + "gateway": "bun scripts/run-gateway.ts", + "config-svc": "bun scripts/run-config.ts", + "llm:claude": "bun scripts/run-llm-claude.ts", + "llm:openai": "bun scripts/run-llm-openai.ts", + "test:pipeline": "bun scripts/test-pipeline.ts", + "seed": "bun scripts/seed-config.ts", + "prompt": "bun scripts/run-prompt.ts", + "agent": "bun scripts/run-agent.ts", + "librarian": "bun scripts/run-librarian.ts", + "knowledge": "bun scripts/run-knowledge.ts", + "flow-manager": "bun scripts/run-flow-manager.ts", + "llm:ollama": "bun scripts/run-ollama.ts", + "pdf-decoder": "bun scripts/run-pdf-decoder.ts", + "triples-store": "bun scripts/run-triples-store.ts", + "graph-embeddings-store": "bun scripts/run-graph-embeddings-store.ts", + "chunker": "bun scripts/run-chunker.ts", + "extractor": "bun scripts/run-extractor.ts", + "embeddings": "bun scripts/run-embeddings.ts", + "triples-query": "bun scripts/run-triples-query.ts", + "graph-embeddings-query": "bun scripts/run-graph-embeddings-query.ts", + "doc-embeddings-query": "bun scripts/run-doc-embeddings-query.ts", + "graph-rag": "bun scripts/run-graph-rag.ts", + "document-rag": "bun scripts/run-document-rag.ts", + "create-test-pdf": "bun scripts/create-test-pdf.ts", + "seed:demo": "bun scripts/seed-demo.ts", + "mcp-tool": "bun scripts/run-mcp-tool.ts", + "llm:azure-openai": "bun scripts/run-llm-azure-openai.ts", + "llm:openai-compat": "bun scripts/run-llm-openai-compatible.ts", + "llm:mistral": "bun scripts/run-llm-mistral.ts" }, "devDependencies": { + "@effect/platform-bun": "4.0.0-beta.65", + "@effect/tsgo": "0.6.0", + "@effect/vitest": "4.0.0-beta.65", + "@types/bun": "^1.3.13", + "@types/node": "^25.7.0", + "@typescript/native-preview": "^7.0.0-dev.20260511.1", "falkordb": "^5.0.0", "nats": "^2.29.0", "pdf-lib": "^1.17.1", "tsx": "^4.21.0", "turbo": "^2.5.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^4.1.6" }, - "packageManager": "pnpm@9.15.0", + "dependencies": { + "effect": "4.0.0-beta.65" + }, + "packageManager": "bun@1.3.13", "workspaces": [ "packages/*" ] diff --git a/ts/packages/base/package.json b/ts/packages/base/package.json index 1b3c1c1c..9ce93ab0 100644 --- a/ts/packages/base/package.json +++ b/ts/packages/base/package.json @@ -4,19 +4,30 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./backend": "./src/backend/index.ts", + "./messaging": "./src/messaging/index.ts", + "./processor": "./src/processor/index.ts", + "./runtime": "./src/runtime/index.ts", + "./schema": "./src/schema/index.ts", + "./package.json": "./package.json" + }, "scripts": { - "build": "tsc", + "build": "bunx --bun tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "test": "vitest run" + "test": "bunx --bun vitest run" }, "dependencies": { + "effect": "4.0.0-beta.65", "nats": "^2.29.0", "prom-client": "^15.1.0" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0" + "vitest": "^4.1.6" } } diff --git a/ts/packages/base/src/__tests__/consumer.test.ts b/ts/packages/base/src/__tests__/consumer.test.ts index f1ba223c..6c650ba3 100644 --- a/ts/packages/base/src/__tests__/consumer.test.ts +++ b/ts/packages/base/src/__tests__/consumer.test.ts @@ -8,7 +8,7 @@ import type { CreateProducerOptions, CreateConsumerOptions, } from "../backend/types.js"; -import { TooManyRequestsError } from "../errors.js"; +import { tooManyRequestsError } from "../errors.js"; import type { Flow } from "../processor/flow.js"; // ── Mock Message ────────────────────────────────────────────────────── @@ -202,7 +202,7 @@ describe("Consumer", () => { const handler = vi.fn().mockImplementation(async () => { handlerCalls++; if (handlerCalls === 1) { - throw new TooManyRequestsError("rate limited"); + throw tooManyRequestsError("rate limited"); } // Second call succeeds }); diff --git a/ts/packages/base/src/__tests__/embeddings-service.test.ts b/ts/packages/base/src/__tests__/embeddings-service.test.ts new file mode 100644 index 00000000..d6989fdc --- /dev/null +++ b/ts/packages/base/src/__tests__/embeddings-service.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Fiber } from "effect"; +import { + Embeddings, + EmbeddingsService, + MessagingRuntimeLive, + PubSub, + embeddingsError, + runProcessorScoped, + topics, + type BackendConsumer, + type BackendProducer, + type CreateConsumerOptions, + type CreateProducerOptions, + type EmbeddingsRequest, + type EmbeddingsResponse, + type Message, + type PubSubBackend, +} from "../index.js"; + +function createMessage(value: T, properties: Record = {}): Message { + return { + value: () => value, + properties: () => properties, + }; +} + +const waitFor = (condition: () => boolean, label: string) => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const deadline = Date.now() + 1000; + const check = () => { + if (condition()) { + resolve(); + return; + } + if (Date.now() > deadline) { + reject(new Error(`Timed out waiting for ${label}`)); + return; + } + setTimeout(check, 5); + }; + check(); + }), + catch: (error) => error, + }); + +class RecordingProducer implements BackendProducer { + readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; + + async send(message: T, properties?: Record): Promise { + this.sent.push(properties === undefined ? { message } : { message, properties }); + } + + async flush(): Promise {} + + async close(): Promise {} +} + +class PushConsumer implements BackendConsumer { + readonly acknowledged: Array> = []; + readonly nacked: Array> = []; + private readonly messages: Array> = []; + private readonly waiters: Array<(message: Message | null) => void> = []; + private closed = false; + + push(message: Message): void { + const waiter = this.waiters.shift(); + if (waiter !== undefined) { + waiter(message); + return; + } + this.messages.push(message); + } + + async receive(): Promise | null> { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return message ?? null; + } + return await new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + async acknowledge(message: Message): Promise { + this.acknowledged.push(message); + } + + async negativeAcknowledge(message: Message): Promise { + this.nacked.push(message); + } + + async unsubscribe(): Promise {} + + async close(): Promise { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter(null); + } + } +} + +class EmbeddingsBackend implements PubSubBackend { + readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record }>(); + readonly consumersByTopic = new Map>(); + readonly producersByTopic = new Map>(); + closeCount = 0; + + async createProducer(options: CreateProducerOptions): Promise> { + const producer = new RecordingProducer(); + this.producersByTopic.set(options.topic, producer); + return producer as BackendProducer; + } + + async createConsumer(options: CreateConsumerOptions): Promise> { + if (options.topic === topics.configPush) { + return this.configConsumer as unknown as BackendConsumer; + } + const consumer = new PushConsumer(); + this.consumersByTopic.set(options.topic, consumer); + return consumer as BackendConsumer; + } + + async close(): Promise { + this.closeCount += 1; + } + + pushConfig(): void { + this.configConsumer.push( + createMessage({ + version: 1, + config: { + flows: { + default: { + topics: { + "embeddings-request": "embeddings-request-topic", + "embeddings-response": "embeddings-response-topic", + }, + }, + }, + }, + }), + ); + } +} + +const fastMessagingConfig = ConfigProvider.layer( + ConfigProvider.fromEnv({ + TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1", + TG_CONSUMER_ERROR_BACKOFF_MS: "1", + TG_RATE_LIMIT_RETRY_MS: "1", + TG_REQUEST_TIMEOUT_MS: "250", + }), +); + +describe("EmbeddingsService", () => { + it.effect( + "handles embeddings requests through the Embeddings Context service", + Effect.fnUntraced(function* () { + const backend = new EmbeddingsBackend(); + const embeddingCalls: Array<{ readonly texts: ReadonlyArray; readonly model?: string }> = []; + const embeddings = Embeddings.of({ + embed: Effect.fn("TestEmbeddings.embed")((texts: ReadonlyArray, model?: string) => { + embeddingCalls.push(model === undefined ? { texts } : { texts, model }); + return Effect.succeed(texts.map((text, index) => [text.length, model?.length ?? 0, index])); + }), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* runService(backend, embeddings).pipe(Effect.forkChild); + + backend.pushConfig(); + yield* waitFor(() => backend.consumersByTopic.has("embeddings-request-topic"), "embeddings consumer"); + yield* waitFor(() => backend.producersByTopic.has("embeddings-response-topic"), "embeddings producer"); + + const input = backend.consumersByTopic.get("embeddings-request-topic") as PushConsumer; + const output = backend.producersByTopic.get("embeddings-response-topic") as RecordingProducer; + + input.push(createMessage({ text: ["alpha", "beta"], model: "model-a" }, { id: "request-1" })); + yield* waitFor(() => output.sent.length === 1, "embeddings response"); + + expect(embeddingCalls).toEqual([{ texts: ["alpha", "beta"], model: "model-a" }]); + expect(output.sent).toEqual([ + { + message: { vectors: [[5, 7, 0], [4, 7, 1]] }, + properties: { id: "request-1" }, + }, + ]); + expect(input.acknowledged.length).toBe(1); + expect(input.nacked).toEqual([]); + + yield* Fiber.interrupt(fiber); + }), + ); + + expect(backend.closeCount).toBe(1); + }), + ); + + it.effect( + "returns a wire error response when the Embeddings service fails", + Effect.fnUntraced(function* () { + const backend = new EmbeddingsBackend(); + const embeddings = Embeddings.of({ + embed: Effect.fn("FailingEmbeddings.embed")(() => + Effect.fail(embeddingsError("test.embed", new Error("provider unavailable"), "test")), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* runService(backend, embeddings).pipe(Effect.forkChild); + + backend.pushConfig(); + yield* waitFor(() => backend.consumersByTopic.has("embeddings-request-topic"), "embeddings consumer"); + yield* waitFor(() => backend.producersByTopic.has("embeddings-response-topic"), "embeddings producer"); + + const input = backend.consumersByTopic.get("embeddings-request-topic") as PushConsumer; + const output = backend.producersByTopic.get("embeddings-response-topic") as RecordingProducer; + + input.push(createMessage({ text: ["alpha"] }, { id: "request-1" })); + yield* waitFor(() => output.sent.length === 1, "embeddings error response"); + + expect(output.sent).toEqual([ + { + message: { + vectors: [], + error: { + type: "embeddings-error", + message: "provider unavailable", + }, + }, + properties: { id: "request-1" }, + }, + ]); + expect(input.acknowledged.length).toBe(1); + expect(input.nacked).toEqual([]); + + yield* Fiber.interrupt(fiber); + }), + ); + }), + ); +}); + +const runService = ( + backend: EmbeddingsBackend, + embeddings: Embeddings, +) => + runProcessorScoped( + { + id: "embeddings", + pubsubUrl: "nats://unused:4222", + metricsPort: 8000, + manageProcessSignals: true, + }, + (config) => new EmbeddingsService(config), + ).pipe( + Effect.provideService(Embeddings, embeddings), + Effect.provide(MessagingRuntimeLive), + Effect.provide(PubSub.layer(backend)), + Effect.provide(fastMessagingConfig), + ); diff --git a/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts b/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts new file mode 100644 index 00000000..5a909b35 --- /dev/null +++ b/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Fiber } from "effect"; +import { + FlowProcessor, + MessagingRuntimeLive, + ProducerSpec, + PubSub, + runProcessorScoped, + topics, + type BackendConsumer, + type BackendProducer, + type CreateConsumerOptions, + type CreateProducerOptions, + type Message, + type ProcessorConfig, + type PubSubBackend, +} from "../index.js"; + +function createMessage(value: T, properties: Record = {}): Message { + return { + value: () => value, + properties: () => properties, + }; +} + +const waitFor = (condition: () => boolean, label: string) => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const deadline = Date.now() + 1000; + const check = () => { + if (condition()) { + resolve(); + return; + } + if (Date.now() > deadline) { + reject(new Error(`Timed out waiting for ${label}`)); + return; + } + setTimeout(check, 5); + }; + check(); + }), + catch: (error) => error, + }); + +class RecordingProducer implements BackendProducer { + readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; + closeCount = 0; + flushCount = 0; + + async send(message: T, properties?: Record): Promise { + this.sent.push(properties === undefined ? { message } : { message, properties }); + } + + async flush(): Promise { + this.flushCount += 1; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +class PushConsumer implements BackendConsumer { + readonly acknowledged: Array> = []; + readonly nacked: Array> = []; + closeCount = 0; + private readonly messages: Array> = []; + private readonly waiters: Array<(message: Message | null) => void> = []; + private closed = false; + + push(message: Message): void { + const waiter = this.waiters.shift(); + if (waiter !== undefined) { + waiter(message); + return; + } + this.messages.push(message); + } + + async receive(): Promise | null> { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return message ?? null; + } + return await new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + async acknowledge(message: Message): Promise { + this.acknowledged.push(message); + } + + async negativeAcknowledge(message: Message): Promise { + this.nacked.push(message); + } + + async unsubscribe(): Promise {} + + async close(): Promise { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter(null); + } + this.closeCount += 1; + } +} + +class FlowProcessorBackend implements PubSubBackend { + readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record }>(); + readonly producerOptions: Array = []; + readonly consumerOptions: Array = []; + readonly producers: Array> = []; + closeCount = 0; + + async createProducer(options: CreateProducerOptions): Promise> { + this.producerOptions.push(options); + const producer = new RecordingProducer(); + this.producers.push(producer); + return producer as BackendProducer; + } + + async createConsumer(options: CreateConsumerOptions): Promise> { + this.consumerOptions.push(options); + if (options.topic === topics.configPush) { + return this.configConsumer as unknown as BackendConsumer; + } + return new PushConsumer(); + } + + async close(): Promise { + this.closeCount += 1; + } + + pushConfig(version: number, flows: Record): void { + this.configConsumer.push(createMessage({ version, config: { flows } })); + } +} + +class TestFlowProcessor extends FlowProcessor { + constructor( + config: ProcessorConfig, + private readonly events: Array, + ) { + super(config); + this.registerSpecification(new ProducerSpec("output")); + this.registerConfigHandler(async (_config, version) => { + this.events.push(`handler:${version}`); + }); + } +} + +const fastMessagingConfig = ConfigProvider.layer( + ConfigProvider.fromEnv({ + TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1", + TG_CONSUMER_ERROR_BACKOFF_MS: "1", + TG_RATE_LIMIT_RETRY_MS: "1", + TG_REQUEST_TIMEOUT_MS: "250", + }), +); + +describe("Effect-native FlowProcessor runtime", () => { + it.effect( + "starts, restarts, and removes flow scopes from config pushes", + Effect.fnUntraced(function* () { + const backend = new FlowProcessorBackend(); + const events: Array = []; + + yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* runProcessorScoped( + { + id: "flow-processor-test", + pubsubUrl: "nats://unused:4222", + metricsPort: 8000, + manageProcessSignals: true, + }, + (config) => new TestFlowProcessor(config, events), + ).pipe( + Effect.provide(MessagingRuntimeLive), + Effect.provide(PubSub.layer(backend)), + Effect.provide(fastMessagingConfig), + Effect.forkChild, + ); + + yield* waitFor(() => backend.consumerOptions.length === 1, "config subscription"); + + backend.pushConfig(1, { default: { topics: { output: "topic-a" } } }); + yield* waitFor(() => backend.producers.length === 1, "first flow producer"); + yield* waitFor(() => backend.configConsumer.acknowledged.length === 1, "first config ack"); + + backend.pushConfig(2, { default: { topics: { output: "topic-a" } } }); + yield* waitFor(() => backend.configConsumer.acknowledged.length === 2, "unchanged config ack"); + expect(backend.producers.length).toBe(1); + + backend.pushConfig(3, { default: { topics: { output: "topic-b" } } }); + yield* waitFor(() => backend.producers.length === 2, "restarted flow producer"); + yield* waitFor(() => backend.producers[0]?.closeCount === 1, "old flow close"); + + backend.pushConfig(4, {}); + yield* waitFor(() => backend.producers[1]?.closeCount === 1, "removed flow close"); + + yield* Fiber.interrupt(fiber); + }), + ); + + expect(backend.producerOptions.map((options) => options.topic)).toEqual(["topic-a", "topic-b"]); + expect(events).toEqual(["handler:1", "handler:2", "handler:3", "handler:4"]); + expect(backend.configConsumer.closeCount).toBeGreaterThanOrEqual(1); + expect(backend.closeCount).toBe(1); + }), + ); +}); diff --git a/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts b/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts new file mode 100644 index 00000000..5df40a30 --- /dev/null +++ b/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Duration, Effect, Fiber } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { + ConsumerSpec, + Flow, + MessagingRuntimeLive, + ParameterSpec, + ProducerSpec, + PubSub, + RequestResponseSpec, + type BackendConsumer, + type BackendProducer, + type CreateConsumerOptions, + type CreateProducerOptions, + type FlowContext, + type Message, + type PubSubBackend, +} from "../index.js"; + +function createMessage(value: T, properties: Record = {}): Message { + return { + value: () => value, + properties: () => properties, + }; +} + +class RecordingProducer implements BackendProducer { + readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; + closeCount = 0; + flushCount = 0; + + constructor(private readonly onSend?: (message: T, properties?: Record) => void) {} + + async send(message: T, properties?: Record): Promise { + this.sent.push(properties === undefined ? { message } : { message, properties }); + this.onSend?.(message, properties); + } + + async flush(): Promise { + this.flushCount += 1; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +class ScriptedConsumer implements BackendConsumer { + readonly acknowledged: Array> = []; + readonly nacked: Array> = []; + closeCount = 0; + private readonly messages: Array>; + private readonly waiters: Array<(message: Message | null) => void> = []; + private closed = false; + + constructor( + messages: Array> = [], + private readonly waitForMessages = false, + ) { + this.messages = messages; + } + + push(message: Message): void { + const waiter = this.waiters.shift(); + if (waiter !== undefined) { + waiter(message); + return; + } + this.messages.push(message); + } + + async receive(): Promise | null> { + const message = this.messages.shift(); + if (message !== undefined || !this.waitForMessages || this.closed) { + return message ?? null; + } + return await new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + async acknowledge(message: Message): Promise { + this.acknowledged.push(message); + } + + async negativeAcknowledge(message: Message): Promise { + this.nacked.push(message); + } + + async unsubscribe(): Promise {} + + async close(): Promise { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter(null); + } + this.closeCount += 1; + } +} + +class RuntimeBackend implements PubSubBackend { + closeCount = 0; + producerOptions: CreateProducerOptions | null = null; + consumerOptions: CreateConsumerOptions | null = null; + readonly producer: RecordingProducer; + + constructor( + private readonly consumer: BackendConsumer, + onSend?: (message: unknown, properties?: Record) => void, + ) { + this.producer = new RecordingProducer(onSend); + } + + async createProducer(options: CreateProducerOptions): Promise> { + this.producerOptions = options; + return this.producer as BackendProducer; + } + + async createConsumer(options: CreateConsumerOptions): Promise> { + this.consumerOptions = options; + return this.consumer as BackendConsumer; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +const fastMessagingConfig = ConfigProvider.layer( + ConfigProvider.fromEnv({ + TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1", + TG_CONSUMER_ERROR_BACKOFF_MS: "1", + TG_RATE_LIMIT_RETRY_MS: "1", + TG_REQUEST_TIMEOUT_MS: "250", + }), +); + +const provideRuntime = ( + backend: RuntimeBackend, + effect: Effect.Effect, +) => + effect.pipe( + Effect.provide(MessagingRuntimeLive), + Effect.provideService(PubSub, PubSub.fromBackend(backend)), + Effect.provide(fastMessagingConfig), + ); + +describe("Effect-native flow specifications", () => { + it.effect( + "starts producer specs through Effect factories and exposes typed accessors", + Effect.fnUntraced(function* () { + const backend = new RuntimeBackend(new ScriptedConsumer()); + const flow = new Flow( + "default", + "processor", + backend, + { topics: { output: "actual-output" } }, + [new ProducerSpec("output")], + ); + + yield* Effect.scoped( + provideRuntime( + backend, + Effect.gen(function* () { + yield* flow.startEffect(); + const producer = yield* flow.producerEffect("output"); + yield* producer.send("request-1", "hello"); + }), + ), + ); + + expect(backend.producerOptions).toEqual({ topic: "actual-output" }); + expect(backend.producer.sent).toEqual([ + { message: "hello", properties: { id: "request-1" } }, + ]); + expect(backend.producer.closeCount).toBe(1); + }), + ); + + it.effect( + "runs Promise handlers through the explicit ConsumerSpec compatibility helper", + Effect.fnUntraced(function* () { + const message = createMessage("payload", { id: "request-1" }); + const consumer = new ScriptedConsumer([message]); + const backend = new RuntimeBackend(consumer as BackendConsumer); + const handled: Array = []; + const flow = new Flow( + "default", + "processor", + backend, + {}, + [ + ConsumerSpec.fromPromise( + "input", + async (value, properties, flowContext: FlowContext) => { + handled.push(`${flowContext.name}:${properties.id}:${value}`); + }, + ), + ], + ); + + yield* Effect.scoped( + provideRuntime( + backend, + Effect.gen(function* () { + yield* flow.startEffect(); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(5)); + }), + ), + ); + + expect(consumer.acknowledged).toEqual([message]); + expect(consumer.nacked).toEqual([]); + expect(handled).toEqual(["default:request-1:payload"]); + }), + ); + + it.effect( + "registers request-response specs through Effect queues and keeps the Promise facade working", + Effect.fnUntraced(function* () { + const responseConsumer = new ScriptedConsumer([], true); + const backend = new RuntimeBackend( + responseConsumer as BackendConsumer, + (_message, properties) => { + responseConsumer.push(createMessage("response", { id: properties?.id ?? "" })); + }, + ); + const flow = new Flow( + "default", + "processor", + backend, + { + topics: { + request: "actual-request", + response: "actual-response", + }, + }, + [new RequestResponseSpec("rr", "request", "response")], + ); + + const response = yield* Effect.scoped( + provideRuntime( + backend, + Effect.gen(function* () { + yield* flow.startEffect(); + const requestor = flow.requestor("rr"); + const fiber = yield* Effect.promise(() => + requestor.request("request", { timeoutMs: 250 }), + ).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.millis(5)); + return yield* Fiber.join(fiber); + }), + ), + ); + + expect(response).toBe("response"); + expect(backend.producerOptions).toEqual({ topic: "actual-request" }); + expect(responseConsumer.acknowledged.length).toBe(1); + }), + ); + + it.effect( + "returns typed errors for missing flow resources", + Effect.fnUntraced(function* () { + const backend = new RuntimeBackend(new ScriptedConsumer()); + const flow = new Flow( + "default", + "processor", + backend, + { parameters: { present: 42 } }, + [new ParameterSpec("present")], + ); + + const errors = yield* Effect.scoped( + provideRuntime( + backend, + Effect.gen(function* () { + yield* flow.startEffect(); + const producerError = yield* flow.producerEffect("missing-producer").pipe(Effect.flip); + const parameter = yield* flow.parameterEffect("present"); + const parameterError = yield* flow.parameterEffect("missing-parameter").pipe(Effect.flip); + return { producerError, parameter, parameterError }; + }), + ), + ); + + expect(errors.parameter).toBe(42); + expect(errors.producerError._tag).toBe("FlowResourceNotFoundError"); + expect(errors.producerError.resourceType).toBe("producer"); + expect(errors.producerError.resourceName).toBe("missing-producer"); + expect(errors.parameterError._tag).toBe("FlowResourceNotFoundError"); + expect(errors.parameterError.resourceType).toBe("parameter"); + expect(() => flow.producer("missing-producer")).toThrow("not found"); + }), + ); +}); diff --git a/ts/packages/base/src/__tests__/messaging-runtime.test.ts b/ts/packages/base/src/__tests__/messaging-runtime.test.ts new file mode 100644 index 00000000..81570fd8 --- /dev/null +++ b/ts/packages/base/src/__tests__/messaging-runtime.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, Fiber } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { + PubSub, + defaultMessagingRuntimeConfig, + makeEffectRequestResponseFromPubSub, + MessagingRuntimeLive, + ProducerSpec, + runEffectConsumerScoped, + runEffectProducerScoped, + runFlowScoped, + type BackendConsumer, + type BackendProducer, + type CreateConsumerOptions, + type CreateProducerOptions, + type FlowContext, + type Message, + type PubSubBackend, +} from "../index.js"; +import type { Flow } from "../processor/flow.js"; +import { Flow as RuntimeFlow } from "../processor/flow.js"; + +function createMessage(value: T, properties: Record = {}): Message { + return { + value: () => value, + properties: () => properties, + }; +} + +class RecordingProducer implements BackendProducer { + readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; + closeCount = 0; + flushCount = 0; + + constructor(private readonly onSend?: (message: T, properties?: Record) => void) {} + + async send(message: T, properties?: Record): Promise { + this.sent.push(properties === undefined ? { message } : { message, properties }); + this.onSend?.(message, properties); + } + + async flush(): Promise { + this.flushCount += 1; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +class ScriptedConsumer implements BackendConsumer { + readonly acknowledged: Array> = []; + readonly nacked: Array> = []; + closeCount = 0; + private readonly messages: Array>; + + constructor(messages: Array> = []) { + this.messages = messages; + } + + push(message: Message): void { + this.messages.push(message); + } + + async receive(): Promise | null> { + const message = this.messages.shift(); + if (message !== undefined) { + return message; + } + return null; + } + + async acknowledge(message: Message): Promise { + this.acknowledged.push(message); + } + + async negativeAcknowledge(message: Message): Promise { + this.nacked.push(message); + } + + async unsubscribe(): Promise {} + + async close(): Promise { + this.closeCount += 1; + } +} + +class RuntimeBackend implements PubSubBackend { + closeCount = 0; + producerOptions: CreateProducerOptions | null = null; + consumerOptions: CreateConsumerOptions | null = null; + readonly producer: RecordingProducer; + + constructor( + private readonly consumer: BackendConsumer, + onSend?: (message: unknown, properties?: Record) => void, + ) { + this.producer = new RecordingProducer(onSend); + } + + async createProducer(options: CreateProducerOptions): Promise> { + this.producerOptions = options; + return this.producer as BackendProducer; + } + + async createConsumer(options: CreateConsumerOptions): Promise> { + this.consumerOptions = options; + return this.consumer as BackendConsumer; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +const flowContext: FlowContext = { + id: "processor", + name: "default", + flow: {} as Flow, +}; + +describe("Effect-native messaging runtime", () => { + it.effect( + "creates scoped producers through PubSub and translates send calls", + Effect.fnUntraced(function* () { + const consumer = new ScriptedConsumer(); + const backend = new RuntimeBackend(consumer); + + yield* Effect.scoped( + Effect.gen(function* () { + const producer = yield* runEffectProducerScoped({ topic: "tg.test.producer" }); + yield* producer.send("message-1", "hello"); + + expect(backend.producerOptions).toEqual({ topic: "tg.test.producer" }); + expect(backend.producer.sent).toEqual([ + { message: "hello", properties: { id: "message-1" } }, + ]); + }).pipe(Effect.provide(PubSub.layer(backend))), + ); + + expect(backend.producer.closeCount).toBe(1); + expect(backend.closeCount).toBe(1); + }), + ); + + it.effect( + "runs consumers as scoped fibers and acknowledges handled messages", + Effect.fnUntraced(function* () { + const message = createMessage("payload", { id: "request-1" }); + const consumer = new ScriptedConsumer([message]); + const backend = new RuntimeBackend(consumer as BackendConsumer); + const handled: Array = []; + + yield* Effect.scoped( + Effect.gen(function* () { + yield* runEffectConsumerScoped( + { + topic: "tg.test.consumer", + subscription: "sub", + receiveTimeoutMs: 1, + errorBackoffMs: 1, + handler: (value, properties) => + Effect.sync(() => { + handled.push(`${properties.id}:${value}`); + }), + }, + flowContext, + ); + yield* TestClock.adjust(Duration.millis(20)); + }).pipe(Effect.provide(PubSub.layer(backend))), + ); + + expect(handled).toEqual(["request-1:payload"]); + expect(consumer.acknowledged).toEqual([message]); + expect(consumer.nacked).toEqual([]); + expect(consumer.closeCount).toBeGreaterThan(0); + }), + ); + + it.effect( + "routes request-response replies through an Effect queue", + Effect.fnUntraced(function* () { + const responseConsumer = new ScriptedConsumer(); + const backend = new RuntimeBackend( + responseConsumer as BackendConsumer, + (_message, properties) => { + responseConsumer.push(createMessage("response", { id: properties?.id ?? "" })); + }, + ); + + const response = yield* Effect.scoped( + Effect.gen(function* () { + const requestor = yield* makeEffectRequestResponseFromPubSub( + PubSub.fromBackend(backend), + { + ...defaultMessagingRuntimeConfig, + consumerReceiveTimeoutMs: 1, + }, + { + requestTopic: "tg.test.request", + responseTopic: "tg.test.response", + subscription: "sub", + }, + ); + const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.millis(5)); + return yield* Fiber.join(fiber); + }), + ); + + expect(response).toBe("response"); + expect(backend.producer.sent[0]?.message).toBe("request"); + expect(responseConsumer.acknowledged.length).toBe(1); + }), + ); + + it.effect( + "fails request-response calls with a typed timeout", + Effect.fnUntraced(function* () { + const responseConsumer = new ScriptedConsumer(); + const backend = new RuntimeBackend(responseConsumer as BackendConsumer); + + const error = yield* Effect.scoped( + Effect.gen(function* () { + const requestor = yield* makeEffectRequestResponseFromPubSub( + PubSub.fromBackend(backend), + { + ...defaultMessagingRuntimeConfig, + consumerReceiveTimeoutMs: 1, + }, + { + requestTopic: "tg.test.request", + responseTopic: "tg.test.response", + subscription: "sub", + }, + ); + const fiber = yield* requestor.request("request", { timeoutMs: 5 }).pipe( + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.millis(10)); + return yield* Fiber.join(fiber); + }), + ); + + expect(error._tag).toBe("MessagingTimeoutError"); + expect(error.operation).toBe("request-response"); + expect(error.timeoutMs).toBe(5); + }), + ); + + it.effect( + "owns Flow lifecycle through a scoped Effect boundary", + Effect.fnUntraced(function* () { + const consumer = new ScriptedConsumer(); + const backend = new RuntimeBackend(consumer); + const flow = new RuntimeFlow( + "flow-a", + "processor", + backend, + {}, + [new ProducerSpec("flow-output")], + ); + + yield* Effect.scoped( + runFlowScoped(flow).pipe( + Effect.provide(MessagingRuntimeLive), + Effect.provideService(PubSub, PubSub.fromBackend(backend)), + ), + ); + + expect(backend.producerOptions).toEqual({ topic: "flow-output" }); + expect(backend.producer.closeCount).toBe(1); + }), + ); +}); diff --git a/ts/packages/base/src/__tests__/runtime-services.test.ts b/ts/packages/base/src/__tests__/runtime-services.test.ts new file mode 100644 index 00000000..86aadba8 --- /dev/null +++ b/ts/packages/base/src/__tests__/runtime-services.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { + AsyncProcessor, + PubSub, + runProcessorScoped, + type BackendConsumer, + type BackendProducer, + type CreateConsumerOptions, + type CreateProducerOptions, + type Message, + type ProcessorConfig, + type PubSubBackend, +} from "../index.js"; + +class FakeProducer implements BackendProducer { + readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; + closeCount = 0; + flushCount = 0; + + async send(message: T, properties?: Record): Promise { + this.sent.push( + properties === undefined + ? { message } + : { message, properties }, + ); + } + + async flush(): Promise { + this.flushCount += 1; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +class FakeConsumer implements BackendConsumer { + closeCount = 0; + + async receive(): Promise | null> { + return null; + } + + async acknowledge(): Promise {} + + async negativeAcknowledge(): Promise {} + + async unsubscribe(): Promise {} + + async close(): Promise { + this.closeCount += 1; + } +} + +class FakePubSubBackend implements PubSubBackend { + closeCount = 0; + producerOptions: CreateProducerOptions | null = null; + consumerOptions: CreateConsumerOptions | null = null; + + async createProducer(options: CreateProducerOptions): Promise> { + this.producerOptions = options; + return new FakeProducer(); + } + + async createConsumer(options: CreateConsumerOptions): Promise> { + this.consumerOptions = options; + return new FakeConsumer(); + } + + async close(): Promise { + this.closeCount += 1; + } +} + +class FailingProducerBackend extends FakePubSubBackend { + override async createProducer(): Promise> { + throw new Error("producer unavailable"); + } +} + +class RecordingProcessor extends AsyncProcessor { + constructor( + config: ProcessorConfig, + private readonly events: Array, + ) { + super(config); + } + + protected async run(): Promise { + this.events.push(`run:${this.config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`); + } + + override async stop(): Promise { + this.events.push("stop"); + await super.stop(); + } +} + +class FailingProcessor extends AsyncProcessor { + protected async run(): Promise { + throw new Error("processor failed"); + } +} + +class NativeRecordingProcessor extends AsyncProcessor { + constructor( + config: ProcessorConfig, + private readonly events: Array, + ) { + super(config); + } + + protected override runEffect() { + const events = this.events; + const config = this.config; + return Effect.gen(function* () { + const pubsub = yield* PubSub; + events.push(`native:${config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`); + events.push(`pubsub:${pubsub.backend.constructor.name}`); + }); + } + + override stopEffect() { + this.events.push("native-stop"); + return super.stopEffect(); + } +} + +describe("Effect runtime services", () => { + it.effect( + "provides a compatibility backend through the PubSub service", + Effect.fnUntraced(function* () { + const backend = new FakePubSubBackend(); + + yield* Effect.scoped( + Effect.gen(function* () { + const pubsub = yield* PubSub; + const producer = yield* pubsub.createProducer({ topic: "tg.test.topic" }); + yield* Effect.promise(() => producer.send("hello", { id: "1" })); + + expect(backend.producerOptions).toEqual({ topic: "tg.test.topic" }); + expect(pubsub.backend).toBe(backend); + }).pipe(Effect.provide(PubSub.layer(backend))), + ); + + expect(backend.closeCount).toBe(1); + }), + ); + + it.effect( + "maps backend failures into PubSubError", + Effect.fnUntraced(function* () { + const backend = new FailingProducerBackend(); + + const error = yield* Effect.scoped( + Effect.gen(function* () { + const pubsub = yield* PubSub; + return yield* pubsub.createProducer({ topic: "tg.test.failure" }).pipe(Effect.flip); + }).pipe(Effect.provide(PubSub.layer(backend))), + ); + + expect(error._tag).toBe("PubSubError"); + expect(error.operation).toBe("createProducer:tg.test.failure"); + expect(error.message).toBe("producer unavailable"); + }), + ); + + it.effect( + "runs a processor with injected PubSub and scoped finalization", + Effect.fnUntraced(function* () { + const backend = new FakePubSubBackend(); + const events: Array = []; + + yield* Effect.scoped( + runProcessorScoped( + { + id: "recording", + pubsubUrl: "nats://unused:4222", + metricsPort: 8000, + manageProcessSignals: true, + }, + (config) => new RecordingProcessor(config, events), + ).pipe(Effect.provide(PubSub.layer(backend))), + ); + + expect(events).toEqual(["run:effect-signals", "stop"]); + expect(backend.closeCount).toBe(1); + }), + ); + + it.effect( + "runs native processor lifecycle hooks with Effect requirements", + Effect.fnUntraced(function* () { + const backend = new FakePubSubBackend(); + const events: Array = []; + + yield* Effect.scoped( + runProcessorScoped( + { + id: "native-recording", + pubsubUrl: "nats://unused:4222", + metricsPort: 8000, + manageProcessSignals: true, + }, + (config) => new NativeRecordingProcessor(config, events), + ).pipe(Effect.provide(PubSub.layer(backend))), + ); + + expect(events).toEqual(["native:effect-signals", "pubsub:FakePubSubBackend", "native-stop"]); + expect(backend.closeCount).toBe(1); + }), + ); + + it.effect( + "maps processor start failures into ProcessorLifecycleError", + Effect.fnUntraced(function* () { + const backend = new FakePubSubBackend(); + + const error = yield* Effect.scoped( + runProcessorScoped( + { + id: "failing", + metricsPort: 8000, + manageProcessSignals: true, + }, + (config) => new FailingProcessor(config), + ).pipe( + Effect.provide(PubSub.layer(backend)), + Effect.flip, + ), + ); + + expect(error._tag).toBe("ProcessorLifecycleError"); + expect(error.operation).toBe("start"); + expect(error.processorId).toBe("failing"); + expect(error.message).toBe("processor failed"); + }), + ); +}); diff --git a/ts/packages/base/src/__tests__/schema-effect.test.ts b/ts/packages/base/src/__tests__/schema-effect.test.ts new file mode 100644 index 00000000..8b868cbb --- /dev/null +++ b/ts/packages/base/src/__tests__/schema-effect.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; +import * as S from "effect/Schema"; +import { + ConfigRequest, + GraphRagResponse, + Term, + TextCompletionRequest, + loadProcessorRuntimeConfig, +} from "../index.js"; + +describe("Effect schemas", () => { + it.effect( + "decode existing text-completion wire payloads", + Effect.fnUntraced(function* () { + const request = yield* S.decodeUnknownEffect(TextCompletionRequest)({ + system: "system", + prompt: "hello", + streaming: true, + }); + + expect(request.prompt).toBe("hello"); + expect(request.streaming).toBe(true); + }), + ); + + it.effect( + "decode recursive RDF terms", + Effect.fnUntraced(function* () { + const term = yield* S.decodeUnknownEffect(Term)({ + type: "TRIPLE", + triple: { + s: { type: "IRI", iri: "urn:s" }, + p: { type: "IRI", iri: "urn:p" }, + o: { type: "LITERAL", value: "object" }, + }, + }); + + expect(term.type).toBe("TRIPLE"); + }), + ); + + it.effect( + "preserve gateway response extension fields", + Effect.fnUntraced(function* () { + const response = yield* S.decodeUnknownEffect(GraphRagResponse)({ + response: "ok", + message_type: "explain", + explain_id: "e1", + providerTrace: { kept: true }, + }); + + expect(response.providerTrace).toEqual({ kept: true }); + }), + ); + + it.effect( + "decode config requests", + Effect.fnUntraced(function* () { + const request = yield* S.decodeUnknownEffect(ConfigRequest)({ + operation: "put", + keys: ["flows"], + values: { default: { topics: {} } }, + }); + + expect(request.operation).toBe("put"); + }), + ); +}); + +describe("Effect runtime config", () => { + it.effect( + "loads processor settings from existing env names", + Effect.fnUntraced(function* () { + const provider = ConfigProvider.fromEnv({ + env: { + NATS_URL: "nats://example:4222", + METRICS_PORT: "9000", + }, + }); + + const config = yield* Effect.provide( + loadProcessorRuntimeConfig("svc", { manageProcessSignals: false }), + ConfigProvider.layer(provider), + ); + + expect(config).toEqual({ + id: "svc", + pubsubUrl: "nats://example:4222", + metricsPort: 9000, + manageProcessSignals: false, + }); + }), + ); +}); diff --git a/ts/packages/base/src/backend/index.ts b/ts/packages/base/src/backend/index.ts index d2c5b714..8ce6264b 100644 --- a/ts/packages/base/src/backend/index.ts +++ b/ts/packages/base/src/backend/index.ts @@ -10,3 +10,11 @@ export type { } from "./types.js"; export { NatsBackend } from "./nats.js"; +export { + PubSub, + NatsPubSubLive, + makeNatsPubSubLayer, + makePubSubService, + pubSubLayer, + type PubSubService, +} from "./pubsub.js"; diff --git a/ts/packages/base/src/backend/nats.ts b/ts/packages/base/src/backend/nats.ts index ff621aaf..e1d060e0 100644 --- a/ts/packages/base/src/backend/nats.ts +++ b/ts/packages/base/src/backend/nats.ts @@ -19,6 +19,7 @@ import { AckPolicy, DeliverPolicy, } from "nats"; +import * as S from "effect/Schema"; import type { PubSubBackend, @@ -34,12 +35,11 @@ const sc = StringCodec(); class NatsMessage implements Message { /** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */ readonly _jsMsg: JsMsg; + private readonly decoded: T; - constructor( - msg: JsMsg, - private readonly decoded: T, - ) { + constructor(msg: JsMsg, decoded: T) { this._jsMsg = msg; + this.decoded = decoded; } value(): T { @@ -49,9 +49,12 @@ class NatsMessage implements Message { properties(): Record { const headers = this._jsMsg.headers; const props: Record = {}; - if (headers) { + if (headers !== undefined) { for (const [key, values] of headers) { - props[key] = values[0]; + const value = values[0]; + if (value !== undefined) { + props[key] = value; + } } } return props; @@ -59,16 +62,24 @@ class NatsMessage implements Message { } class NatsProducer implements BackendProducer { - constructor( - private readonly js: JetStreamClient, - private readonly subject: string, - ) {} + private readonly js: JetStreamClient; + private readonly subject: string; + private readonly schema: S.Top | undefined; + + constructor(js: JetStreamClient, subject: string, schema?: S.Top) { + this.js = js; + this.subject = subject; + this.schema = schema; + } async send(message: T, properties?: Record): Promise { - const data = sc.encode(JSON.stringify(message)); + const encoded = this.schema !== undefined + ? S.encodeUnknownSync(this.schema as S.Codec)(message) + : message; + const data = sc.encode(JSON.stringify(encoded)); const opts: Record = {}; - if (properties && Object.keys(properties).length > 0) { + if (properties !== undefined && Object.keys(properties).length > 0) { const { headers } = await import("nats"); const hdrs = headers(); for (const [key, val] of Object.entries(properties)) { @@ -91,15 +102,31 @@ class NatsProducer implements BackendProducer { class NatsConsumer implements BackendConsumer { private consumer: NatsJsConsumer | null = null; + private readonly js: JetStreamClient; + private readonly jsm: JetStreamManager; + private readonly subject: string; + private readonly subscription: string; + private readonly initialPosition: "latest" | "earliest"; + private readonly streamName: string; + private readonly schema: S.Top | undefined; constructor( - private readonly js: JetStreamClient, - private readonly jsm: JetStreamManager, - private readonly subject: string, - private readonly subscription: string, - private readonly initialPosition: "latest" | "earliest", - private readonly streamName: string, - ) {} + js: JetStreamClient, + jsm: JetStreamManager, + subject: string, + subscription: string, + initialPosition: "latest" | "earliest", + streamName: string, + schema?: S.Top, + ) { + this.js = js; + this.jsm = jsm; + this.subject = subject; + this.subscription = subscription; + this.initialPosition = initialPosition; + this.streamName = streamName; + this.schema = schema; + } async init(): Promise { // Stream is already ensured by NatsBackend.ensureStream(). @@ -124,14 +151,17 @@ class NatsConsumer implements BackendConsumer { } async receive(timeoutMs = 2000): Promise | null> { - if (!this.consumer) throw new Error("Consumer not initialized"); + if (this.consumer === null) throw new Error("Consumer not initialized"); // Pull a single message with a timeout using the pull-based API. // consumer.next() returns a JsMsg or null when the timeout expires. const msg = await this.consumer.next({ expires: timeoutMs }); - if (!msg) return null; + if (msg === null) return null; - const decoded = JSON.parse(sc.decode(msg.data)) as T; + const parsed = JSON.parse(sc.decode(msg.data)); + const decoded = this.schema !== undefined + ? S.decodeUnknownSync(this.schema as S.Codec)(parsed) as T + : parsed as T; return new NatsMessage(msg, decoded); } @@ -161,11 +191,14 @@ export class NatsBackend implements PubSubBackend { private js: JetStreamClient | null = null; private jsm: JetStreamManager | null = null; private initializedStreams = new Set(); + private readonly url: string; - constructor(private readonly url: string = "nats://localhost:4222") {} + constructor(url = "nats://localhost:4222") { + this.url = url; + } private async ensureConnected(): Promise { - if (!this.connection) { + if (this.connection === null) { this.connection = await connect({ servers: this.url }); this.js = this.connection.jetstream(); this.jsm = await this.connection.jetstreamManager(); @@ -184,10 +217,13 @@ export class NatsBackend implements PubSubBackend { const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`; + const jsm = this.jsm; + if (jsm === null) throw new Error("NATS backend not connected"); + try { - await this.jsm!.streams.info(streamName); + await jsm.streams.info(streamName); } catch { - await this.jsm!.streams.add({ + await jsm.streams.add({ name: streamName, subjects: [wildcardSubject], }); @@ -199,26 +235,32 @@ export class NatsBackend implements PubSubBackend { async createProducer(options: CreateProducerOptions): Promise> { await this.ensureConnected(); await this.ensureStream(options.topic); - return new NatsProducer(this.js!, options.topic); + const js = this.js; + if (js === null) throw new Error("NATS backend not connected"); + return new NatsProducer(js, options.topic, options.schema); } async createConsumer(options: CreateConsumerOptions): Promise> { await this.ensureConnected(); const streamName = await this.ensureStream(options.topic); + const js = this.js; + const jsm = this.jsm; + if (js === null || jsm === null) throw new Error("NATS backend not connected"); const consumer = new NatsConsumer( - this.js!, - this.jsm!, + js, + jsm, options.topic, options.subscription, options.initialPosition ?? "latest", streamName, + options.schema, ); await consumer.init(); return consumer; } async close(): Promise { - if (this.connection) { + if (this.connection !== null) { await this.connection.drain(); this.connection = null; this.js = null; diff --git a/ts/packages/base/src/backend/pubsub.ts b/ts/packages/base/src/backend/pubsub.ts new file mode 100644 index 00000000..5be30c60 --- /dev/null +++ b/ts/packages/base/src/backend/pubsub.ts @@ -0,0 +1,101 @@ +/** + * Effect-native pub/sub capability for runtime composition. + * + * The existing Promise-based backend protocol stays available as the + * compatibility bridge while service code moves to `Context.Service`/Layers. + */ + +import { Config, Context, Effect, Layer } from "effect"; +import * as O from "effect/Option"; +import type { + BackendConsumer, + BackendProducer, + CreateConsumerOptions, + CreateProducerOptions, + PubSubBackend, +} from "./types.js"; +import { NatsBackend } from "./nats.js"; +import { pubSubError } from "../errors.js"; + +export interface PubSubService { + readonly backend: PubSubBackend; + readonly createProducer: ( + options: CreateProducerOptions, + ) => Effect.Effect, ReturnType>; + readonly createConsumer: ( + options: CreateConsumerOptions, + ) => Effect.Effect, ReturnType>; + readonly close: Effect.Effect>; +} + +export class PubSub extends Context.Service()("@trustgraph/base/backend/pubsub") { + static fromBackend(backend: PubSubBackend): PubSubService { + return makePubSubService(backend); + } + + static layer(backend: PubSubBackend): Layer.Layer { + return pubSubLayer(backend); + } +} + +export function makePubSubService(backend: PubSubBackend): PubSubService { + return { + backend, + createProducer: (options: CreateProducerOptions) => + Effect.tryPromise({ + try: () => backend.createProducer(options), + catch: (error) => pubSubError(`createProducer:${options.topic}`, error), + }), + createConsumer: (options: CreateConsumerOptions) => + Effect.tryPromise({ + try: () => backend.createConsumer(options), + catch: (error) => pubSubError(`createConsumer:${options.topic}`, error), + }), + close: Effect.tryPromise({ + try: () => backend.close(), + catch: (error) => pubSubError("close", error), + }), + }; +} + +export function pubSubLayer(backend: PubSubBackend): Layer.Layer { + return Layer.effect(PubSub)( + Effect.gen(function* () { + const service = makePubSubService(backend); + yield* Effect.addFinalizer(() => + service.close.pipe( + Effect.catch((error) => + Effect.logError("[PubSub] Failed to close backend", { + error: error.message, + operation: error.operation, + }), + ), + ), + ); + return PubSub.of(service); + }), + ); +} + +export function makeNatsPubSubLayer(url = "nats://localhost:4222"): Layer.Layer { + return pubSubLayer(new NatsBackend(url)); +} + +export const NatsPubSubLive = Layer.effect(PubSub)( + Effect.gen(function* () { + const natsUrl = O.getOrUndefined(yield* Config.string("NATS_URL").pipe(Config.option)); + const pulsarHost = O.getOrUndefined(yield* Config.string("PULSAR_HOST").pipe(Config.option)); + const service = makePubSubService(new NatsBackend(natsUrl ?? pulsarHost ?? "nats://localhost:4222")); + yield* Effect.addFinalizer(() => + service.close.pipe( + Effect.catch((error) => + Effect.logError("[PubSub] Failed to close NATS backend", { + error: error.message, + operation: error.operation, + }), + ), + ), + ); + return PubSub.of(service); + }), +); diff --git a/ts/packages/base/src/backend/types.ts b/ts/packages/base/src/backend/types.ts index 6f5640c2..f68883b2 100644 --- a/ts/packages/base/src/backend/types.ts +++ b/ts/packages/base/src/backend/types.ts @@ -5,6 +5,8 @@ * (NATS, Pulsar, Redis Streams) implements these interfaces. */ +import type * as S from "effect/Schema"; + export interface Message { value(): T; properties(): Record; @@ -29,6 +31,7 @@ export type InitialPosition = "latest" | "earliest"; export interface CreateProducerOptions { topic: string; + schema?: S.Top; } export interface CreateConsumerOptions { @@ -36,6 +39,7 @@ export interface CreateConsumerOptions { subscription: string; initialPosition?: InitialPosition; consumerType?: ConsumerType; + schema?: S.Top; } export interface PubSubBackend { diff --git a/ts/packages/base/src/errors.ts b/ts/packages/base/src/errors.ts index e6be4fd8..024a544e 100644 --- a/ts/packages/base/src/errors.ts +++ b/ts/packages/base/src/errors.ts @@ -1,29 +1,310 @@ /** - * Custom error types. + * Typed errors and wire-error translation helpers. * * Python reference: trustgraph-base/trustgraph/exceptions.py */ -export class TooManyRequestsError extends Error { - constructor(message = "Rate limit exceeded") { - super(message); - this.name = "TooManyRequestsError"; - } +import * as S from "effect/Schema"; +import type { TgError } from "./schema/primitives.js"; + +export class TooManyRequestsError extends S.TaggedErrorClass()( + "TooManyRequestsError", + { + message: S.String, + }, +) {} + +export class LlmError extends S.TaggedErrorClass()( + "LlmError", + { + message: S.String, + errorType: S.String, + }, +) {} + +export class EmbeddingsError extends S.TaggedErrorClass()( + "EmbeddingsError", + { + message: S.String, + operation: S.String, + provider: S.optionalKey(S.String), + }, +) {} + +export class ParseError extends S.TaggedErrorClass()( + "ParseError", + { + message: S.String, + }, +) {} + +export class RuntimeConfigError extends S.TaggedErrorClass()( + "RuntimeConfigError", + { + message: S.String, + key: S.optionalKey(S.String), + }, +) {} + +export class WireDecodeError extends S.TaggedErrorClass()( + "WireDecodeError", + { + message: S.String, + service: S.optionalKey(S.String), + }, +) {} + +export class PubSubError extends S.TaggedErrorClass()( + "PubSubError", + { + message: S.String, + operation: S.String, + }, +) {} + +export class ProcessorLifecycleError extends S.TaggedErrorClass()( + "ProcessorLifecycleError", + { + message: S.String, + operation: S.String, + processorId: S.String, + }, +) {} + +export class MessagingLifecycleError extends S.TaggedErrorClass()( + "MessagingLifecycleError", + { + message: S.String, + operation: S.String, + resource: S.String, + }, +) {} + +export class MessagingDeliveryError extends S.TaggedErrorClass()( + "MessagingDeliveryError", + { + message: S.String, + operation: S.String, + topic: S.String, + }, +) {} + +export class MessagingDecodeError extends S.TaggedErrorClass()( + "MessagingDecodeError", + { + message: S.String, + operation: S.String, + topic: S.optionalKey(S.String), + }, +) {} + +export class MessagingTimeoutError extends S.TaggedErrorClass()( + "MessagingTimeoutError", + { + message: S.String, + operation: S.String, + timeoutMs: S.Number, + }, +) {} + +export class MessagingHandlerError extends S.TaggedErrorClass()( + "MessagingHandlerError", + { + message: S.String, + topic: S.String, + subscription: S.String, + }, +) {} + +export class FlowRuntimeError extends S.TaggedErrorClass()( + "FlowRuntimeError", + { + message: S.String, + flowName: S.String, + operation: S.String, + }, +) {} + +export class FlowResourceNotFoundError extends S.TaggedErrorClass()( + "FlowResourceNotFoundError", + { + message: S.String, + flowName: S.String, + resourceType: S.Union([ + S.Literal("producer"), + S.Literal("consumer"), + S.Literal("requestor"), + S.Literal("parameter"), + ]), + resourceName: S.String, + }, +) {} + +export type TrustGraphError = + | TooManyRequestsError + | LlmError + | EmbeddingsError + | ParseError + | RuntimeConfigError + | WireDecodeError + | PubSubError + | ProcessorLifecycleError + | MessagingLifecycleError + | MessagingDeliveryError + | MessagingDecodeError + | MessagingTimeoutError + | MessagingHandlerError + | FlowRuntimeError + | FlowResourceNotFoundError; + +export type MessagingRuntimeError = + | PubSubError + | MessagingLifecycleError + | MessagingDeliveryError + | MessagingDecodeError + | MessagingTimeoutError + | MessagingHandlerError + | FlowRuntimeError + | FlowResourceNotFoundError; + +export function tooManyRequestsError(message = "Rate limit exceeded"): TooManyRequestsError { + return new TooManyRequestsError({ message }); } -export class LlmError extends Error { - constructor( - message: string, - public readonly errorType: string = "llm-error", - ) { - super(message); - this.name = "LlmError"; - } +export function llmError(message: string, errorType = "llm-error"): LlmError { + return new LlmError({ message, errorType }); } -export class ParseError extends Error { - constructor(message: string) { - super(message); - this.name = "ParseError"; - } +export function embeddingsError( + operation: string, + error: unknown, + provider?: string, +): EmbeddingsError { + return new EmbeddingsError({ + operation, + message: errorMessage(error), + ...(provider === undefined ? {} : { provider }), + }); +} + +export function parseError(message: string): ParseError { + return new ParseError({ message }); +} + +export function pubSubError(operation: string, error: unknown): PubSubError { + return new PubSubError({ operation, message: errorMessage(error) }); +} + +export function processorLifecycleError( + processorId: string, + operation: string, + error: unknown, +): ProcessorLifecycleError { + return new ProcessorLifecycleError({ + processorId, + operation, + message: errorMessage(error), + }); +} + +export function messagingLifecycleError( + resource: string, + operation: string, + error: unknown, +): MessagingLifecycleError { + return new MessagingLifecycleError({ + resource, + operation, + message: errorMessage(error), + }); +} + +export function messagingDeliveryError( + topic: string, + operation: string, + error: unknown, +): MessagingDeliveryError { + return new MessagingDeliveryError({ + topic, + operation, + message: errorMessage(error), + }); +} + +export function messagingDecodeError( + operation: string, + error: unknown, + topic?: string, +): MessagingDecodeError { + return new MessagingDecodeError({ + operation, + message: errorMessage(error), + ...(topic === undefined ? {} : { topic }), + }); +} + +export function messagingTimeoutError( + operation: string, + timeoutMs: number, +): MessagingTimeoutError { + return new MessagingTimeoutError({ + operation, + timeoutMs, + message: `${operation} timed out after ${timeoutMs}ms`, + }); +} + +export function messagingHandlerError( + topic: string, + subscription: string, + error: unknown, +): MessagingHandlerError { + return new MessagingHandlerError({ + topic, + subscription, + message: errorMessage(error), + }); +} + +export function flowRuntimeError( + flowName: string, + operation: string, + error: unknown, +): FlowRuntimeError { + return new FlowRuntimeError({ + flowName, + operation, + message: errorMessage(error), + }); +} + +export function flowResourceNotFoundError( + flowName: string, + resourceType: FlowResourceNotFoundError["resourceType"], + resourceName: string, +): FlowResourceNotFoundError { + return new FlowResourceNotFoundError({ + flowName, + resourceType, + resourceName, + message: `${resourceType} "${resourceName}" not found in flow "${flowName}"`, + }); +} + +export function errorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") return message; + } + return String(error); +} + +export function toTgError(error: unknown, fallbackType = "internal"): TgError { + if (typeof error === "object" && error !== null && "_tag" in error) { + const tag = (error as { _tag?: unknown })._tag; + if (typeof tag === "string") { + return { type: tag, message: errorMessage(error) }; + } + } + return { type: fallbackType, message: errorMessage(error) }; } diff --git a/ts/packages/base/src/index.ts b/ts/packages/base/src/index.ts index 2bd141ec..b1aba9e2 100644 --- a/ts/packages/base/src/index.ts +++ b/ts/packages/base/src/index.ts @@ -7,4 +7,5 @@ export * from "./schema/index.js"; export * from "./spec/index.js"; export * from "./services/index.js"; export * from "./metrics/index.js"; +export * from "./runtime/index.js"; export * from "./errors.js"; diff --git a/ts/packages/base/src/messaging/consumer.ts b/ts/packages/base/src/messaging/consumer.ts index 960c7780..3d2c4dfb 100644 --- a/ts/packages/base/src/messaging/consumer.ts +++ b/ts/packages/base/src/messaging/consumer.ts @@ -7,6 +7,7 @@ import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js"; import type { Flow } from "../processor/flow.js"; import { TooManyRequestsError } from "../errors.js"; +import * as S from "effect/Schema"; export type MessageHandler = ( message: T, @@ -14,11 +15,11 @@ export type MessageHandler = ( flow: FlowContext, ) => Promise; -export interface FlowContext { +export interface FlowContext { id: string; name: string; /** Reference to the owning Flow instance, giving handlers access to producers and parameters. */ - flow: Flow; + flow: Flow; } export interface ConsumerOptions { @@ -36,11 +37,13 @@ export class Consumer { private backend: BackendConsumer | null = null; private running = false; private abortController = new AbortController(); + private readonly options: ConsumerOptions; private readonly concurrency: number; private readonly rateLimitRetryMs: number; - constructor(private readonly options: ConsumerOptions) { + constructor(options: ConsumerOptions) { + this.options = options; this.concurrency = options.concurrency ?? 1; this.rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000; } @@ -65,7 +68,7 @@ export class Consumer { async stop(): Promise { this.running = false; this.abortController.abort(); - if (this.backend) { + if (this.backend !== null) { await this.backend.close(); this.backend = null; } @@ -75,17 +78,23 @@ export class Consumer { while (this.running) { let msg: Message | null = null; try { - msg = await this.backend!.receive(2000); - if (!msg) continue; + const backend = this.backend; + if (backend === null) throw new Error("Consumer backend not started"); + + msg = await backend.receive(2000); + if (msg === null) continue; await this.handleWithRetry(msg, flow); - await this.backend!.acknowledge(msg); + await backend.acknowledge(msg); } catch (err) { if (!this.running) break; console.error("[Consumer] Error in consume loop:", err); - if (msg) { + if (msg !== null) { try { - await this.backend!.negativeAcknowledge(msg); + const backend = this.backend; + if (backend !== null) { + await backend.negativeAcknowledge(msg); + } } catch (nakErr) { console.error("[Consumer] Failed to nak message:", nakErr); } @@ -99,7 +108,7 @@ export class Consumer { try { await this.options.handler(msg.value(), msg.properties(), flow); } catch (err) { - if (err instanceof TooManyRequestsError) { + if (S.is(TooManyRequestsError)(err)) { console.warn(`[Consumer] Rate limited, retrying in ${this.rateLimitRetryMs}ms`); await sleep(this.rateLimitRetryMs); await this.options.handler(msg.value(), msg.properties(), flow); diff --git a/ts/packages/base/src/messaging/index.ts b/ts/packages/base/src/messaging/index.ts index 74b2809d..81b47775 100644 --- a/ts/packages/base/src/messaging/index.ts +++ b/ts/packages/base/src/messaging/index.ts @@ -2,3 +2,37 @@ export { Producer } from "./producer.js"; export { Consumer, type MessageHandler, type FlowContext, type ConsumerOptions } from "./consumer.js"; export { Subscriber, AsyncQueue } from "./subscriber.js"; export { RequestResponse, type RequestResponseOptions } from "./request-response.js"; +export { + ConsumerFactory, + ConsumerFactoryLive, + FlowRuntime, + FlowRuntimeLive, + MessagingRuntimeLive, + ProducerFactory, + ProducerFactoryLive, + RequestResponseFactory, + RequestResponseFactoryLive, + makeEffectConsumerFromPubSub, + makeEffectProducerFromPubSub, + makeEffectProducerHandle, + makeEffectRequestResponseFromPubSub, + makeConsumerFactoryService, + makeProducerFactoryService, + makeRequestResponseFactoryService, + runEffectConsumerScoped, + runEffectProducerScoped, + runEffectRequestResponseScoped, + runFlowScoped, + type ConsumerFactoryService, + type EffectConsumer, + type EffectConsumerOptions, + type EffectMessageHandler, + type EffectProducer, + type EffectProducerOptions, + type EffectRequestOptions, + type EffectRequestResponse, + type EffectRequestResponseOptions, + type FlowRuntimeService, + type ProducerFactoryService, + type RequestResponseFactoryService, +} from "./runtime.js"; diff --git a/ts/packages/base/src/messaging/producer.ts b/ts/packages/base/src/messaging/producer.ts index 7e45d5d0..f12125f7 100644 --- a/ts/packages/base/src/messaging/producer.ts +++ b/ts/packages/base/src/messaging/producer.ts @@ -6,34 +6,44 @@ import type { PubSubBackend, BackendProducer } from "../backend/types.js"; import type { ProducerMetrics } from "../metrics/prometheus.js"; +import { Effect } from "effect"; +import { makeEffectProducerHandle, type EffectProducer } from "./runtime.js"; export class Producer { private backend: BackendProducer | null = null; - private running = false; + private effectProducer: EffectProducer | null = null; + private readonly pubsub: PubSubBackend; + private readonly topic: string; + private readonly metrics: ProducerMetrics | undefined; - constructor( - private readonly pubsub: PubSubBackend, - private readonly topic: string, - private readonly metrics?: ProducerMetrics, - ) {} + constructor(pubsub: PubSubBackend, topic: string, metrics?: ProducerMetrics) { + this.pubsub = pubsub; + this.topic = topic; + this.metrics = metrics; + } async start(): Promise { this.backend = await this.pubsub.createProducer({ topic: this.topic }); - this.running = true; + this.effectProducer = makeEffectProducerHandle(this.backend, { + topic: this.topic, + ...(this.metrics === undefined ? {} : { metrics: this.metrics }), + }); } async send(id: string, message: T): Promise { - if (!this.backend) throw new Error("Producer not started"); + if (this.effectProducer === null) throw new Error("Producer not started"); - await this.backend.send(message, { id }); - this.metrics?.inc(); + await Effect.runPromise(this.effectProducer.send(id, message)); } async stop(): Promise { - this.running = false; - if (this.backend) { - await this.backend.flush(); - await this.backend.close(); + if (this.effectProducer !== null) { + await Effect.runPromise( + this.effectProducer.flush.pipe( + Effect.flatMap(() => this.effectProducer === null ? Effect.void : this.effectProducer.close), + ), + ); + this.effectProducer = null; this.backend = null; } } diff --git a/ts/packages/base/src/messaging/request-response.ts b/ts/packages/base/src/messaging/request-response.ts index 2c570c68..410e337c 100644 --- a/ts/packages/base/src/messaging/request-response.ts +++ b/ts/packages/base/src/messaging/request-response.ts @@ -23,7 +23,7 @@ export class RequestResponse { private producer: Producer; private subscriber: Subscriber; - constructor(private readonly options: RequestResponseOptions) { + constructor(options: RequestResponseOptions) { this.producer = new Producer(options.pubsub, options.requestTopic); this.subscriber = new Subscriber( options.pubsub, @@ -77,7 +77,7 @@ export class RequestResponse { const response = await queue.pop(remaining); - if (recipient) { + if (recipient !== undefined) { const isFinal = await recipient(response); if (isFinal) return response; } else { diff --git a/ts/packages/base/src/messaging/runtime.ts b/ts/packages/base/src/messaging/runtime.ts new file mode 100644 index 00000000..741aaa26 --- /dev/null +++ b/ts/packages/base/src/messaging/runtime.ts @@ -0,0 +1,612 @@ +/** + * Effect-native messaging factories and scoped runtime helpers. + */ + +import { randomUUID } from "node:crypto"; +import { Context, Duration, Effect, Fiber, Layer, Queue, Scope } from "effect"; +import * as O from "effect/Option"; +import * as S from "effect/Schema"; +import type { + BackendConsumer, + BackendProducer, + CreateConsumerOptions, + CreateProducerOptions, + Message, +} from "../backend/types.js"; +import { PubSub, type PubSubService } from "../backend/pubsub.js"; +import { + flowRuntimeError, + messagingDeliveryError, + messagingHandlerError, + messagingLifecycleError, + messagingTimeoutError, + TooManyRequestsError, + type FlowRuntimeError, + type MessagingDeliveryError, + type MessagingLifecycleError, + type MessagingTimeoutError, + type PubSubError, +} from "../errors.js"; +import type { ProducerMetrics } from "../metrics/prometheus.js"; +import type { FlowContext } from "./consumer.js"; +import type { Flow } from "../processor/flow.js"; +import type { SpecRuntimeRequirements } from "../spec/types.js"; +import { + loadMessagingRuntimeConfig, + type MessagingRuntimeConfig, +} from "../runtime/messaging-config.js"; + +const isTooManyRequestsError = S.is(TooManyRequestsError); + +export type EffectMessageHandler = ( + message: T, + properties: Record, + flow: FlowContext, +) => Effect.Effect; + +export interface EffectProducerOptions { + readonly topic: string; + readonly schema?: S.Top; + readonly metrics?: ProducerMetrics; +} + +export interface EffectProducer { + readonly send: (id: string, message: T) => Effect.Effect; + readonly flush: Effect.Effect; + readonly close: Effect.Effect; +} + +export interface EffectConsumerOptions { + readonly topic: string; + readonly subscription: string; + readonly handler: EffectMessageHandler; + readonly concurrency?: number; + readonly initialPosition?: "latest" | "earliest"; + readonly schema?: S.Top; + readonly receiveTimeoutMs?: number; + readonly errorBackoffMs?: number; + readonly rateLimitRetryMs?: number; +} + +export interface EffectConsumer { + readonly stop: Effect.Effect; + readonly fibers: ReadonlyArray>; +} + +export interface EffectRequestResponseOptions { + readonly requestTopic: string; + readonly responseTopic: string; + readonly subscription: string; + readonly requestSchema?: S.Top; + readonly responseSchema?: S.Top; +} + +export interface EffectRequestOptions { + readonly timeoutMs?: number; + readonly recipient?: (response: TRes) => Effect.Effect; +} + +export interface EffectRequestResponse { + readonly request: ( + request: TReq, + options?: EffectRequestOptions, + ) => Effect.Effect; + readonly stop: Effect.Effect; +} + +export interface ProducerFactoryService { + readonly make: ( + options: EffectProducerOptions, + ) => Effect.Effect, PubSubError, Scope.Scope>; +} + +export interface ConsumerFactoryService { + readonly run: ( + options: EffectConsumerOptions, + flow: FlowContext, + ) => Effect.Effect; +} + +export interface RequestResponseFactoryService { + readonly make: ( + options: EffectRequestResponseOptions, + ) => Effect.Effect, PubSubError, Scope.Scope>; +} + +export interface FlowRuntimeService { + readonly run: ( + flow: Flow, + ) => Effect.Effect; +} + +export class ProducerFactory extends Context.Service()( + "@trustgraph/base/messaging/runtime/ProducerFactory", +) {} + +export class ConsumerFactory extends Context.Service()( + "@trustgraph/base/messaging/runtime/ConsumerFactory", +) {} + +export class RequestResponseFactory extends Context.Service< + RequestResponseFactory, + RequestResponseFactoryService +>()("@trustgraph/base/messaging/runtime/RequestResponseFactory") {} + +export class FlowRuntime extends Context.Service()( + "@trustgraph/base/messaging/runtime/FlowRuntime", +) {} + +export function makeEffectProducerHandle( + backend: BackendProducer, + options: EffectProducerOptions, +): EffectProducer { + return { + send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) => + Effect.tryPromise({ + try: () => backend.send(message, { id }), + catch: (error) => messagingDeliveryError(options.topic, "send", error), + }).pipe( + Effect.tap(() => + options.metrics === undefined + ? Effect.void + : Effect.sync(() => { + options.metrics?.inc(); + }), + ), + ), + ), + flush: Effect.tryPromise({ + try: () => backend.flush(), + catch: (error) => messagingDeliveryError(options.topic, "flush", error), + }), + close: Effect.tryPromise({ + try: () => backend.close(), + catch: (error) => messagingDeliveryError(options.topic, "close", error), + }), + }; +} + +export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* ( + pubsub: PubSubService, + options: EffectProducerOptions, +) { + const createOptions: CreateProducerOptions = options.schema === undefined + ? { topic: options.topic } + : { topic: options.topic, schema: options.schema }; + const backend = yield* pubsub.createProducer(createOptions); + const producer = makeEffectProducerHandle(backend, options); + + yield* Effect.addFinalizer(() => + producer.close.pipe( + Effect.catch((error) => + Effect.logError("[Producer] Failed to close producer", { + error: error.message, + topic: error.topic, + }), + ), + ), + ); + + return producer; +}); + +const closeConsumerBackend = ( + backend: BackendConsumer, + topic: string, + subscription: string, +) => + Effect.tryPromise({ + try: () => backend.close(), + catch: (error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error), + }); + +const acknowledgeMessage = ( + backend: BackendConsumer, + message: Message, + topic: string, +) => + Effect.tryPromise({ + try: () => backend.acknowledge(message), + catch: (error) => messagingDeliveryError(topic, "acknowledge", error), + }); + +const negativeAcknowledgeMessage = ( + backend: BackendConsumer, + message: Message, + topic: string, +) => + Effect.tryPromise({ + try: () => backend.negativeAcknowledge(message), + catch: (error) => messagingDeliveryError(topic, "negative-acknowledge", error), + }); + +const receiveMessage = ( + backend: BackendConsumer, + topic: string, + timeoutMs: number, +) => + Effect.tryPromise({ + try: () => backend.receive(timeoutMs), + catch: (error) => messagingDeliveryError(topic, "receive", error), + }); + +const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* ( + options: EffectConsumerOptions, + flow: FlowContext, + message: Message, + config: MessagingRuntimeConfig, +) { + const runHandler = Effect.fn(`Consumer.handler:${options.topic}`)(() => + options.handler(message.value(), message.properties(), flow).pipe( + Effect.mapError((error) => messagingHandlerError(options.topic, options.subscription, error)), + ), + ); + + return yield* options.handler(message.value(), message.properties(), flow).pipe( + Effect.catch((error) => { + if (isTooManyRequestsError(error)) { + return Effect.gen(function* () { + yield* Effect.logWarning("[Consumer] Rate limited, retrying", { + topic: options.topic, + subscription: options.subscription, + retryMs: config.rateLimitRetryMs, + }); + yield* Effect.sleep(Duration.millis(config.rateLimitRetryMs)); + yield* runHandler(); + }); + } + + return Effect.fail(messagingHandlerError(options.topic, options.subscription, error)); + }), + ); +}); + +const processConsumerMessage = Effect.fn("processConsumerMessage")(function* ( + backend: BackendConsumer, + options: EffectConsumerOptions, + flow: FlowContext, + message: Message, + config: MessagingRuntimeConfig, +) { + yield* handleMessageWithRetry(options, flow, message, config).pipe( + Effect.flatMap(() => acknowledgeMessage(backend, message, options.topic)), + Effect.catch((error) => + negativeAcknowledgeMessage(backend, message, options.topic).pipe( + Effect.catch((nakError) => + Effect.logError("[Consumer] Failed to negative-acknowledge message", { + error: nakError.message, + topic: nakError.topic, + }), + ), + Effect.flatMap(() => + Effect.logError("[Consumer] Message handling failed", { + error: error.message, + topic: options.topic, + subscription: options.subscription, + }), + ), + ), + ), + ); +}); + +const consumerLoop = ( + backend: BackendConsumer, + options: EffectConsumerOptions, + flow: FlowContext, + config: MessagingRuntimeConfig, +): Effect.Effect => + Effect.whileLoop({ + while: () => true, + body: () => + receiveMessage(backend, options.topic, options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs).pipe( + Effect.flatMap((message) => + message === null + ? Effect.sleep(Duration.millis(options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs)) + : processConsumerMessage(backend, options, flow, message, config), + ), + Effect.catch((error) => + Effect.logError("[Consumer] Receive loop failed", { + error: error.message, + topic: options.topic, + subscription: options.subscription, + }).pipe( + Effect.flatMap(() => + Effect.sleep(Duration.millis(options.errorBackoffMs ?? config.consumerErrorBackoffMs)), + ), + ), + ), + ), + step: () => undefined, + }); + +export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPubSub")(function* ( + pubsub: PubSubService, + config: MessagingRuntimeConfig, + options: EffectConsumerOptions, + flow: FlowContext, +) { + const createOptions: CreateConsumerOptions = { + topic: options.topic, + subscription: options.subscription, + ...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }), + ...(options.schema === undefined ? {} : { schema: options.schema }), + }; + const backend = yield* pubsub.createConsumer(createOptions); + const concurrency = Math.max(1, options.concurrency ?? 1); + const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index); + const fibers = yield* Effect.forEach(workerIndexes, () => + consumerLoop(backend, options, flow, { + ...config, + rateLimitRetryMs: options.rateLimitRetryMs ?? config.rateLimitRetryMs, + }).pipe(Effect.forkChild), + ); + + const stop = Effect.fn(`Consumer.stop:${options.topic}`)(function* () { + yield* Effect.forEach(fibers, Fiber.interrupt, { discard: true }); + yield* closeConsumerBackend(backend, options.topic, options.subscription); + }); + + yield* Effect.addFinalizer(() => + stop().pipe( + Effect.catch((error) => + Effect.logError("[Consumer] Failed to stop consumer", { + error: error.message, + resource: error.resource, + operation: error.operation, + }), + ), + ), + ); + + return { + fibers, + stop: stop(), + } satisfies EffectConsumer; +}); + +const dispatchResponseLoop = ( + backend: BackendConsumer, + responseTopic: string, + subscribers: Map>, + config: MessagingRuntimeConfig, +): Effect.Effect => + Effect.whileLoop({ + while: () => true, + body: () => + receiveMessage(backend, responseTopic, config.consumerReceiveTimeoutMs).pipe( + Effect.flatMap((message) => { + if (message === null) { + return Effect.sleep(Duration.millis(config.consumerReceiveTimeoutMs)); + } + + const id = message.properties().id; + const queue = id === undefined ? undefined : subscribers.get(id); + return Effect.gen(function* () { + if (queue !== undefined) { + yield* Queue.offer(queue, message.value()); + } + yield* acknowledgeMessage(backend, message, responseTopic); + }); + }), + Effect.catch((error) => + Effect.logError("[RequestResponse] Response dispatch failed", { + error: error.message, + topic: responseTopic, + }).pipe(Effect.flatMap(() => Effect.sleep(Duration.millis(config.consumerErrorBackoffMs)))), + ), + ), + step: () => undefined, + }); + +const waitForResponse = Effect.fn("waitForResponse")(function* ( + queue: Queue.Queue, + options: EffectRequestOptions | undefined, +) { + while (true) { + const response = yield* Queue.take(queue); + if (options?.recipient === undefined) { + return response; + } + + const complete = yield* options.recipient(response); + if (complete) { + return response; + } + } +}); + +export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestResponseFromPubSub")(function* < + TReq, + TRes, +>( + pubsub: PubSubService, + config: MessagingRuntimeConfig, + options: EffectRequestResponseOptions, +) { + const producer = yield* makeEffectProducerFromPubSub(pubsub, { + topic: options.requestTopic, + ...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }), + }); + const createOptions: CreateConsumerOptions = { + topic: options.responseTopic, + subscription: options.subscription, + ...(options.responseSchema === undefined ? {} : { schema: options.responseSchema }), + }; + const backend = yield* pubsub.createConsumer(createOptions); + const subscribers = new Map>(); + const fiber = yield* dispatchResponseLoop(backend, options.responseTopic, subscribers, config).pipe(Effect.forkChild); + + const stop = Effect.fn(`RequestResponse.stop:${options.requestTopic}`)(function* () { + yield* Fiber.interrupt(fiber); + yield* producer.close; + yield* closeConsumerBackend(backend, options.responseTopic, options.subscription); + }); + + yield* Effect.addFinalizer(() => + stop().pipe( + Effect.catch((error) => + Effect.logError("[RequestResponse] Failed to stop runtime", { + error: error.message, + }), + ), + ), + ); + + return { + request: ( + request: TReq, + requestOptions?: EffectRequestOptions, + ) => { + const id = randomUUID(); + const timeoutMs = requestOptions?.timeoutMs ?? config.requestTimeoutMs; + + return Effect.acquireUseRelease( + Queue.unbounded().pipe( + Effect.tap((queue) => + Effect.sync(() => { + subscribers.set(id, queue); + }), + ), + ), + (queue) => + Effect.gen(function* () { + yield* producer.send(id, request); + const result = yield* waitForResponse(queue, requestOptions).pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + ); + return yield* O.match(result, { + onNone: () => Effect.fail(messagingTimeoutError("request-response", timeoutMs)), + onSome: Effect.succeed, + }); + }), + (queue) => + Effect.sync(() => { + subscribers.delete(id); + }).pipe( + Effect.flatMap(() => Queue.shutdown(queue)), + Effect.ignore, + ), + ); + }, + stop: stop(), + } satisfies EffectRequestResponse; +}); + +export function makeProducerFactoryService(pubsub: PubSubService): ProducerFactoryService { + return { + make: Effect.fn("ProducerFactory.make")((options: EffectProducerOptions) => + makeEffectProducerFromPubSub(pubsub, options), + ), + }; +} + +export function makeConsumerFactoryService( + pubsub: PubSubService, + config: MessagingRuntimeConfig, +): ConsumerFactoryService { + return { + run: Effect.fn("ConsumerFactory.run")(( + options: EffectConsumerOptions, + flow: FlowContext, + ) => + makeEffectConsumerFromPubSub(pubsub, config, options, flow), + ), + }; +} + +export function makeRequestResponseFactoryService( + pubsub: PubSubService, + config: MessagingRuntimeConfig, +): RequestResponseFactoryService { + const make = Effect.fn("RequestResponseFactory.make")(function* ( + options: EffectRequestResponseOptions, + ) { + return yield* makeEffectRequestResponseFromPubSub(pubsub, config, options); + }) as RequestResponseFactoryService["make"]; + + return { make }; +} + +export const ProducerFactoryLive = Layer.effect( + ProducerFactory, + Effect.gen(function* () { + const pubsub = yield* PubSub; + return ProducerFactory.of(makeProducerFactoryService(pubsub)); + }), +); + +export const ConsumerFactoryLive = Layer.effect( + ConsumerFactory, + Effect.gen(function* () { + const pubsub = yield* PubSub; + const config = yield* loadMessagingRuntimeConfig(); + return ConsumerFactory.of(makeConsumerFactoryService(pubsub, config)); + }), +); + +export const RequestResponseFactoryLive = Layer.effect( + RequestResponseFactory, + Effect.gen(function* () { + const pubsub = yield* PubSub; + const config = yield* loadMessagingRuntimeConfig(); + return RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, config)); + }), +); + +export const runFlowRuntimeScoped = Effect.fn("FlowRuntime.run")(function* ( + flow: Flow, +) { + yield* flow.startEffect().pipe( + Effect.mapError((error) => flowRuntimeError(flow.name, "start", error)), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + flow.clearResources(); + }), + ); +}); + +export const FlowRuntimeLive = Layer.succeed( + FlowRuntime, + FlowRuntime.of({ + run: runFlowRuntimeScoped, + }), +); + +export const MessagingRuntimeLive = Layer.mergeAll( + ProducerFactoryLive, + ConsumerFactoryLive, + RequestResponseFactoryLive, + FlowRuntimeLive, +); + +export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* ( + options: EffectProducerOptions, +) { + const pubsub = yield* PubSub; + return yield* makeEffectProducerFromPubSub(pubsub, options); +}); + +export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(function* ( + options: EffectConsumerOptions, + flow: FlowContext, +) { + const pubsub = yield* PubSub; + const config = yield* loadMessagingRuntimeConfig(); + return yield* makeEffectConsumerFromPubSub(pubsub, config, options, flow); +}); + +export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* ( + options: EffectRequestResponseOptions, +) { + const pubsub = yield* PubSub; + const config = yield* loadMessagingRuntimeConfig(); + return yield* makeEffectRequestResponseFromPubSub(pubsub, config, options); +}); + +export const runFlowScoped = Effect.fn("runFlowScoped")(function* ( + flow: Flow, +) { + yield* runFlowRuntimeScoped(flow); +}); diff --git a/ts/packages/base/src/messaging/subscriber.ts b/ts/packages/base/src/messaging/subscriber.ts index 4ec7a8b3..801781e8 100644 --- a/ts/packages/base/src/messaging/subscriber.ts +++ b/ts/packages/base/src/messaging/subscriber.ts @@ -19,7 +19,7 @@ export class AsyncQueue { push(item: T): void { const waiter = this.waiters.shift(); - if (waiter) { + if (waiter !== undefined) { waiter(item); } else { this.buffer.push(item); @@ -34,7 +34,7 @@ export class AsyncQueue { let timer: ReturnType | undefined; const waiter = (value: T) => { - if (timer) clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); resolve(value); }; @@ -58,17 +58,20 @@ export class AsyncQueue { export class Subscriber { private backend: BackendConsumer | null = null; private running = false; + private readonly pubsub: PubSubBackend; + private readonly topic: string; + private readonly subscription: string; // ID-specific subscriptions (request/response correlation) private idSubscribers = new Map>(); // Wildcard subscribers (receive all messages) private allSubscribers = new Map>(); - constructor( - private readonly pubsub: PubSubBackend, - private readonly topic: string, - private readonly subscription: string, - ) {} + constructor(pubsub: PubSubBackend, topic: string, subscription: string) { + this.pubsub = pubsub; + this.topic = topic; + this.subscription = subscription; + } async start(): Promise { this.backend = await this.pubsub.createConsumer({ @@ -78,13 +81,13 @@ export class Subscriber { this.running = true; // Start the dispatch loop (fire and forget — runs until stop) this.dispatchLoop().catch((err) => { - if (this.running) console.error("[Subscriber] dispatch loop error:", err); + if (this.running === true) console.error("[Subscriber] dispatch loop error:", err); }); } async stop(): Promise { this.running = false; - if (this.backend) { + if (this.backend !== null) { await this.backend.close(); this.backend = null; } @@ -114,8 +117,11 @@ export class Subscriber { let consecutiveErrors = 0; while (this.running) { try { - const msg = await this.backend!.receive(2000); - if (!msg) continue; + const backend = this.backend; + if (backend === null) throw new Error("Subscriber backend not started"); + + const msg = await backend.receive(2000); + if (msg === null) continue; consecutiveErrors = 0; @@ -124,9 +130,9 @@ export class Subscriber { const value = msg.value(); // Route to ID-specific subscriber - if (id) { + if (id !== undefined && id.length > 0) { const sub = this.idSubscribers.get(id); - if (sub) { + if (sub !== undefined) { sub.queue.push(value); } } @@ -136,7 +142,7 @@ export class Subscriber { sub.queue.push(value); } - await this.backend!.acknowledge(msg); + await backend.acknowledge(msg); } catch (err) { if (!this.running) break; consecutiveErrors++; diff --git a/ts/packages/base/src/metrics/prometheus.ts b/ts/packages/base/src/metrics/prometheus.ts index ebaa30bf..f88b3ab8 100644 --- a/ts/packages/base/src/metrics/prometheus.ts +++ b/ts/packages/base/src/metrics/prometheus.ts @@ -13,8 +13,10 @@ export class ConsumerMetrics { private requestHistogram: Histogram; private processingCounter: Counter; private rateLimitCounter: Counter; + private readonly labels: { processor: string; flow: string; name: string }; constructor(processor: string, flow: string, name: string) { + this.labels = { processor, flow, name }; this.requestHistogram = new Histogram({ name: "tg_consumer_request_duration_seconds", help: "Consumer request processing time", @@ -38,22 +40,24 @@ export class ConsumerMetrics { } recordTime(seconds: number): void { - this.requestHistogram.observe(seconds); + this.requestHistogram.observe(this.labels, seconds); } process(status: "success" | "error"): void { - this.processingCounter.inc({ status }); + this.processingCounter.inc({ ...this.labels, status }); } rateLimit(): void { - this.rateLimitCounter.inc(); + this.rateLimitCounter.inc(this.labels); } } export class ProducerMetrics { private counter: Counter; + private readonly labels: { processor: string; flow: string; name: string }; constructor(processor: string, flow: string, name: string) { + this.labels = { processor, flow, name }; this.counter = new Counter({ name: "tg_producer_items_total", help: "Producer items sent", @@ -63,6 +67,6 @@ export class ProducerMetrics { } inc(): void { - this.counter.inc(); + this.counter.inc(this.labels); } } diff --git a/ts/packages/base/src/processor/async-processor.ts b/ts/packages/base/src/processor/async-processor.ts index 50ba9de4..1d6ab205 100644 --- a/ts/packages/base/src/processor/async-processor.ts +++ b/ts/packages/base/src/processor/async-processor.ts @@ -8,12 +8,16 @@ import type { PubSubBackend } from "../backend/types.js"; import { NatsBackend } from "../backend/nats.js"; -import { topics } from "../schema/topics.js"; +import { Effect } from "effect"; +import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js"; +import { loadProcessorRuntimeConfig } from "../runtime/config.js"; export interface ProcessorConfig { id: string; pubsubUrl?: string; metricsPort?: number; + manageProcessSignals?: boolean; + pubsub?: PubSubBackend; } export type ConfigHandler = ( @@ -21,14 +25,29 @@ export type ConfigHandler = ( version: number, ) => Promise; -export abstract class AsyncProcessor { +export type EffectConfigHandler = ( + config: Record, + version: number, +) => Effect.Effect; + +interface RegisteredSignalHandler { + readonly signal: NodeJS.Signals; + readonly handler: () => void; +} + +export abstract class AsyncProcessor { protected pubsub: PubSubBackend; protected running = false; protected configHandlers: ConfigHandler[] = []; private shutdownCallbacks: Array<() => Promise> = []; + private signalHandlers: RegisteredSignalHandler[] = []; + private readonly ownsPubSub: boolean; + protected readonly config: ProcessorConfig; - constructor(protected readonly config: ProcessorConfig) { - this.pubsub = new NatsBackend(config.pubsubUrl ?? "nats://localhost:4222"); + constructor(config: ProcessorConfig) { + this.config = config; + this.pubsub = config.pubsub ?? new NatsBackend(config.pubsubUrl ?? "nats://localhost:4222"); + this.ownsPubSub = config.pubsub === undefined; } registerConfigHandler(handler: ConfigHandler): void { @@ -36,47 +55,107 @@ export abstract class AsyncProcessor { } async start(): Promise { - this.running = true; - // Set up graceful shutdown - const shutdown = async () => { - console.log(`[${this.config.id}] Shutting down...`); - await this.stop(); - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - await this.run(); + await Effect.runPromise( + this.startEffect() as Effect.Effect, + ); } async stop(): Promise { - this.running = false; - for (const cb of this.shutdownCallbacks) { - await cb(); - } - await this.pubsub.close(); + await Effect.runPromise(this.stopEffect()); } protected onShutdown(callback: () => Promise): void { this.shutdownCallbacks.push(callback); } - protected abstract run(): Promise; + startEffect(): Effect.Effect { + const processor = this; + return Effect.gen(function* () { + yield* Effect.sync(() => { + processor.running = true; + processor.registerProcessSignalHandlers(); + }); + + yield* processor.runEffect(); + }).pipe( + Effect.withSpan("trustgraph.processor.start", { + attributes: { + "trustgraph.processor.id": processor.config.id, + }, + }), + ); + } + + stopEffect(): Effect.Effect { + const processor = this; + return Effect.gen(function* () { + yield* Effect.sync(() => { + processor.running = false; + processor.unregisterProcessSignalHandlers(); + }); + + for (const cb of processor.shutdownCallbacks) { + yield* Effect.tryPromise({ + try: () => cb(), + catch: (error) => processorLifecycleError(processor.config.id, "shutdown-callback", error), + }); + } + + if (processor.ownsPubSub) { + yield* Effect.tryPromise({ + try: () => processor.pubsub.close(), + catch: (error) => processorLifecycleError(processor.config.id, "close-pubsub", error), + }); + } + }); + } + + protected run(): Promise { + return Effect.runPromise(this.runEffect() as unknown as Effect.Effect); + } + + protected runEffect(): Effect.Effect { + return Effect.tryPromise({ + try: () => this.run(), + catch: (error) => processorLifecycleError(this.config.id, "start", error), + }) as unknown as Effect.Effect; + } + + private registerProcessSignalHandlers(): void { + if (this.config.manageProcessSignals === false || this.signalHandlers.length > 0) { + return; + } + + const shutdown = () => { + console.log(`[${this.config.id}] Shutting down...`); + void this.stop().then(() => process.exit(0)); + }; + const handlers: RegisteredSignalHandler[] = [ + { signal: "SIGINT", handler: shutdown }, + { signal: "SIGTERM", handler: shutdown }, + ]; + for (const { signal, handler } of handlers) { + process.once(signal, handler); + } + this.signalHandlers = handlers; + } + + private unregisterProcessSignalHandlers(): void { + for (const { signal, handler } of this.signalHandlers) { + process.off(signal, handler); + } + this.signalHandlers = []; + } /** * Static launch helper — parses env/args and starts the processor. * Subclasses call: `MyProcessor.launch("my-service")` */ - static async launch( + static async launch>( this: new (config: ProcessorConfig) => T, id: string, ): Promise { - const config: ProcessorConfig = { - id, - pubsubUrl: process.env.NATS_URL ?? process.env.PULSAR_HOST, - metricsPort: parseInt(process.env.METRICS_PORT ?? "8000", 10), - }; - + const config = await Effect.runPromise(loadProcessorRuntimeConfig(id)); const processor = new this(config); await processor.start(); } diff --git a/ts/packages/base/src/processor/flow-processor.ts b/ts/packages/base/src/processor/flow-processor.ts index 2e8da8b8..e337cd3b 100644 --- a/ts/packages/base/src/processor/flow-processor.ts +++ b/ts/packages/base/src/processor/flow-processor.ts @@ -12,15 +12,53 @@ import type { Spec } from "../spec/types.js"; import type { BackendConsumer } from "../backend/types.js"; import { Flow, type FlowDefinition } from "./flow.js"; import { topics } from "../schema/topics.js"; +import { + pubSubError, + type FlowRuntimeError, + type ProcessorLifecycleError, + type PubSubError, +} from "../errors.js"; +import { + ConsumerFactory, + FlowRuntime, + ProducerFactory, + RequestResponseFactory, + makeConsumerFactoryService, + makeProducerFactoryService, + makeRequestResponseFactoryService, + runFlowRuntimeScoped, +} from "../messaging/runtime.js"; +import { makePubSubService, PubSub } from "../backend/pubsub.js"; +import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js"; +import { Duration, Effect, Exit, Scope } from "effect"; +import * as S from "effect/Schema"; interface ConfigPush { version: number; config: Record; } -export abstract class FlowProcessor extends AsyncProcessor { - private specifications: Spec[] = []; - private flows = new Map(); +interface ActiveFlow { + readonly scope: Scope.Closeable; +} + +const ConfigPushSchema = S.Struct({ + version: S.Number, + config: S.Record(S.String, S.Unknown), +}); + +export abstract class FlowProcessor extends AsyncProcessor< + PubSubError | FlowRuntimeError | ProcessorLifecycleError, + | PubSub + | FlowRuntime + | ProducerFactory + | ConsumerFactory + | RequestResponseFactory + | Scope.Scope + | FlowRequirements +> { + private specifications: Array> = []; + private flows = new Map(); private configConsumer: BackendConsumer | null = null; private lastFlowsJson = ""; @@ -28,110 +66,254 @@ export abstract class FlowProcessor extends AsyncProcessor { super(config); } - registerSpecification(spec: Spec): void { - this.specifications.push(spec); + registerSpecification( + spec: Spec, + ): void { + this.specifications.push(spec as Spec); } - protected async run(): Promise { - // Subscribe to config-push topic to receive flow definitions. - // Use "earliest" to replay any config pushes that arrived before this service started. - this.configConsumer = await this.pubsub.createConsumer({ - topic: topics.configPush, - subscription: `${this.config.id}-config-push`, - initialPosition: "earliest", + override async start(): Promise { + const pubsub = makePubSubService(this.pubsub); + const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig()); + const start = this.startEffect().pipe( + Effect.provideService(PubSub, pubsub), + Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))), + Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))), + Effect.provideService( + RequestResponseFactory, + RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)), + ), + Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })), + ) as Effect.Effect; + await Effect.runPromise( + Effect.scoped( + start, + ), + ); + } + + protected override runEffect(): Effect.Effect< + void, + PubSubError | FlowRuntimeError | ProcessorLifecycleError, + | PubSub + | FlowRuntime + | ProducerFactory + | ConsumerFactory + | RequestResponseFactory + | Scope.Scope + | FlowRequirements + > { + const processor = this; + return Effect.gen(function* () { + const pubsub = yield* PubSub; + + // Subscribe to config-push topic to receive flow definitions. + // Use "earliest" to replay any config pushes that arrived before this service started. + processor.configConsumer = yield* pubsub.createConsumer({ + topic: topics.configPush, + subscription: `${processor.config.id}-config-push`, + initialPosition: "earliest", + schema: ConfigPushSchema, + }); + + yield* Effect.addFinalizer(() => + processor.closeConfigConsumerEffect().pipe( + Effect.flatMap(() => processor.closeAllFlowsEffect()), + ), + ); + + yield* Effect.log(`[${processor.config.id}] Listening for config pushes on ${topics.configPush}`); + + yield* Effect.whileLoop({ + while: () => processor.running, + body: () => processor.processNextConfigPushEffect(), + step: () => undefined, + }); }); + } - console.log(`[${this.config.id}] Listening for config pushes on ${topics.configPush}`); + private onConfigureFlowsEffect( + config: Record, + _version: number, + ): Effect.Effect< + void, + FlowRuntimeError, + FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements + > { + const processor = this; + return Effect.gen(function* () { + const flowDefs = config.flows as Record | undefined; + if (flowDefs === undefined) { + yield* Effect.log(`[${processor.config.id}] No flows in config push, skipping`); + return; + } - while (this.running) { - try { - const msg = await this.configConsumer.receive(2000); - if (!msg) continue; + // Skip flow restart if the flow definitions haven't changed. + // This prevents disrupting in-flight requests when non-flow config + // sections (prompts, tools, mcp) are updated. + const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe( + Effect.catch((error) => Effect.succeed(String(error))), + ); + if (processor.lastFlowsJson.length > 0 && flowsJson === processor.lastFlowsJson && processor.flows.size > 0) { + yield* Effect.log(`[${processor.config.id}] Flow definitions unchanged, skipping restart`); + return; + } + processor.lastFlowsJson = flowsJson; - const push = msg.value(); - console.log(`[${this.config.id}] Received config push version=${push.version}`); + // Stop removed flows + for (const [name, activeFlow] of processor.flows) { + if (!(name in flowDefs)) { + yield* Effect.log(`[${processor.config.id}] Stopping removed flow: ${name}`); + yield* processor.closeFlowEffect(name, activeFlow); + processor.flows.delete(name); + } + } - await this.onConfigureFlows(push.config, push.version); - - // Also call any registered config handlers - for (const handler of this.configHandlers) { - await handler(push.config, push.version); + // Start or update flows + for (const [name, defn] of Object.entries(flowDefs)) { + // Skip invalid definitions (e.g., stringified JSON) + if (typeof defn !== "object" || defn === null) { + yield* Effect.logWarning(`[${processor.config.id}] Skipping flow "${name}": definition is not an object`); + continue; } - await this.configConsumer.acknowledge(msg); - } catch (err) { - if (!this.running) break; - console.error(`[${this.config.id}] Config consumer error:`, err); - await sleep(1000); + // Stop existing flow before (re)starting with new config + const existing = processor.flows.get(name); + if (existing !== undefined) { + yield* Effect.log(`[${processor.config.id}] Restarting flow "${name}" with updated config`); + yield* processor.closeFlowEffect(name, existing); + processor.flows.delete(name); + } + + yield* Effect.log(`[${processor.config.id}] Starting flow "${name}"`); + const activeFlow = yield* processor.startFlowEffect(name, defn); + processor.flows.set(name, activeFlow); + yield* Effect.log(`[${processor.config.id}] Flow "${name}" started`); } - } + }); } - private async onConfigureFlows( - config: Record, - version: number, - ): Promise { - const flowDefs = config.flows as Record | undefined; - if (!flowDefs) { - console.log(`[${this.config.id}] No flows in config push, skipping`); - return; - } - - // Skip flow restart if the flow definitions haven't changed. - // This prevents disrupting in-flight requests when non-flow config - // sections (prompts, tools, mcp) are updated. - const flowsJson = JSON.stringify(flowDefs); - if (this.lastFlowsJson && flowsJson === this.lastFlowsJson && this.flows.size > 0) { - console.log(`[${this.config.id}] Flow definitions unchanged, skipping restart`); - return; - } - this.lastFlowsJson = flowsJson; - - // Stop removed flows - for (const [name, flow] of this.flows) { - if (!(name in flowDefs)) { - console.log(`[${this.config.id}] Stopping removed flow: ${name}`); - await flow.stop(); - this.flows.delete(name); - } - } - - // Start or update flows - for (const [name, defn] of Object.entries(flowDefs)) { - // Skip invalid definitions (e.g., stringified JSON) - if (typeof defn !== "object" || defn === null) { - console.warn(`[${this.config.id}] Skipping flow "${name}": definition is not an object`); - continue; - } - - // Stop existing flow before (re)starting with new config - if (this.flows.has(name)) { - console.log(`[${this.config.id}] Restarting flow "${name}" with updated config`); - await this.flows.get(name)!.stop(); - this.flows.delete(name); - } - - console.log(`[${this.config.id}] Starting flow "${name}" with topics:`, defn.topics); - const flow = new Flow(name, this.config.id, this.pubsub, defn, this.specifications); - await flow.start(); - this.flows.set(name, flow); - console.log(`[${this.config.id}] Flow "${name}" started`); - } + override stopEffect(): Effect.Effect { + return this.closeConfigConsumerEffect().pipe( + Effect.flatMap(() => this.closeAllFlowsEffect()), + Effect.flatMap(() => super.stopEffect()), + ); } - override async stop(): Promise { - if (this.configConsumer) { - await this.configConsumer.close(); - this.configConsumer = null; + private processNextConfigPushEffect(): Effect.Effect< + void, + never, + FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements + > { + const processor = this; + return Effect.gen(function* () { + const consumer = processor.configConsumer; + if (consumer === null) { + yield* Effect.sleep(Duration.millis(1000)); + return; + } + + const msg = yield* Effect.tryPromise({ + try: () => consumer.receive(2000), + catch: (error) => pubSubError("receive:config-push", error), + }); + if (msg === null) { + return; + } + + const push = msg.value(); + yield* Effect.log(`[${processor.config.id}] Received config push version=${push.version}`); + + yield* processor.onConfigureFlowsEffect(push.config, push.version); + + for (const handler of processor.configHandlers) { + yield* Effect.tryPromise({ + try: () => handler(push.config, push.version), + catch: (error) => pubSubError("config-handler", error), + }); + } + + yield* Effect.tryPromise({ + try: () => consumer.acknowledge(msg), + catch: (error) => pubSubError("acknowledge:config-push", error), + }); + }).pipe( + Effect.catch((error) => { + if (!processor.running) { + return Effect.void; + } + return Effect.logError(`[${processor.config.id}] Config consumer error`, { + error: error.message, + }).pipe( + Effect.flatMap(() => Effect.sleep(Duration.millis(1000))), + ); + }), + ); + } + + private startFlowEffect( + name: string, + definition: FlowDefinition, + ): Effect.Effect< + ActiveFlow, + FlowRuntimeError, + FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements + > { + const processor = this; + return Effect.gen(function* () { + const flowRuntime = yield* FlowRuntime; + const scope = yield* Scope.make(); + const flow = new Flow( + name, + processor.config.id, + processor.pubsub, + definition, + processor.specifications, + ); + return yield* flowRuntime.run(flow).pipe( + Scope.provide(scope), + Effect.as({ scope } satisfies ActiveFlow), + Effect.catch((error) => + Scope.close(scope, Exit.void).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + }); + } + + private closeFlowEffect(name: string, activeFlow: ActiveFlow): Effect.Effect { + return Scope.close(activeFlow.scope, Exit.void).pipe( + Effect.tap(() => Effect.log(`[${this.config.id}] Flow "${name}" stopped`)), + ); + } + + private closeAllFlowsEffect(): Effect.Effect { + const processor = this; + return Effect.gen(function* () { + const flows = Array.from(processor.flows.entries()); + for (const [name, activeFlow] of flows) { + yield* processor.closeFlowEffect(name, activeFlow); + } + processor.flows.clear(); + }); + } + + private closeConfigConsumerEffect(): Effect.Effect { + const consumer = this.configConsumer; + this.configConsumer = null; + if (consumer === null) { + return Effect.void; } - for (const flow of this.flows.values()) { - await flow.stop(); - } - this.flows.clear(); - await super.stop(); + return Effect.tryPromise({ + try: () => consumer.close(), + catch: (error) => pubSubError("close:config-push", error), + }).pipe( + Effect.catch((error) => + Effect.logError(`[${this.config.id}] Failed to close config consumer`, { + error: error.message, + }), + ), + ); } } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/ts/packages/base/src/processor/flow.ts b/ts/packages/base/src/processor/flow.ts index 230885c1..b218a013 100644 --- a/ts/packages/base/src/processor/flow.ts +++ b/ts/packages/base/src/processor/flow.ts @@ -4,11 +4,28 @@ * Python reference: trustgraph-base/trustgraph/base/flow.py */ +import { Effect, Exit, Scope } from "effect"; import type { PubSubBackend } from "../backend/types.js"; -import type { Spec } from "../spec/types.js"; -import type { Producer } from "../messaging/producer.js"; -import type { Consumer } from "../messaging/consumer.js"; -import type { RequestResponse } from "../messaging/request-response.js"; +import { makePubSubService } from "../backend/pubsub.js"; +import { + flowResourceNotFoundError, + type FlowResourceNotFoundError, + type PubSubError, +} from "../errors.js"; +import { + ConsumerFactory, + ProducerFactory, + RequestResponseFactory, + type EffectConsumer, + type EffectProducer, + type EffectRequestOptions, + type EffectRequestResponse, + makeConsumerFactoryService, + makeProducerFactoryService, + makeRequestResponseFactoryService, +} from "../messaging/runtime.js"; +import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js"; +import type { Spec, SpecRuntimeRequirements } from "../spec/types.js"; export interface FlowDefinition { /** Topic overrides keyed by spec name */ @@ -17,54 +34,119 @@ export interface FlowDefinition { parameters?: Record; } -export class Flow { - private producers = new Map>(); - private consumers = new Map>(); - private requestors = new Map>(); +export interface FlowProducer { + readonly send: (id: string, message: T) => Promise; + readonly flush: () => Promise; + readonly stop: () => Promise; +} + +export interface FlowConsumer { + readonly stop: () => Promise; +} + +export interface FlowRequestOptions { + readonly timeoutMs?: number; + readonly recipient?: (response: TRes) => Promise; +} + +export interface FlowRequestor { + readonly request: ( + request: TReq, + options?: FlowRequestOptions, + ) => Promise; + readonly stop: () => Promise; +} + +export class Flow { + private producers = new Map>(); + private consumers = new Map(); + private requestors = new Map>(); private parameters = new Map(); + private compatibilityScope: Scope.Closeable | null = null; + public readonly name: string; + public readonly processorId: string; + private readonly pubsub: PubSubBackend; + private readonly definition: FlowDefinition; + private readonly specifications: ReadonlyArray>; constructor( - public readonly name: string, - public readonly processorId: string, - private readonly pubsub: PubSubBackend, - private readonly definition: FlowDefinition, - private readonly specifications: Spec[], - ) {} + name: string, + processorId: string, + pubsub: PubSubBackend, + definition: FlowDefinition, + specifications: ReadonlyArray>, + ) { + this.name = name; + this.processorId = processorId; + this.pubsub = pubsub; + this.definition = definition; + this.specifications = specifications; + } + + startEffect(): Effect.Effect { + const flow = this; + return Effect.gen(function* () { + for (const spec of flow.specifications) { + yield* spec.addEffect(flow, flow.definition); + } + }); + } async start(): Promise { - for (const spec of this.specifications) { - await spec.add(this, this.pubsub, this.definition); - } - - // Start all consumers, passing this Flow instance via FlowContext - for (const consumer of this.consumers.values()) { - consumer.start({ id: this.processorId, name: this.name, flow: this }).catch((err) => { - console.error(`[Flow:${this.name}] Consumer error:`, err); - }); + if (this.compatibilityScope !== null) { + await this.stop(); } + await this.runInCompatibilityScope( + this.startEffect() as Effect.Effect, + this.pubsub, + ); } async stop(): Promise { - for (const consumer of this.consumers.values()) { - await consumer.stop(); - } - for (const producer of this.producers.values()) { - await producer.stop(); - } - for (const rr of this.requestors.values()) { - await rr.stop(); + const scope = this.compatibilityScope; + this.compatibilityScope = null; + if (scope !== null) { + await Effect.runPromise(Scope.close(scope, Exit.void)); } + this.clearResources(); } - registerProducer(name: string, producer: Producer): void { + async runInCompatibilityScope( + effect: Effect.Effect, + pubsub: PubSubBackend, + ): Promise { + const scope = await this.ensureCompatibilityScope(); + const pubsubService = makePubSubService(pubsub); + const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig()); + return await Effect.runPromise( + effect.pipe( + Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))), + Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))), + Effect.provideService( + RequestResponseFactory, + RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)), + ), + Scope.provide(scope), + ), + ); + } + + clearResources(): void { + this.producers.clear(); + this.consumers.clear(); + this.requestors.clear(); + this.parameters.clear(); + } + + registerProducer(name: string, producer: EffectProducer): void { this.producers.set(name, producer); } - registerConsumer(name: string, consumer: Consumer): void { + registerConsumer(name: string, consumer: EffectConsumer): void { this.consumers.set(name, consumer); } - registerRequestor(name: string, rr: RequestResponse): void { + registerRequestor(name: string, rr: EffectRequestResponse): void { this.requestors.set(name, rr); } @@ -72,27 +154,97 @@ export class Flow { this.parameters.set(name, value); } - producer(name: string): Producer { + producerEffect(name: string): Effect.Effect, FlowResourceNotFoundError> { const p = this.producers.get(name); - if (!p) throw new Error(`Producer "${name}" not found in flow "${this.name}"`); - return p as Producer; + return p === undefined + ? Effect.fail(flowResourceNotFoundError(this.name, "producer", name)) + : Effect.succeed(p as EffectProducer); } - consumer(name: string): Consumer { + consumerEffect(name: string): Effect.Effect { const c = this.consumers.get(name); - if (!c) throw new Error(`Consumer "${name}" not found in flow "${this.name}"`); - return c as Consumer; + return c === undefined + ? Effect.fail(flowResourceNotFoundError(this.name, "consumer", name)) + : Effect.succeed(c); } - requestor(name: string): RequestResponse { + requestorEffect( + name: string, + ): Effect.Effect, FlowResourceNotFoundError> { const rr = this.requestors.get(name); - if (!rr) throw new Error(`Requestor "${name}" not found in flow "${this.name}"`); - return rr as RequestResponse; + return rr === undefined + ? Effect.fail(flowResourceNotFoundError(this.name, "requestor", name)) + : Effect.succeed(rr as EffectRequestResponse); + } + + parameterEffect(name: string): Effect.Effect { + const v = this.parameters.get(name); + return v === undefined + ? Effect.fail(flowResourceNotFoundError(this.name, "parameter", name)) + : Effect.succeed(v as T); + } + + producer(name: string): FlowProducer { + const p = this.producers.get(name); + if (p === undefined) throw flowResourceNotFoundError(this.name, "producer", name); + return { + send: (id, message) => Effect.runPromise((p as EffectProducer).send(id, message)), + flush: () => Effect.runPromise(p.flush), + stop: () => Effect.runPromise(p.flush.pipe(Effect.flatMap(() => p.close))), + }; + } + + consumer(name: string): FlowConsumer { + const c = this.consumers.get(name); + if (c === undefined) throw flowResourceNotFoundError(this.name, "consumer", name); + return { + stop: () => Effect.runPromise(c.stop), + }; + } + + requestor(name: string): FlowRequestor { + const rr = this.requestors.get(name); + if (rr === undefined) throw flowResourceNotFoundError(this.name, "requestor", name); + return { + request: (request, options) => + Effect.runPromise( + (rr as EffectRequestResponse).request( + request, + this.toEffectRequestOptions(options), + ), + ), + stop: () => Effect.runPromise(rr.stop), + }; } parameter(name: string): T { const v = this.parameters.get(name); - if (v === undefined) throw new Error(`Parameter "${name}" not found in flow "${this.name}"`); + if (v === undefined) throw flowResourceNotFoundError(this.name, "parameter", name); return v as T; } + + private async ensureCompatibilityScope(): Promise { + if (this.compatibilityScope !== null) { + return this.compatibilityScope; + } + this.compatibilityScope = await Effect.runPromise(Scope.make()); + return this.compatibilityScope; + } + + private toEffectRequestOptions( + options: FlowRequestOptions | undefined, + ): EffectRequestOptions | undefined { + if (options === undefined) { + return undefined; + } + const recipient = options.recipient; + return { + ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + ...(recipient === undefined + ? {} + : { + recipient: (response: TRes) => Effect.promise(() => recipient(response)), + }), + }; + } } diff --git a/ts/packages/base/src/processor/index.ts b/ts/packages/base/src/processor/index.ts index e9d9b013..b7d344b1 100644 --- a/ts/packages/base/src/processor/index.ts +++ b/ts/packages/base/src/processor/index.ts @@ -1,3 +1,22 @@ -export { AsyncProcessor, type ProcessorConfig, type ConfigHandler } from "./async-processor.js"; +export { + AsyncProcessor, + type ConfigHandler, + type EffectConfigHandler, + type ProcessorConfig, +} from "./async-processor.js"; export { FlowProcessor } from "./flow-processor.js"; -export { Flow, type FlowDefinition } from "./flow.js"; +export { + Flow, + type FlowConsumer, + type FlowDefinition, + type FlowProducer, + type FlowRequestOptions, + type FlowRequestor, +} from "./flow.js"; +export { + makeAsyncProcessorProgram, + makeFlowProcessorProgram, + makeProcessorProgram, + runProcessorScoped, + type ProcessorProgramOptions, +} from "./program.js"; diff --git a/ts/packages/base/src/processor/program.ts b/ts/packages/base/src/processor/program.ts new file mode 100644 index 00000000..aed11f52 --- /dev/null +++ b/ts/packages/base/src/processor/program.ts @@ -0,0 +1,139 @@ +/** + * Scoped Effect runtime helpers for legacy processor classes. + * + * These helpers make `Context.Service`/Layer composition the canonical + * executable path while the processor internals remain Promise-based. + */ + +import { Effect, Scope } from "effect"; +import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js"; +import { NatsBackend } from "../backend/nats.js"; +import { makePubSubService, PubSub } from "../backend/pubsub.js"; +import { + ConsumerFactory, + FlowRuntime, + ProducerFactory, + RequestResponseFactory, + makeConsumerFactoryService, + makeProducerFactoryService, + makeRequestResponseFactoryService, + runFlowRuntimeScoped, +} from "../messaging/runtime.js"; +import { + loadProcessorRuntimeConfig, + type ProcessorRuntimeConfigOptions, +} from "../runtime/config.js"; +import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js"; +import type { AsyncProcessor, ProcessorConfig } from "./async-processor.js"; + +type ProcessorRunError = Processor extends AsyncProcessor ? Error : never; +type ProcessorRunRequirements = Processor extends AsyncProcessor ? Requirements : never; + +export interface ProcessorProgramOptions< + Config extends ProcessorConfig, + Error, + Requirements, + Processor extends AsyncProcessor, +> { + readonly id: string; + readonly make: (config: Config) => Processor; + readonly loadConfig?: Effect.Effect; +} + +export function runProcessorScoped< + Config extends ProcessorConfig, + Processor extends AsyncProcessor, +>( + config: Config, + make: (config: Config) => Processor, +): Effect.Effect< + void, + ProcessorRunError | ProcessorLifecycleError, + PubSub | Scope.Scope | ProcessorRunRequirements +> { + return Effect.gen(function* () { + const pubsub = yield* PubSub; + const runtimeConfig = { + ...config, + manageProcessSignals: false, + pubsub: pubsub.backend, + } as Config; + const processor = make(runtimeConfig); + + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => processor.stop(), + catch: (error) => processorLifecycleError(config.id, "stop", error), + }).pipe( + Effect.catch((error) => + Effect.logError("[Processor] Failed to stop processor", { + error: error.message, + operation: error.operation, + processorId: error.processorId, + }), + ), + ), + ); + + const typedProcessor = processor as unknown as AsyncProcessor< + ProcessorRunError, + ProcessorRunRequirements + >; + yield* typedProcessor.startEffect(); + }); +} + +export function makeProcessorProgram< + Config extends ProcessorConfig, + Error = never, + Requirements = never, + Processor extends AsyncProcessor = AsyncProcessor, +>( + options: ProcessorProgramOptions, +) { + return Effect.scoped( + Effect.gen(function* () { + const config = yield* ( + options.loadConfig ?? + loadProcessorRuntimeConfig(options.id, { + manageProcessSignals: false, + } satisfies ProcessorRuntimeConfigOptions) + ); + + const runtimeConfig = { + ...config, + manageProcessSignals: false, + } as Config; + + const pubsub = makePubSubService(new NatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222")); + const messagingConfig = yield* loadMessagingRuntimeConfig(); + yield* Effect.addFinalizer(() => + pubsub.close.pipe( + Effect.catch((error) => + Effect.logError("[PubSub] Failed to close processor backend", { + error: error.message, + operation: error.operation, + }), + ), + ), + ); + const processorEffect = runProcessorScoped( + runtimeConfig, + options.make, + ); + yield* processorEffect.pipe( + Effect.provideService(PubSub, pubsub), + Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))), + Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))), + Effect.provideService( + RequestResponseFactory, + RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)), + ), + Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })), + ); + }), + ); +} + +export const makeAsyncProcessorProgram = makeProcessorProgram; +export const makeFlowProcessorProgram = makeProcessorProgram; diff --git a/ts/packages/base/src/runtime/config.ts b/ts/packages/base/src/runtime/config.ts new file mode 100644 index 00000000..37bf5c2e --- /dev/null +++ b/ts/packages/base/src/runtime/config.ts @@ -0,0 +1,33 @@ +/** + * Effect Config contracts for process/runtime settings. + * + * These declarations preserve the existing environment variable names while + * moving reads to a typed Effect boundary. + */ + +import { Config, Effect } from "effect"; +import * as O from "effect/Option"; + +export interface ProcessorRuntimeConfigOptions { + readonly manageProcessSignals?: boolean; +} + +export const optionalStringConfig = Effect.fn("optionalStringConfig")(function* (name: string) { + return O.getOrUndefined(yield* Config.string(name).pipe(Config.option)); +}); + +export const loadProcessorRuntimeConfig = Effect.fn("loadProcessorRuntimeConfig")(function* ( + id: string, + options: ProcessorRuntimeConfigOptions = {}, +) { + const natsUrl = yield* optionalStringConfig("NATS_URL"); + const pulsarHost = yield* optionalStringConfig("PULSAR_HOST"); + const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000)); + + return { + id, + pubsubUrl: natsUrl ?? pulsarHost ?? "nats://localhost:4222", + metricsPort, + manageProcessSignals: options.manageProcessSignals ?? true, + }; +}); diff --git a/ts/packages/base/src/runtime/index.ts b/ts/packages/base/src/runtime/index.ts new file mode 100644 index 00000000..e5664503 --- /dev/null +++ b/ts/packages/base/src/runtime/index.ts @@ -0,0 +1,10 @@ +export { + defaultMessagingRuntimeConfig, + loadMessagingRuntimeConfig, + type MessagingRuntimeConfig, +} from "./messaging-config.js"; +export { + loadProcessorRuntimeConfig, + optionalStringConfig, + type ProcessorRuntimeConfigOptions, +} from "./config.js"; diff --git a/ts/packages/base/src/runtime/messaging-config.ts b/ts/packages/base/src/runtime/messaging-config.ts new file mode 100644 index 00000000..556a4c7f --- /dev/null +++ b/ts/packages/base/src/runtime/messaging-config.ts @@ -0,0 +1,41 @@ +/** + * Effect Config contracts for messaging runtime behavior. + */ + +import { Config, Effect } from "effect"; + +export interface MessagingRuntimeConfig { + readonly consumerReceiveTimeoutMs: number; + readonly consumerErrorBackoffMs: number; + readonly rateLimitRetryMs: number; + readonly requestTimeoutMs: number; +} + +export const defaultMessagingRuntimeConfig: MessagingRuntimeConfig = { + consumerReceiveTimeoutMs: 2_000, + consumerErrorBackoffMs: 1_000, + rateLimitRetryMs: 10_000, + requestTimeoutMs: 300_000, +}; + +export const loadMessagingRuntimeConfig = Effect.fn("loadMessagingRuntimeConfig")(function* () { + const consumerReceiveTimeoutMs = yield* Config.number("TG_CONSUMER_RECEIVE_TIMEOUT_MS").pipe( + Config.withDefault(defaultMessagingRuntimeConfig.consumerReceiveTimeoutMs), + ); + const consumerErrorBackoffMs = yield* Config.number("TG_CONSUMER_ERROR_BACKOFF_MS").pipe( + Config.withDefault(defaultMessagingRuntimeConfig.consumerErrorBackoffMs), + ); + const rateLimitRetryMs = yield* Config.number("TG_RATE_LIMIT_RETRY_MS").pipe( + Config.withDefault(defaultMessagingRuntimeConfig.rateLimitRetryMs), + ); + const requestTimeoutMs = yield* Config.number("TG_REQUEST_TIMEOUT_MS").pipe( + Config.withDefault(defaultMessagingRuntimeConfig.requestTimeoutMs), + ); + + return { + consumerReceiveTimeoutMs, + consumerErrorBackoffMs, + rateLimitRetryMs, + requestTimeoutMs, + } satisfies MessagingRuntimeConfig; +}); diff --git a/ts/packages/base/src/schema/messages.ts b/ts/packages/base/src/schema/messages.ts index 62d111a6..7b2fb28b 100644 --- a/ts/packages/base/src/schema/messages.ts +++ b/ts/packages/base/src/schema/messages.ts @@ -1,344 +1,455 @@ /** - * Message types for service communication. + * Schema-backed message types for service communication. * * Python reference: trustgraph-base/trustgraph/schema/services/ */ -import type { TgError, Triple, Term, RowSchema } from "./primitives.js"; +import * as S from "effect/Schema"; +import { Term, TgError, Triple } from "./primitives.js"; + +const UnknownRecord = S.Record(S.String, S.Unknown); +const MutableArray = (schema: A) => schema.pipe(S.Array, S.mutable); +const OptionalMutableArray = (schema: A) => schema.pipe(S.Array, S.mutable, S.optionalKey); +const StringArray = MutableArray(S.String); +const NumberArray = MutableArray(S.Number); +const NumberArrays = MutableArray(NumberArray); // Text completion -export interface TextCompletionRequest { - system: string; - prompt: string; - model?: string; - temperature?: number; - streaming?: boolean; -} +export const TextCompletionRequest = S.Struct({ + system: S.String, + prompt: S.String, + model: S.optionalKey(S.String), + temperature: S.optionalKey(S.Number), + streaming: S.optionalKey(S.Boolean), +}); +export type TextCompletionRequest = typeof TextCompletionRequest.Type; -export interface TextCompletionResponse { - response: string; - model?: string; - inToken?: number; - outToken?: number; - error?: TgError; - endOfStream?: boolean; -} +export const TextCompletionResponse = S.Struct({ + response: S.String, + model: S.optionalKey(S.String), + inToken: S.optionalKey(S.Number), + outToken: S.optionalKey(S.Number), + error: S.optionalKey(TgError), + endOfStream: S.optionalKey(S.Boolean), +}); +export type TextCompletionResponse = typeof TextCompletionResponse.Type; // Embeddings -export interface EmbeddingsRequest { - text: string[]; - model?: string; -} +export const EmbeddingsRequest = S.Struct({ + text: StringArray, + model: S.optionalKey(S.String), +}); +export type EmbeddingsRequest = typeof EmbeddingsRequest.Type; -export interface EmbeddingsResponse { - vectors: number[][]; - error?: TgError; -} +export const EmbeddingsResponse = S.Struct({ + vectors: NumberArrays, + error: S.optionalKey(TgError), +}); +export type EmbeddingsResponse = typeof EmbeddingsResponse.Type; // Graph RAG -export interface GraphRagRequest { - query: string; - collection?: string; - entityLimit?: number; - tripleLimit?: number; - maxSubgraphSize?: number; - maxPathLength?: number; - streaming?: boolean; -} +export const GraphRagRequest = S.Struct({ + query: S.String, + collection: S.optionalKey(S.String), + entityLimit: S.optionalKey(S.Number), + tripleLimit: S.optionalKey(S.Number), + maxSubgraphSize: S.optionalKey(S.Number), + maxPathLength: S.optionalKey(S.Number), + streaming: S.optionalKey(S.Boolean), +}); +export type GraphRagRequest = typeof GraphRagRequest.Type; -export interface GraphRagResponse { - response: string; - error?: TgError; - endOfStream?: boolean; - // Explainability: include retrieved subgraph triples - message_type?: "chunk" | "explain"; - explain_id?: string; - explain_triples?: Triple[]; - [key: string]: unknown; -} +export const GraphRagResponse = S.StructWithRest( + S.Struct({ + response: S.String, + error: S.optionalKey(TgError), + endOfStream: S.optionalKey(S.Boolean), + message_type: S.optionalKey(S.Union([S.Literal("chunk"), S.Literal("explain")])), + explain_id: S.optionalKey(S.String), + explain_triples: OptionalMutableArray(Triple), + }), + [UnknownRecord], +); +export type GraphRagResponse = typeof GraphRagResponse.Type; // Document RAG -export interface DocumentRagRequest { - query: string; - collection?: string; - streaming?: boolean; -} +export const DocumentRagRequest = S.Struct({ + query: S.String, + collection: S.optionalKey(S.String), + streaming: S.optionalKey(S.Boolean), +}); +export type DocumentRagRequest = typeof DocumentRagRequest.Type; -export interface DocumentRagResponse { - response: string; - error?: TgError; - endOfStream?: boolean; -} +export const DocumentRagResponse = S.Struct({ + response: S.String, + error: S.optionalKey(TgError), + endOfStream: S.optionalKey(S.Boolean), +}); +export type DocumentRagResponse = typeof DocumentRagResponse.Type; // Agent -export interface AgentRequest { - question: string; - collection?: string; - streaming?: boolean; - group?: string[]; - state?: string; -} +export const AgentRequest = S.Struct({ + question: S.String, + collection: S.optionalKey(S.String), + streaming: S.optionalKey(S.Boolean), + group: S.optionalKey(StringArray), + state: S.optionalKey(S.String), +}); +export type AgentRequest = typeof AgentRequest.Type; -export interface AgentResponse { - /** Streaming chunk type */ - chunk_type?: "thought" | "observation" | "answer" | "error" | "explain"; - content?: string; - end_of_message?: boolean; - end_of_dialog?: boolean; - /** Legacy non-streaming fields */ - answer?: string; - error?: TgError; - endOfStream?: boolean; - endOfSession?: boolean; - /** Explainability fields */ - explain_id?: string; - explain_graph?: string; - explain_triples?: unknown[]; - message_type?: string; -} +export const AgentResponse = S.Struct({ + chunk_type: S.optionalKey(S.Union([ + S.Literal("thought"), + S.Literal("observation"), + S.Literal("answer"), + S.Literal("error"), + S.Literal("explain"), + ])), + content: S.optionalKey(S.String), + end_of_message: S.optionalKey(S.Boolean), + end_of_dialog: S.optionalKey(S.Boolean), + answer: S.optionalKey(S.String), + error: S.optionalKey(TgError), + endOfStream: S.optionalKey(S.Boolean), + endOfSession: S.optionalKey(S.Boolean), + explain_id: S.optionalKey(S.String), + explain_graph: S.optionalKey(S.String), + explain_triples: OptionalMutableArray(S.Unknown), + message_type: S.optionalKey(S.String), +}); +export type AgentResponse = typeof AgentResponse.Type; // Triples query -export interface TriplesQueryRequest { - s?: Term; - p?: Term; - o?: Term; - collection?: string; - limit?: number; -} +export const TriplesQueryRequest = S.Struct({ + s: S.optionalKey(Term), + p: S.optionalKey(Term), + o: S.optionalKey(Term), + collection: S.optionalKey(S.String), + limit: S.optionalKey(S.Number), +}); +export type TriplesQueryRequest = typeof TriplesQueryRequest.Type; -export interface TriplesQueryResponse { - triples: Triple[]; - error?: TgError; -} +export const TriplesQueryResponse = S.Struct({ + triples: MutableArray(Triple), + error: S.optionalKey(TgError), +}); +export type TriplesQueryResponse = typeof TriplesQueryResponse.Type; // Graph embeddings query -export interface GraphEmbeddingsRequest { - vectors: number[][]; - user?: string; - limit?: number; - collection?: string; -} +export const GraphEmbeddingsRequest = S.Struct({ + vectors: NumberArrays, + user: S.optionalKey(S.String), + limit: S.optionalKey(S.Number), + collection: S.optionalKey(S.String), +}); +export type GraphEmbeddingsRequest = typeof GraphEmbeddingsRequest.Type; -export interface GraphEmbeddingsResponse { - entities: Term[]; - error?: TgError; -} +export const GraphEmbeddingsResponse = S.Struct({ + entities: MutableArray(Term), + error: S.optionalKey(TgError), +}); +export type GraphEmbeddingsResponse = typeof GraphEmbeddingsResponse.Type; // Document embeddings query -export interface DocumentEmbeddingsRequest { - vectors: number[][]; - limit?: number; - user?: string; - collection?: string; -} +export const DocumentEmbeddingsRequest = S.Struct({ + vectors: NumberArrays, + limit: S.optionalKey(S.Number), + user: S.optionalKey(S.String), + collection: S.optionalKey(S.String), +}); +export type DocumentEmbeddingsRequest = typeof DocumentEmbeddingsRequest.Type; -export interface DocumentEmbeddingsResponse { - chunks: Array<{ chunkId: string; score: number; content?: string }>; - error?: TgError; -} +const DocumentEmbeddingChunk = S.Struct({ + chunkId: S.String, + score: S.Number, + content: S.optionalKey(S.String), +}); + +export const DocumentEmbeddingsResponse = S.Struct({ + chunks: MutableArray(DocumentEmbeddingChunk), + error: S.optionalKey(TgError), +}); +export type DocumentEmbeddingsResponse = typeof DocumentEmbeddingsResponse.Type; // Config -export type ConfigOperation = "get" | "list" | "delete" | "put" | "config" | "getvalues"; +export const ConfigOperation = S.Union([ + S.Literal("get"), + S.Literal("list"), + S.Literal("delete"), + S.Literal("put"), + S.Literal("config"), + S.Literal("getvalues"), +]); +export type ConfigOperation = typeof ConfigOperation.Type; -export interface ConfigRequest { - operation: ConfigOperation; - keys?: string[]; - values?: Record; - type?: string; -} +export const ConfigRequest = S.Struct({ + operation: ConfigOperation, + keys: S.optionalKey(StringArray), + values: S.optionalKey(UnknownRecord), + type: S.optionalKey(S.String), +}); +export type ConfigRequest = typeof ConfigRequest.Type; -export interface ConfigResponse { - version?: number; - values?: Record; - directory?: string[]; - config?: Record; - error?: TgError; -} +export const ConfigResponse = S.Struct({ + version: S.optionalKey(S.Number), + values: S.optionalKey(S.Unknown), + directory: S.optionalKey(StringArray), + config: S.optionalKey(UnknownRecord), + error: S.optionalKey(TgError), +}); +export type ConfigResponse = typeof ConfigResponse.Type; // Prompt -export interface PromptRequest { - name: string; - variables?: Record; -} +export const PromptRequest = S.Struct({ + name: S.String, + variables: S.optionalKey(S.Record(S.String, S.String)), +}); +export type PromptRequest = typeof PromptRequest.Type; -export interface PromptResponse { - system: string; - prompt: string; - error?: TgError; -} +export const PromptResponse = S.Struct({ + system: S.String, + prompt: S.String, + error: S.optionalKey(TgError), +}); +export type PromptResponse = typeof PromptResponse.Type; -// ---------- Pipeline types ---------- +// Pipeline types +export const PipelineMetadata = S.Struct({ + id: S.String, + root: S.String, + user: S.String, + collection: S.String, +}); +export type PipelineMetadata = typeof PipelineMetadata.Type; -export interface PipelineMetadata { - id: string; - root: string; - user: string; - collection: string; -} +export const Document = S.Struct({ + metadata: PipelineMetadata, + documentId: S.String, +}); +export type Document = typeof Document.Type; -/** Document message — triggers the decode pipeline for a librarian document. */ -export interface Document { - metadata: PipelineMetadata; - documentId: string; -} +export const TextDocument = S.Struct({ + metadata: PipelineMetadata, + text: S.String, + documentId: S.String, +}); +export type TextDocument = typeof TextDocument.Type; -export interface TextDocument { - metadata: PipelineMetadata; - text: string; - documentId: string; -} +export const Chunk = S.Struct({ + metadata: PipelineMetadata, + chunk: S.String, + documentId: S.String, +}); +export type Chunk = typeof Chunk.Type; -export interface Chunk { - metadata: PipelineMetadata; - chunk: string; - documentId: string; -} +export const EntityContext = S.Struct({ + entity: Term, + context: S.String, + chunkId: S.String, +}); +export type EntityContext = typeof EntityContext.Type; -export interface EntityContext { - entity: Term; - context: string; - chunkId: string; -} +export const EntityContexts = S.Struct({ + metadata: PipelineMetadata, + entities: MutableArray(EntityContext), +}); +export type EntityContexts = typeof EntityContexts.Type; -export interface EntityContexts { - metadata: PipelineMetadata; - entities: EntityContext[]; -} +export const Triples = S.Struct({ + metadata: PipelineMetadata, + triples: MutableArray(Triple), +}); +export type Triples = typeof Triples.Type; -export interface Triples { - metadata: PipelineMetadata; - triples: Triple[]; -} +// Document metadata +export const DocumentMetadata = S.Struct({ + id: S.String, + time: S.Number, + kind: S.String, + title: S.String, + comments: S.String, + user: S.String, + tags: StringArray, + parentId: S.optionalKey(S.String), + documentType: S.String, + metadata: OptionalMutableArray(Triple), +}); +export type DocumentMetadata = typeof DocumentMetadata.Type; -// ---------- Document metadata ---------- +export const ProcessingMetadata = S.Struct({ + id: S.String, + documentId: S.String, + time: S.Number, + flow: S.String, + user: S.String, + collection: S.String, + tags: StringArray, +}); +export type ProcessingMetadata = typeof ProcessingMetadata.Type; -export interface DocumentMetadata { - id: string; - time: number; - kind: string; - title: string; - comments: string; - user: string; - tags: string[]; - parentId?: string; - documentType: string; // "source" | "page" | "chunk" | "extracted" - metadata?: Triple[]; -} +// Librarian +export const LibrarianOperation = S.Literals([ + "add-document", + "remove-document", + "list-documents", + "get-document-metadata", + "get-document-content", + "add-child-document", + "list-children", + "add-processing", + "remove-processing", + "list-processing", +]); +export type LibrarianOperation = typeof LibrarianOperation.Type; -export interface ProcessingMetadata { - id: string; - documentId: string; - time: number; - flow: string; - user: string; - collection: string; - tags: string[]; -} +export const LibrarianRequest = S.Struct({ + operation: LibrarianOperation, + documentId: S.optionalKey(S.String), + processingId: S.optionalKey(S.String), + documentMetadata: S.optionalKey(DocumentMetadata), + processingMetadata: S.optionalKey(ProcessingMetadata), + content: S.optionalKey(S.String), + user: S.optionalKey(S.String), + collection: S.optionalKey(S.String), +}); +export type LibrarianRequest = typeof LibrarianRequest.Type; -// ---------- Librarian ---------- +export const LibrarianResponse = S.Struct({ + error: S.optionalKey(TgError), + documentMetadata: S.optionalKey(DocumentMetadata), + content: S.optionalKey(S.String), + documents: OptionalMutableArray(DocumentMetadata), + processing: OptionalMutableArray(ProcessingMetadata), +}); +export type LibrarianResponse = typeof LibrarianResponse.Type; -export type LibrarianOperation = - | "add-document" - | "remove-document" - | "list-documents" - | "get-document-metadata" - | "get-document-content" - | "add-child-document" - | "list-children" - | "add-processing" - | "remove-processing" - | "list-processing"; +// Knowledge core +export const KnowledgeOperation = S.Literals([ + "list-kg-cores", + "get-kg-core", + "delete-kg-core", + "put-kg-core", + "load-kg-core", +]); +export type KnowledgeOperation = typeof KnowledgeOperation.Type; -export interface LibrarianRequest { - operation: LibrarianOperation; - documentId?: string; - processingId?: string; - documentMetadata?: DocumentMetadata; - processingMetadata?: ProcessingMetadata; - content?: string; // base64 - user?: string; - collection?: string; -} +const GraphEmbedding = S.Struct({ + entity: Term, + vectors: NumberArrays, +}); -export interface LibrarianResponse { - error?: TgError; - documentMetadata?: DocumentMetadata; - content?: string; // base64 - documents?: DocumentMetadata[]; - processing?: ProcessingMetadata[]; -} +export const KnowledgeRequest = S.Struct({ + operation: KnowledgeOperation, + user: S.optionalKey(S.String), + id: S.optionalKey(S.String), + flow: S.optionalKey(S.String), + collection: S.optionalKey(S.String), + triples: OptionalMutableArray(Triple), + graphEmbeddings: OptionalMutableArray(GraphEmbedding), +}); +export type KnowledgeRequest = typeof KnowledgeRequest.Type; -// ---------- Knowledge core ---------- +export const KnowledgeResponse = S.Struct({ + error: S.optionalKey(TgError), + ids: S.optionalKey(StringArray), + eos: S.optionalKey(S.Boolean), + triples: OptionalMutableArray(Triple), + graphEmbeddings: OptionalMutableArray(GraphEmbedding), +}); +export type KnowledgeResponse = typeof KnowledgeResponse.Type; -export type KnowledgeOperation = - | "list-kg-cores" - | "get-kg-core" - | "delete-kg-core" - | "put-kg-core" - | "load-kg-core"; +// Collection management +export const CollectionOperation = S.Literals([ + "list-collections", + "update-collection", + "delete-collection", +]); +export type CollectionOperation = typeof CollectionOperation.Type; -export interface KnowledgeRequest { - operation: KnowledgeOperation; - user?: string; - id?: string; - flow?: string; - collection?: string; - triples?: Triple[]; - graphEmbeddings?: { entity: Term; vectors: number[][] }[]; -} +const CollectionEntry = S.Struct({ + user: S.String, + collection: S.String, + name: S.String, + description: S.String, + tags: StringArray, +}); -export interface KnowledgeResponse { - error?: TgError; - ids?: string[]; - eos?: boolean; - triples?: Triple[]; - graphEmbeddings?: { entity: Term; vectors: number[][] }[]; -} +export const CollectionManagementRequest = S.Struct({ + operation: CollectionOperation, + user: S.optionalKey(S.String), + collection: S.optionalKey(S.String), + name: S.optionalKey(S.String), + description: S.optionalKey(S.String), + tags: S.optionalKey(StringArray), +}); +export type CollectionManagementRequest = typeof CollectionManagementRequest.Type; -// ---------- Collection management ---------- +export const CollectionManagementResponse = S.Struct({ + error: S.optionalKey(TgError), + collections: OptionalMutableArray(CollectionEntry), +}); +export type CollectionManagementResponse = typeof CollectionManagementResponse.Type; -export type CollectionOperation = - | "list-collections" - | "update-collection" - | "delete-collection"; +// Tool invocation (MCP tools) +export const ToolRequest = S.Struct({ + name: S.String, + parameters: S.String, +}); +export type ToolRequest = typeof ToolRequest.Type; -export interface CollectionManagementRequest { - operation: CollectionOperation; - user?: string; - collection?: string; - name?: string; - description?: string; - tags?: string[]; -} +export const ToolResponse = S.Struct({ + error: S.optionalKey(TgError), + text: S.optionalKey(S.String), + object: S.optionalKey(S.String), +}); +export type ToolResponse = typeof ToolResponse.Type; -export interface CollectionManagementResponse { - error?: TgError; - collections?: { user: string; collection: string; name: string; description: string; tags: string[] }[]; -} +// Flow management +export const FlowRequest = S.StructWithRest( + S.Struct({ + operation: S.String, + }), + [UnknownRecord], +); +export type FlowRequest = typeof FlowRequest.Type; -// ---------- Tool invocation (MCP tools) ---------- +export const FlowResponse = S.StructWithRest( + S.Struct({ + error: S.optionalKey(TgError), + }), + [UnknownRecord], +); +export type FlowResponse = typeof FlowResponse.Type; -export interface ToolRequest { - name: string; - parameters: string; // JSON-encoded -} - -export interface ToolResponse { - error?: TgError; - text?: string; // Plain text response - object?: string; // JSON-encoded structured response -} - -// ---------- Flow management ---------- - -// Flow request/response use kebab-case wire format to match the client. -// Access fields via bracket notation: request["flow-id"] -export interface FlowRequest { - operation: string; - [key: string]: unknown; -} - -export interface FlowResponse { - error?: TgError; - [key: string]: unknown; -} +export const ServiceMessageSchemas = { + TextCompletionRequest, + TextCompletionResponse, + EmbeddingsRequest, + EmbeddingsResponse, + GraphRagRequest, + GraphRagResponse, + DocumentRagRequest, + DocumentRagResponse, + AgentRequest, + AgentResponse, + TriplesQueryRequest, + TriplesQueryResponse, + GraphEmbeddingsRequest, + GraphEmbeddingsResponse, + DocumentEmbeddingsRequest, + DocumentEmbeddingsResponse, + ConfigRequest, + ConfigResponse, + PromptRequest, + PromptResponse, + LibrarianRequest, + LibrarianResponse, + KnowledgeRequest, + KnowledgeResponse, + CollectionManagementRequest, + CollectionManagementResponse, + ToolRequest, + ToolResponse, + FlowRequest, + FlowResponse, +} as const; diff --git a/ts/packages/base/src/schema/primitives.ts b/ts/packages/base/src/schema/primitives.ts index 72a017c1..5a42a287 100644 --- a/ts/packages/base/src/schema/primitives.ts +++ b/ts/packages/base/src/schema/primitives.ts @@ -1,72 +1,102 @@ /** - * Core data types mirroring the Python schema primitives. + * Schema-backed core data types mirroring the Python schema primitives. * * Python reference: trustgraph-base/trustgraph/schema/core/primitives.py */ -export interface TgError { - type: string; - message: string; -} +import * as S from "effect/Schema"; -// RDF Term types — discriminated union -export type TermType = "IRI" | "BLANK" | "LITERAL" | "TRIPLE"; +export const TgError = S.Struct({ + type: S.String, + message: S.String, +}); +export type TgError = typeof TgError.Type; -export interface IriTerm { - type: "IRI"; - iri: string; -} +export const TermType = S.Literals([ + "IRI", + "BLANK", + "LITERAL", + "TRIPLE", +]); +export type TermType = typeof TermType.Type; -export interface BlankTerm { - type: "BLANK"; - id: string; -} +export const IriTerm = S.Struct({ + type: S.tag("IRI"), + iri: S.String, +}); +export type IriTerm = typeof IriTerm.Type; -export interface LiteralTerm { - type: "LITERAL"; - value: string; - datatype?: string; - language?: string; -} +export const BlankTerm = S.Struct({ + type: S.tag("BLANK"), + id: S.String, +}); +export type BlankTerm = typeof BlankTerm.Type; -export interface TripleTerm { - type: "TRIPLE"; - triple: Triple; -} +export const LiteralTerm = S.Struct({ + type: S.tag("LITERAL"), + value: S.String, + datatype: S.optionalKey(S.String), + language: S.optionalKey(S.String), +}); +export type LiteralTerm = typeof LiteralTerm.Type; export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm; +export type Triple = { + readonly s: Term; + readonly p: Term; + readonly o: Term; + readonly g?: Term; +}; -export interface Triple { - s: Term; - p: Term; - o: Term; - g?: Term; // Named graph (optional quad) +export const Triple: S.Codec = S.suspend(() => + S.Struct({ + s: Term, + p: Term, + o: Term, + g: S.optionalKey(Term), + }) +); + +export const TripleTerm: S.Codec = S.suspend(() => + S.Struct({ + type: S.tag("TRIPLE"), + triple: Triple, + }) +); +export interface TripleTerm { + readonly type: "TRIPLE"; + readonly triple: Triple; } -export interface Field { - name: string; - type: string; - description?: string; -} +export const Term: S.Codec = S.suspend(() => S.Union([IriTerm, BlankTerm, LiteralTerm, TripleTerm])); -export interface RowSchema { - name: string; - description?: string; - fields: Field[]; -} +export const Field = S.Struct({ + name: S.String, + type: S.String, + description: S.optionalKey(S.String), +}); +export type Field = typeof Field.Type; -// LLM-related types -export interface LlmResult { - text: string; - inToken: number; - outToken: number; - model: string; -} +export const RowSchema = S.Struct({ + name: S.String, + description: S.optionalKey(S.String), + fields: S.Array(Field).pipe(S.mutable), +}); +export type RowSchema = typeof RowSchema.Type; -export interface LlmChunk { - text: string; - inToken: number | null; - outToken: number | null; - model: string; - isFinal: boolean; -} +export const LlmResult = S.Struct({ + text: S.String, + inToken: S.Number, + outToken: S.Number, + model: S.String, +}); +export type LlmResult = typeof LlmResult.Type; + +export const LlmChunk = S.Struct({ + text: S.String, + inToken: S.NullOr(S.Number), + outToken: S.NullOr(S.Number), + model: S.String, + isFinal: S.Boolean, +}); +export type LlmChunk = typeof LlmChunk.Type; diff --git a/ts/packages/base/src/services/embeddings-service.ts b/ts/packages/base/src/services/embeddings-service.ts index 9f405017..e2538e2c 100644 --- a/ts/packages/base/src/services/embeddings-service.ts +++ b/ts/packages/base/src/services/embeddings-service.ts @@ -1,54 +1,82 @@ /** - * Base embeddings service. + * Embeddings capability contract and message-bus adapter. * * Python reference: trustgraph-base/trustgraph/base/embeddings_service.py */ -import { FlowProcessor } from "../processor/flow-processor.js"; -import { ConsumerSpec } from "../spec/consumer-spec.js"; -import { ProducerSpec } from "../spec/producer-spec.js"; -import { ParameterSpec } from "../spec/parameter-spec.js"; -import type { ProcessorConfig } from "../processor/async-processor.js"; +import { Context, Effect } from "effect"; +import { + errorMessage, + type EmbeddingsError, + type FlowResourceNotFoundError, + type MessagingDeliveryError, +} from "../errors.js"; import type { FlowContext } from "../messaging/consumer.js"; +import { FlowProcessor } from "../processor/flow-processor.js"; +import type { ProcessorConfig } from "../processor/async-processor.js"; import type { EmbeddingsRequest, EmbeddingsResponse } from "../schema/messages.js"; +import { ConsumerSpec } from "../spec/consumer-spec.js"; +import { ParameterSpec } from "../spec/parameter-spec.js"; +import { ProducerSpec } from "../spec/producer-spec.js"; -export abstract class EmbeddingsService extends FlowProcessor { - protected constructor(config: ProcessorConfig) { +export interface EmbeddingsServiceShape { + readonly embed: ( + texts: ReadonlyArray, + model?: string, + ) => Effect.Effect; +} + +export class Embeddings extends Context.Service()( + "@trustgraph/base/services/embeddings-service/Embeddings", +) {} + +export class EmbeddingsService extends FlowProcessor { + constructor(config: ProcessorConfig) { super(config); this.registerSpecification( - new ConsumerSpec( + new ConsumerSpec( "embeddings-request", - this.onRequest.bind(this), + this.onRequestEffect.bind(this), ), ); this.registerSpecification(new ProducerSpec("embeddings-response")); this.registerSpecification(new ParameterSpec("model")); } - private async onRequest( + private onRequestEffect( msg: EmbeddingsRequest, properties: Record, - flowCtx: FlowContext, - ): Promise { + flowCtx: FlowContext, + ): Effect.Effect { const requestId = properties.id; - if (!requestId) return; - - const responseProducer = flowCtx.flow.producer("embeddings-response"); - - try { - const vectors = await this.onEmbeddings(msg.text, msg.model); - await responseProducer.send(requestId, { vectors }); - } catch (err) { - console.error(`[EmbeddingsService] Error processing request:`, err); - - const message = err instanceof Error ? err.message : String(err); - await responseProducer.send(requestId, { - vectors: [], - error: { type: "embeddings-error", message }, - }); + if (requestId === undefined || requestId.length === 0) { + return Effect.void; } - } - abstract onEmbeddings(texts: string[], model?: string): Promise; + return Effect.gen(function* () { + const responseProducer = yield* flowCtx.flow.producerEffect("embeddings-response"); + const embeddings = yield* Embeddings; + const response = yield* embeddings.embed(msg.text, msg.model).pipe( + Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse), + Effect.catch((error) => + Effect.logError("[EmbeddingsService] Error processing request", { + error: errorMessage(error), + operation: error.operation, + provider: error.provider ?? "unknown", + }).pipe( + Effect.as({ + vectors: [], + error: { + type: "embeddings-error", + message: errorMessage(error), + }, + } satisfies EmbeddingsResponse), + ), + ), + ); + + yield* responseProducer.send(requestId, response); + }); + } } diff --git a/ts/packages/base/src/services/index.ts b/ts/packages/base/src/services/index.ts index b0788a9e..ece3b441 100644 --- a/ts/packages/base/src/services/index.ts +++ b/ts/packages/base/src/services/index.ts @@ -1,2 +1,6 @@ export { LlmService } from "./llm-service.js"; -export { EmbeddingsService } from "./embeddings-service.js"; +export { + Embeddings, + EmbeddingsService, + type EmbeddingsServiceShape, +} from "./embeddings-service.js"; diff --git a/ts/packages/base/src/services/llm-service.ts b/ts/packages/base/src/services/llm-service.ts index a58ff739..0a7e8b7b 100644 --- a/ts/packages/base/src/services/llm-service.ts +++ b/ts/packages/base/src/services/llm-service.ts @@ -22,7 +22,7 @@ export abstract class LlmService extends FlowProcessor { super(config); this.registerSpecification( - new ConsumerSpec( + ConsumerSpec.fromPromise( "text-completion-request", this.onRequest.bind(this), ), @@ -36,50 +36,52 @@ export abstract class LlmService extends FlowProcessor { msg: TextCompletionRequest, properties: Record, flowCtx: FlowContext, - ): Promise { - const requestId = properties.id; - if (!requestId) return; + ): Promise { + const requestId = properties.id; + if (requestId === undefined || requestId.length === 0) return; - const responseProducer = flowCtx.flow.producer("text-completion-response"); + const responseProducer = flowCtx.flow.producer("text-completion-response"); - try { - if (msg.streaming && this.supportsStreaming()) { - for await (const chunk of this.generateContentStream( - msg.system, - msg.prompt, - msg.model, - msg.temperature, - )) { - await responseProducer.send( - requestId, - { + try { + if (msg.streaming === true && this.supportsStreaming()) { + for await (const chunk of this.generateContentStream( + msg.system, + msg.prompt, + msg.model, + msg.temperature, + )) { + const response = { response: chunk.text, - model: chunk.model, - inToken: chunk.inToken ?? undefined, - outToken: chunk.outToken ?? undefined, + ...(chunk.model !== undefined ? { model: chunk.model } : {}), + ...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}), + ...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}), endOfStream: chunk.isFinal, - } - ); - } - } else { + }; + await responseProducer.send( + requestId, + response + ); + } + } else { const result = await this.generateContent( msg.system, msg.prompt, msg.model, msg.temperature, - ); - - await responseProducer.send( - requestId, - { + ); + const response = { response: result.text, - model: result.model, - inToken: result.inToken, - outToken: result.outToken, + ...(result.model !== undefined ? { model: result.model } : {}), + ...(result.inToken !== undefined ? { inToken: result.inToken } : {}), + ...(result.outToken !== undefined ? { outToken: result.outToken } : {}), endOfStream: true, - } - ); - } + }; + + await responseProducer.send( + requestId, + response + ); + } } catch (err) { console.error( `[LlmService] Error processing request:`, diff --git a/ts/packages/base/src/spec/consumer-spec.ts b/ts/packages/base/src/spec/consumer-spec.ts index d74b7e41..6cd9e1a6 100644 --- a/ts/packages/base/src/spec/consumer-spec.ts +++ b/ts/packages/base/src/spec/consumer-spec.ts @@ -4,29 +4,84 @@ * Python reference: trustgraph-base/trustgraph/base/consumer_spec.py */ +import { Effect } from "effect"; +import * as S from "effect/Schema"; import type { Spec } from "./types.js"; +import type { SpecRuntimeRequirements } from "./types.js"; import type { PubSubBackend } from "../backend/types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; -import { Consumer, type MessageHandler } from "../messaging/consumer.js"; +import { type MessageHandler } from "../messaging/consumer.js"; +import { + ConsumerFactory, + type EffectMessageHandler, +} from "../messaging/runtime.js"; +import { + messagingHandlerError, + TooManyRequestsError, + type MessagingHandlerError, + type PubSubError, +} from "../errors.js"; + +const isTooManyRequestsError = S.is(TooManyRequestsError); + +export class ConsumerSpec implements Spec { + public readonly name: string; + private readonly handler: EffectMessageHandler; + private readonly concurrency: number; -export class ConsumerSpec implements Spec { constructor( - public readonly name: string, - private readonly handler: MessageHandler, - private readonly concurrency = 1, - ) {} + name: string, + handler: EffectMessageHandler, + concurrency = 1, + ) { + this.name = name; + this.handler = handler; + this.concurrency = concurrency; + } + + static fromPromise( + name: string, + handler: MessageHandler, + concurrency = 1, + ): ConsumerSpec { + return new ConsumerSpec( + name, + (message, properties, flow) => + Effect.tryPromise({ + try: () => handler(message, properties, flow), + catch: (error) => + isTooManyRequestsError(error) + ? error + : messagingHandlerError(name, `${flow.id}-${flow.name}-${name}`, error), + }), + concurrency, + ); + } + + addEffect(flow: Flow, definition: FlowDefinition) { + const spec = this; + return Effect.gen(function* () { + const topic = definition.topics?.[spec.name] ?? spec.name; + const factory = yield* ConsumerFactory; + const consumer = yield* factory.run( + { + topic, + subscription: `${flow.processorId}-${flow.name}-${spec.name}`, + handler: spec.handler, + concurrency: spec.concurrency, + }, + { id: flow.processorId, name: flow.name, flow }, + ); + flow.registerConsumer(spec.name, consumer); + }); + } async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise { - const topic = definition.topics?.[this.name] ?? this.name; - - const consumer = new Consumer({ - pubsub, - topic, - subscription: `${flow.processorId}-${flow.name}-${this.name}`, - handler: this.handler, - concurrency: this.concurrency, - }); - - flow.registerConsumer(this.name, consumer as Consumer); + const effect = this.addEffect(flow, definition) as Effect.Effect< + void, + PubSubError, + SpecRuntimeRequirements + >; + await flow.runInCompatibilityScope(effect, pubsub); } } diff --git a/ts/packages/base/src/spec/index.ts b/ts/packages/base/src/spec/index.ts index 6c24fe39..3395f113 100644 --- a/ts/packages/base/src/spec/index.ts +++ b/ts/packages/base/src/spec/index.ts @@ -1,4 +1,4 @@ -export type { Spec } from "./types.js"; +export type { Spec, SpecRuntimeError, SpecRuntimeRequirements } from "./types.js"; export { ConsumerSpec } from "./consumer-spec.js"; export { ProducerSpec } from "./producer-spec.js"; export { ParameterSpec } from "./parameter-spec.js"; diff --git a/ts/packages/base/src/spec/parameter-spec.ts b/ts/packages/base/src/spec/parameter-spec.ts index 5117a2cc..94797f47 100644 --- a/ts/packages/base/src/spec/parameter-spec.ts +++ b/ts/packages/base/src/spec/parameter-spec.ts @@ -4,15 +4,27 @@ * Python reference: trustgraph-base/trustgraph/base/parameter_spec.py */ +import { Effect } from "effect"; import type { Spec } from "./types.js"; import type { PubSubBackend } from "../backend/types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; export class ParameterSpec implements Spec { - constructor(public readonly name: string) {} + public readonly name: string; + + constructor(name: string) { + this.name = name; + } + + addEffect(flow: Flow, definition: FlowDefinition) { + const spec = this; + return Effect.sync(() => { + const value = definition.parameters?.[spec.name]; + flow.setParameter(spec.name, value); + }); + } async add(flow: Flow, _pubsub: PubSubBackend, definition: FlowDefinition): Promise { - const value = definition.parameters?.[this.name]; - flow.setParameter(this.name, value); + await Effect.runPromise(this.addEffect(flow, definition)); } } diff --git a/ts/packages/base/src/spec/producer-spec.ts b/ts/packages/base/src/spec/producer-spec.ts index 1ced8430..b5ebf88e 100644 --- a/ts/packages/base/src/spec/producer-spec.ts +++ b/ts/packages/base/src/spec/producer-spec.ts @@ -4,18 +4,33 @@ * Python reference: trustgraph-base/trustgraph/base/producer_spec.py */ +import { Effect } from "effect"; import type { Spec } from "./types.js"; import type { PubSubBackend } from "../backend/types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; -import { Producer } from "../messaging/producer.js"; +import { + ProducerFactory, + type EffectProducer, +} from "../messaging/runtime.js"; export class ProducerSpec implements Spec { - constructor(public readonly name: string) {} + public readonly name: string; + + constructor(name: string) { + this.name = name; + } + + addEffect(flow: Flow, definition: FlowDefinition) { + const spec = this; + return Effect.gen(function* () { + const topic = definition.topics?.[spec.name] ?? spec.name; + const factory = yield* ProducerFactory; + const producer = yield* factory.make({ topic }); + flow.registerProducer(spec.name, producer as EffectProducer); + }); + } async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise { - const topic = definition.topics?.[this.name] ?? this.name; - const producer = new Producer(pubsub, topic); - await producer.start(); - flow.registerProducer(this.name, producer as Producer); + await flow.runInCompatibilityScope(this.addEffect(flow, definition), pubsub); } } diff --git a/ts/packages/base/src/spec/request-response-spec.ts b/ts/packages/base/src/spec/request-response-spec.ts index be4db8fa..229213cf 100644 --- a/ts/packages/base/src/spec/request-response-spec.ts +++ b/ts/packages/base/src/spec/request-response-spec.ts @@ -7,30 +7,46 @@ * Python reference: trustgraph-base/trustgraph/base/prompt_client_spec.py */ +import { Effect } from "effect"; import type { Spec } from "./types.js"; import type { PubSubBackend } from "../backend/types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; -import { RequestResponse } from "../messaging/request-response.js"; +import { + RequestResponseFactory, + type EffectRequestResponse, +} from "../messaging/runtime.js"; export class RequestResponseSpec implements Spec { + public readonly name: string; + private readonly requestTopicName: string; + private readonly responseTopicName: string; + constructor( - public readonly name: string, - private readonly requestTopicName: string, - private readonly responseTopicName: string, - ) {} + name: string, + requestTopicName: string, + responseTopicName: string, + ) { + this.name = name; + this.requestTopicName = requestTopicName; + this.responseTopicName = responseTopicName; + } + + addEffect(flow: Flow, definition: FlowDefinition) { + const spec = this; + return Effect.gen(function* () { + const requestTopic = definition.topics?.[spec.requestTopicName] ?? spec.requestTopicName; + const responseTopic = definition.topics?.[spec.responseTopicName] ?? spec.responseTopicName; + const factory = yield* RequestResponseFactory; + const requestor = yield* factory.make({ + requestTopic, + responseTopic, + subscription: `${flow.processorId}-${flow.name}-${spec.name}`, + }); + flow.registerRequestor(spec.name, requestor as EffectRequestResponse); + }); + } async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise { - const requestTopic = definition.topics?.[this.requestTopicName] ?? this.requestTopicName; - const responseTopic = definition.topics?.[this.responseTopicName] ?? this.responseTopicName; - - const rr = new RequestResponse({ - pubsub, - requestTopic, - responseTopic, - subscription: `${flow.processorId}-${flow.name}-${this.name}`, - }); - await rr.start(); - - flow.registerRequestor(this.name, rr as RequestResponse); + await flow.runInCompatibilityScope(this.addEffect(flow, definition), pubsub); } } diff --git a/ts/packages/base/src/spec/types.ts b/ts/packages/base/src/spec/types.ts index 27be5ec3..5f9c157f 100644 --- a/ts/packages/base/src/spec/types.ts +++ b/ts/packages/base/src/spec/types.ts @@ -4,10 +4,29 @@ * Python reference: trustgraph-base/trustgraph/base/spec.py and siblings */ +import type { Effect, Scope } from "effect"; import type { PubSubBackend } from "../backend/types.js"; +import type { + ConsumerFactory, + ProducerFactory, + RequestResponseFactory, +} from "../messaging/runtime.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; +import type { PubSubError } from "../errors.js"; -export interface Spec { +export type SpecRuntimeRequirements = + | Scope.Scope + | ProducerFactory + | ConsumerFactory + | RequestResponseFactory; + +export type SpecRuntimeError = PubSubError; + +export interface Spec { name: string; + addEffect( + flow: Flow, + definition: FlowDefinition, + ): Effect.Effect; add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise; } diff --git a/ts/packages/base/tsconfig.json b/ts/packages/base/tsconfig.json index 6560dc56..f9a03bcc 100644 --- a/ts/packages/base/tsconfig.json +++ b/ts/packages/base/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", + "types": ["node"], "composite": true }, "include": ["src"], diff --git a/ts/packages/base/vitest.config.ts b/ts/packages/base/vitest.config.ts index 83d22186..2d4eea7f 100644 --- a/ts/packages/base/vitest.config.ts +++ b/ts/packages/base/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + exclude: ["dist/**", "node_modules/**"], globals: true, }, }); diff --git a/ts/packages/cli/package.json b/ts/packages/cli/package.json index d1b91b49..cab4f090 100644 --- a/ts/packages/cli/package.json +++ b/ts/packages/cli/package.json @@ -6,20 +6,22 @@ "tg": "dist/index.js" }, "scripts": { - "build": "tsc", + "build": "bunx --bun tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "test": "vitest run --passWithNoTests" + "test": "bunx --bun vitest run --passWithNoTests" }, "dependencies": { "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", "commander": "^13.1.0", + "effect": "4.0.0-beta.65", "ws": "^8.18.0" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/ws": "^8.5.0", "typescript": "^5.8.0", - "vitest": "^3.1.0" + "vitest": "^4.1.6" } } diff --git a/ts/packages/cli/src/commands/agent.ts b/ts/packages/cli/src/commands/agent.ts index dd74f051..5a40ef2c 100644 --- a/ts/packages/cli/src/commands/agent.ts +++ b/ts/packages/cli/src/commands/agent.ts @@ -24,15 +24,15 @@ export function registerAgentCommands(program: Command): void { question, (chunk) => { // think — show thought process - if (chunk) process.stderr.write(chunk); + if (chunk.length > 0) process.stderr.write(chunk); }, (chunk) => { // observe — show observations - if (chunk) process.stderr.write(chunk); + if (chunk.length > 0) process.stderr.write(chunk); }, (chunk, complete) => { // answer — print to stdout - if (chunk) process.stdout.write(chunk); + if (chunk.length > 0) process.stdout.write(chunk); if (complete) { process.stdout.write("\n"); resolve(); diff --git a/ts/packages/cli/src/commands/flow.ts b/ts/packages/cli/src/commands/flow.ts index 346faaed..62add47e 100644 --- a/ts/packages/cli/src/commands/flow.ts +++ b/ts/packages/cli/src/commands/flow.ts @@ -58,8 +58,9 @@ export function registerFlowCommands(program: Command): void { try { const flows = socket.flows(); - const params = cmdOpts.parameters - ? JSON.parse(cmdOpts.parameters as string) + const rawParameters = cmdOpts.parameters as string | undefined; + const params = rawParameters !== undefined && rawParameters.length > 0 + ? JSON.parse(rawParameters) : undefined; const resp = await flows.startFlow( id, diff --git a/ts/packages/cli/src/commands/graph-rag.ts b/ts/packages/cli/src/commands/graph-rag.ts index ea883d87..cbede535 100644 --- a/ts/packages/cli/src/commands/graph-rag.ts +++ b/ts/packages/cli/src/commands/graph-rag.ts @@ -21,13 +21,14 @@ export function registerGraphRagCommands(program: Command): void { try { const flow = socket.flow(opts.flow); + const collection = cmdOpts.collection as string | undefined; const response = await flow.graphRag( query, { entityLimit: parseInt(cmdOpts.entityLimit, 10), tripleLimit: parseInt(cmdOpts.tripleLimit, 10), }, - cmdOpts.collection, + collection, ); console.log(response); } finally { @@ -47,10 +48,14 @@ export function registerGraphRagCommands(program: Command): void { try { const flow = socket.flow(opts.flow); + const docLimit = cmdOpts.docLimit as string | undefined; + const collection = cmdOpts.collection as string | undefined; const response = await flow.documentRag( query, - cmdOpts.docLimit ? parseInt(cmdOpts.docLimit, 10) : undefined, - cmdOpts.collection, + docLimit !== undefined && docLimit.length > 0 + ? parseInt(docLimit, 10) + : undefined, + collection, ); console.log(response); } finally { diff --git a/ts/packages/cli/src/commands/library.ts b/ts/packages/cli/src/commands/library.ts index ac9e9dfa..273fc7dc 100644 --- a/ts/packages/cli/src/commands/library.ts +++ b/ts/packages/cli/src/commands/library.ts @@ -4,11 +4,15 @@ * Manages documents stored in the TrustGraph library. */ -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; import type { Command } from "commander"; import { createSocket, getOpts } from "./util.js"; +function basenamePath(filepath: string): string { + const normalized = filepath.replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} + /** Simple MIME-type lookup by file extension. */ function guessMimeType(filepath: string): string { const ext = filepath.split(".").pop()?.toLowerCase(); @@ -69,10 +73,10 @@ export function registerLibraryCommands(program: Command): void { try { const lib = socket.librarian(); - const data = readFileSync(file); - const b64 = data.toString("base64"); + const data = new Uint8Array(await Bun.file(file).arrayBuffer()); + const b64 = Buffer.from(data).toString("base64"); const mimeType = (cmdOpts.mimeType as string | undefined) ?? guessMimeType(file); - const title = (cmdOpts.title as string | undefined) ?? basename(file); + const title = (cmdOpts.title as string | undefined) ?? basenamePath(file); const comments = cmdOpts.comments as string; const tags: string[] = (cmdOpts.tags as string[] | undefined) ?? []; diff --git a/ts/packages/cli/src/commands/triples.ts b/ts/packages/cli/src/commands/triples.ts index afbe84ef..6d439c6a 100644 --- a/ts/packages/cli/src/commands/triples.ts +++ b/ts/packages/cli/src/commands/triples.ts @@ -23,14 +23,17 @@ export function registerTriplesCommands(program: Command): void { try { const flow = socket.flow(opts.flow); - const s: Term | undefined = cmdOpts.subject - ? { t: "i", i: cmdOpts.subject as string } + const subject = cmdOpts.subject as string | undefined; + const predicate = cmdOpts.predicate as string | undefined; + const object = cmdOpts.object as string | undefined; + const s: Term | undefined = subject !== undefined && subject.length > 0 + ? { t: "i", i: subject } : undefined; - const p: Term | undefined = cmdOpts.predicate - ? { t: "i", i: cmdOpts.predicate as string } + const p: Term | undefined = predicate !== undefined && predicate.length > 0 + ? { t: "i", i: predicate } : undefined; - const o: Term | undefined = cmdOpts.object - ? { t: "i", i: cmdOpts.object as string } + const o: Term | undefined = object !== undefined && object.length > 0 + ? { t: "i", i: object } : undefined; const triples = await flow.triplesQuery( diff --git a/ts/packages/cli/src/commands/util.ts b/ts/packages/cli/src/commands/util.ts index cb582037..200f9981 100644 --- a/ts/packages/cli/src/commands/util.ts +++ b/ts/packages/cli/src/commands/util.ts @@ -15,7 +15,7 @@ export interface CliOpts { export function getOpts(cmd: Command): CliOpts { // Walk up to root command to get global options let root = cmd; - while (root.parent) root = root.parent; + while (root.parent !== null) root = root.parent; return root.opts() as CliOpts; } diff --git a/ts/packages/cli/tsconfig.json b/ts/packages/cli/tsconfig.json index 944bd1d6..b1757895 100644 --- a/ts/packages/cli/tsconfig.json +++ b/ts/packages/cli/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", + "types": ["node", "bun"], "composite": true }, "include": ["src"], diff --git a/ts/packages/client/package.json b/ts/packages/client/package.json index 7c562a94..4f154f50 100644 --- a/ts/packages/client/package.json +++ b/ts/packages/client/package.json @@ -6,10 +6,13 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", + "build": "bunx --bun tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "test": "vitest run" + "test": "bunx --bun vitest run" + }, + "dependencies": { + "effect": "4.0.0-beta.65" }, "peerDependencies": { "ws": "^8.0.0" @@ -20,10 +23,11 @@ } }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "typescript": "^5.8.0", - "vitest": "^3.1.0", + "vitest": "^4.1.6", "happy-dom": "^20.0.0" }, "license": "Apache-2.0" diff --git a/ts/packages/client/src/models/messages.ts b/ts/packages/client/src/models/messages.ts index 54679901..4444f9d0 100644 --- a/ts/packages/client/src/models/messages.ts +++ b/ts/packages/client/src/models/messages.ts @@ -1,4 +1,4 @@ -import { Triple, Term } from "./Triple.js"; +import type { Term, Triple } from "./Triple.js"; export type Request = object; export type Response = object; diff --git a/ts/packages/client/src/socket/service-call-multi.ts b/ts/packages/client/src/socket/service-call-multi.ts index eb0d4668..c5cba460 100644 --- a/ts/packages/client/src/socket/service-call-multi.ts +++ b/ts/packages/client/src/socket/service-call-multi.ts @@ -1,4 +1,4 @@ -import { RequestMessage } from "../models/messages.js"; +import type { RequestMessage } from "../models/messages.js"; import { WS_OPEN, WS_CONNECTING, type IsomorphicWebSocket } from "./websocket-adapter.js"; // Constant defining the delay before attempting to reconnect a WebSocket @@ -8,8 +8,14 @@ export const SOCKET_RECONNECTION_TIMEOUT = 2000; // Forward declare Socket type to avoid circular dependency // Using a minimal interface that matches what BaseApi provides interface Socket { - ws?: IsomorphicWebSocket; - inflight: { [key: string]: ServiceCallMulti }; + ws: IsomorphicWebSocket | null | undefined; + inflight: { + [key: string]: { + onReceived: (resp: object) => void; + retryNow: () => void; + error: (err: object | string) => void; + }; + }; reopen: () => void; getNextId?: () => string; user?: string; @@ -42,7 +48,7 @@ export class ServiceCallMulti { success: (resp: unknown) => void; error: (err: object | string) => void; receiver: (resp: unknown) => boolean; - timeoutId?: ReturnType; + timeoutId: ReturnType | undefined = undefined; timeout: number; retries: number; socket: Socket; @@ -121,7 +127,7 @@ export class ServiceCallMulti { } // Check if WebSocket connection is available and ready - if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) { + if (this.socket.ws !== null && this.socket.ws !== undefined && this.socket.ws.readyState === WS_OPEN) { try { this.socket.ws.send(JSON.stringify(this.msg)); this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout); @@ -148,7 +154,8 @@ export class ServiceCallMulti { // No WebSocket connection available or not ready // Check if socket is connecting if ( - this.socket.ws && + this.socket.ws !== null && + this.socket.ws !== undefined && this.socket.ws.readyState === WS_CONNECTING ) { // Wait a bit longer for connection to establish diff --git a/ts/packages/client/src/socket/service-call.ts b/ts/packages/client/src/socket/service-call.ts index efc96211..d2c582e3 100644 --- a/ts/packages/client/src/socket/service-call.ts +++ b/ts/packages/client/src/socket/service-call.ts @@ -1,4 +1,4 @@ -import { RequestMessage } from "../models/messages.js"; +import type { RequestMessage } from "../models/messages.js"; import { WS_OPEN, type IsomorphicWebSocket } from "./websocket-adapter.js"; // Constant defining the delay before attempting to reconnect a WebSocket @@ -8,8 +8,14 @@ export const SOCKET_RECONNECTION_TIMEOUT = 2000; // Forward declare Socket type to avoid circular dependency // Using a minimal interface that matches what BaseApi provides interface Socket { - ws?: IsomorphicWebSocket; - inflight: { [key: string]: ServiceCall }; + ws: IsomorphicWebSocket | null | undefined; + inflight: { + [key: string]: { + onReceived: (resp: object) => void; + retryNow: () => void; + error: (err: object | string) => void; + }; + }; reopen: () => void; getNextId?: () => string; user?: string; @@ -52,7 +58,7 @@ export class ServiceCall { msg: RequestMessage; // The request message success: (resp: unknown) => void; // Success callback error: (err: object | string) => void; // Error callback - timeoutId?: ReturnType; // Reference to the active timeout timer + timeoutId: ReturnType | undefined = undefined; // Reference to the active timeout timer timeout: number; // Timeout duration in milliseconds retries: number; // Remaining retry attempts socket: Socket; // WebSocket connection reference @@ -77,7 +83,10 @@ export class ServiceCall { */ onReceived(resp: object) { // Guard: ignore duplicate responses after completion - if (this.complete) return; + if (this.complete) { + console.log(this.mid, "should not happen, request is already complete"); + return; + } // Mark as complete to prevent duplicate processing this.complete = true; @@ -93,18 +102,18 @@ export class ServiceCall { let errorToHandle: unknown = null; // Check for direct error in response - if (resp && typeof resp === "object" && "error" in resp) { + if (resp !== null && typeof resp === "object" && "error" in resp) { errorToHandle = (resp as Record).error; } // Check for nested error under response property - else if (resp && typeof resp === "object" && "response" in resp) { + else if (resp !== null && typeof resp === "object" && "response" in resp) { const response = (resp as Record).response; - if (response && typeof response === "object" && "error" in response) { + if (response !== null && typeof response === "object" && "error" in response) { errorToHandle = (response as Record).error; } } - if (errorToHandle) { + if (errorToHandle !== null && errorToHandle !== undefined) { // Response contains an error - call error callback const errorObj = errorToHandle as Record; const errorMessage = @@ -151,7 +160,13 @@ export class ServiceCall { */ onTimeout() { // Guard: ignore timeout after completion - if (this.complete) return; + if (this.complete) { + console.log( + this.mid, + "timeout should not happen, request is already complete", + ); + return; + } console.log("Request", this.mid, "timed out"); @@ -180,7 +195,13 @@ export class ServiceCall { */ attempt() { // Guard: don't retry completed requests - if (this.complete) return; + if (this.complete) { + console.log( + this.mid, + "attempt should not be called, request is already complete", + ); + return; + } // Decrement retry counter this.retries--; @@ -197,7 +218,7 @@ export class ServiceCall { } // Check if WebSocket connection is available and ready - if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) { + if (this.socket.ws !== null && this.socket.ws !== undefined && this.socket.ws.readyState === WS_OPEN) { try { // Attempt to send the message as JSON this.socket.ws.send(JSON.stringify(this.msg)); diff --git a/ts/packages/client/src/socket/trustgraph-socket.ts b/ts/packages/client/src/socket/trustgraph-socket.ts index 945ec317..5060f247 100644 --- a/ts/packages/client/src/socket/trustgraph-socket.ts +++ b/ts/packages/client/src/socket/trustgraph-socket.ts @@ -1,5 +1,5 @@ // Import core types and classes for the TrustGraph API -import { Triple, Term } from "../models/Triple.js"; +import type { Term, Triple } from "../models/Triple.js"; import { ServiceCallMulti } from "./service-call-multi.js"; import { ServiceCall } from "./service-call.js"; import { @@ -16,7 +16,7 @@ import { } from "./websocket-adapter.js"; // Import all message types for different services -import { +import type { AgentRequest, AgentResponse, ConfigRequest, @@ -52,6 +52,7 @@ import { PromptResponse, // ProcessingMetadata, RequestMessage, + ResponseError, StructuredQueryRequest, StructuredQueryResponse, TextCompletionRequest, @@ -110,6 +111,60 @@ 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 { + return value !== undefined && value.length > 0; +} + +function withDefault(value: string | undefined, fallback: string): string { + return isNonEmptyString(value) ? value : fallback; +} + +function toErrorMessage(value: unknown, fallback: string): string { + if (value instanceof Error) { + return value.message; + } + if (typeof value === "string" && value.length > 0) { + return value; + } + if (value !== null && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message; + if (typeof message === "string" && message.length > 0) { + return message; + } + } + return fallback; +} + +function streamingMetadataFrom(source: { + in_token?: number; + out_token?: number; + model?: string; +}): StreamingMetadata | undefined { + const metadata: StreamingMetadata = {}; + let hasMetadata = false; + + if (source.in_token !== undefined) { + metadata.in_token = source.in_token; + hasMetadata = true; + } + if (source.out_token !== undefined) { + metadata.out_token = source.out_token; + hasMetadata = true; + } + if (source.model !== undefined) { + metadata.model = source.model; + hasMetadata = true; + } + + return hasMetadata ? metadata : undefined; +} + +function throwIfResponseError(error: ResponseError | undefined): void { + if (error !== undefined) { + throw new Error(error.message); + } +} + /** * Socket interface defining all available operations for the TrustGraph API * This provides a unified interface for various AI/ML and knowledge graph @@ -242,33 +297,33 @@ export interface ConnectionState { } export class BaseApi { - ws?: IsomorphicWebSocket; // WebSocket connection instance + ws: IsomorphicWebSocket | undefined = undefined; // WebSocket connection instance tag: string; // Unique client identifier id: number; // Counter for generating unique message IDs - token?: string; // Optional authentication token + token: string | undefined; // Optional authentication token user: string; // User identifier for API requests socketUrl: string; // WebSocket URL - inflight: { [key: string]: ServiceCall } = {}; // Track active requests by + 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; // Timer for reconnection attempts + reconnectTimer: number | undefined = undefined; // Timer for reconnection attempts reconnectionState: "idle" | "reconnecting" | "failed" = "idle"; // Connection state // Connection state tracking for UI private connectionStateListeners: ((state: ConnectionState) => void)[] = []; - private lastError?: string; + private lastError: string | undefined = undefined; constructor(user: string, token?: string, socketUrl?: string) { this.tag = makeid(16); // Generate unique client tag this.id = 1; // Start message ID counter this.token = token; // Store authentication token this.user = user; // Store user identifier - this.socketUrl = socketUrl || SOCKET_URL; // Use provided URL or default + this.socketUrl = withDefault(socketUrl, SOCKET_URL); // Use provided URL or default console.log( "SOCKET: opening socket...", - token ? "with auth" : "without auth", + isNonEmptyString(token) ? "with auth" : "without auth", "user:", user, ); @@ -297,12 +352,12 @@ export class BaseApi { * Get current connection state */ private getConnectionState(): ConnectionState { - const hasApiKey = !!this.token; + const hasApiKey = isNonEmptyString(this.token); // Determine status based on WebSocket state and reconnection state let status: ConnectionState["status"]; - if (!this.ws || this.ws.readyState === WS_CLOSED) { + if (this.ws === undefined || this.ws.readyState === WS_CLOSED) { if (this.reconnectionState === "failed") { status = "failed"; } else if (this.reconnectionState === "reconnecting") { @@ -321,8 +376,10 @@ export class BaseApi { const state: ConnectionState = { status, hasApiKey, - lastError: this.lastError, }; + if (this.lastError !== undefined) { + state.lastError = this.lastError; + } // Add reconnection details if applicable if (status === "reconnecting") { @@ -353,7 +410,7 @@ export class BaseApi { openSocket() { // Don't create multiple connections if ( - this.ws && + this.ws !== undefined && (this.ws.readyState === WS_CONNECTING || this.ws.readyState === WS_OPEN) ) { @@ -361,7 +418,7 @@ export class BaseApi { } // Clean up old socket if exists - if (this.ws) { + if (this.ws !== undefined) { this.ws.removeEventListener("message", this.onMessage); this.ws.removeEventListener("close", this.onClose); this.ws.removeEventListener("open", this.onOpen); @@ -371,7 +428,7 @@ export class BaseApi { try { // Build WebSocket URL with optional token parameter - const wsUrl = this.token + const wsUrl = isNonEmptyString(this.token) ? `${this.socketUrl}?token=${this.token}` : this.socketUrl; console.log( @@ -401,18 +458,21 @@ export class BaseApi { // Handle incoming messages from server onMessage(message: WsMessageEvent) { - if (!message.data) return; + if (message.data === undefined || message.data === null || message.data === "") return; try { - const obj = JSON.parse(String(message.data)); + const obj: unknown = JSON.parse(String(message.data)); // Skip messages without ID (can't route them) - if (!obj.id) return; + 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 - if (this.inflight[obj.id]) { + const call = this.inflight[id]; + if (call !== undefined) { // Pass the whole message object so receiver can access 'complete' flag - this.inflight[obj.id].onReceived(obj); + call.onReceived(obj); } } catch (e) { console.error("[socket message parse error]", e); @@ -422,7 +482,7 @@ export class BaseApi { // Handle connection closure - automatically attempt reconnection onClose(event: WsCloseEvent) { console.log("[socket close]", event.code, event.reason); - this.lastError = `Connection closed: ${event.reason || "Unknown reason"}`; + this.lastError = `Connection closed: ${event.reason.length > 0 ? event.reason : "Unknown reason"}`; this.ws = undefined; this.notifyStateChange(); this.scheduleReconnect(); @@ -436,7 +496,7 @@ export class BaseApi { this.lastError = undefined; // Clear any previous errors // Clear any pending reconnect timer - if (this.reconnectTimer) { + if (this.reconnectTimer !== undefined) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } @@ -468,7 +528,7 @@ export class BaseApi { } // Don't schedule if already scheduled - if (this.reconnectTimer) return; + if (this.reconnectTimer !== undefined) return; this.reconnectionState = "reconnecting"; this.reconnectAttempts++; @@ -510,7 +570,7 @@ export class BaseApi { console.log("[socket reopen]"); // Check if we're already connected or connecting if ( - this.ws && + this.ws !== undefined && (this.ws.readyState === WS_OPEN || this.ws.readyState === WS_CONNECTING) ) { @@ -524,13 +584,13 @@ export class BaseApi { */ close() { // Clear reconnection timer - if (this.reconnectTimer) { + if (this.reconnectTimer !== undefined) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } // Clean up WebSocket - if (this.ws) { + if (this.ws !== undefined) { // Remove event listeners to prevent memory leaks this.ws.removeEventListener("message", this.onMessage); this.ws.removeEventListener("close", this.onClose); @@ -577,8 +637,8 @@ export class BaseApi { const mid = this.getNextId(); // Set default values - if (timeout == undefined) timeout = 10000; - if (retries == undefined) retries = 3; + if (timeout === undefined) timeout = 10000; + if (retries === undefined) retries = 3; // Construct the request message const msg: RequestMessage = { @@ -588,7 +648,7 @@ export class BaseApi { }; // Add flow identifier if provided - if (flow) msg.flow = flow; + if (isNonEmptyString(flow)) msg.flow = flow; // Return a Promise that will be resolved/rejected by the ServiceCall return new Promise((resolve, reject) => { @@ -625,8 +685,8 @@ export class BaseApi { const mid = this.getNextId(); // Set defaults - if (timeout == undefined) timeout = 10000; - if (retries == undefined) retries = 3; + if (timeout === undefined) timeout = 10000; + if (retries === undefined) retries = 3; // Construct request message const msg: RequestMessage = { @@ -635,7 +695,7 @@ export class BaseApi { request: request, }; - if (flow) msg.flow = flow; + if (isNonEmptyString(flow)) msg.flow = flow; return new Promise((resolve, reject) => { const call = new ServiceCallMulti( @@ -645,7 +705,7 @@ export class BaseApi { reject as (err: object | string) => void, timeout, retries, - this as any, // eslint-disable-line @typescript-eslint/no-explicit-any + this, receiver, ); @@ -666,7 +726,7 @@ export class BaseApi { retries?: number, flow?: string, ) { - if (!flow) flow = "default"; + if (!isNonEmptyString(flow)) flow = "default"; return this.makeRequest( service, @@ -727,7 +787,7 @@ export class LibrarianApi { }, 60000, // 60 second timeout for potentially large lists ) - .then((r) => r["document-metadatas"] || []); + .then((r) => r["document-metadatas"] ?? []); } /** @@ -743,7 +803,7 @@ export class LibrarianApi { }, 60000, ) - .then((r) => r["processing-metadata"] || []); + .then((r) => r["processing-metadata"] ?? []); } /** @@ -762,7 +822,7 @@ export class LibrarianApi { }, 30000, ) - .then((r) => r["document-metadata"] || r.documentMetadata || null); + .then((r) => r["document-metadata"] ?? r.documentMetadata ?? null); } /** @@ -784,20 +844,26 @@ export class LibrarianApi { id?: string, metadata?: Triple[], ) { + const documentMetadata: DocumentMetadata = { + time: Math.floor(Date.now() / 1000), // Unix timestamp + kind: mimeType, + title, + comments, + user: this.api.user, + tags, + }; + if (id !== undefined) { + documentMetadata.id = id; + } + if (metadata !== undefined) { + documentMetadata.metadata = metadata; + } + return this.api.makeRequest( "librarian", { operation: "add-document", - documentMetadata: { - id: id, - time: Math.floor(Date.now() / 1000), // Unix timestamp - kind: mimeType, - title: title, - comments: comments, - metadata: metadata, - user: this.api.user, - tags: tags, - }, + documentMetadata, content: document, }, 30000, // 30 second timeout for document upload @@ -814,7 +880,7 @@ export class LibrarianApi { operation: "remove-document", "document-id": id, user: this.api.user, - collection: collection || "default", + collection: withDefault(collection, "default"), }, 30000, ); @@ -845,8 +911,8 @@ export class LibrarianApi { time: Math.floor(Date.now() / 1000), flow: flow, user: this.api.user, - collection: collection ? collection : "default", - tags: tags ? tags : [], + collection: withDefault(collection, "default"), + tags: tags ?? [], }, }, 30000, @@ -867,21 +933,23 @@ export class LibrarianApi { totalSize: number, chunkSize?: number, ): Promise { + const request: BeginUploadRequest = { + operation: "begin-upload", + documentMetadata: metadata, + "total-size": totalSize, + }; + if (chunkSize !== undefined) { + request["chunk-size"] = chunkSize; + } + return this.api .makeRequest( "librarian", - { - operation: "begin-upload", - documentMetadata: metadata, - "total-size": totalSize, - "chunk-size": chunkSize, - }, + request, 30000, ) .then((r) => { - if (r.error) { - throw new Error(r.error.message); - } + throwIfResponseError(r.error); return r; }); } @@ -912,9 +980,7 @@ export class LibrarianApi { 60000, // Longer timeout for chunk uploads ) .then((r) => { - if (r.error) { - throw new Error(r.error.message); - } + throwIfResponseError(r.error); return r; }); } @@ -937,9 +1003,7 @@ export class LibrarianApi { 30000, ) .then((r) => { - if (r.error) { - throw new Error(r.error.message); - } + throwIfResponseError(r.error); return r; }); } @@ -961,9 +1025,7 @@ export class LibrarianApi { 30000, ) .then((r) => { - if (r.error) { - throw new Error(r.error.message); - } + throwIfResponseError(r.error); return r; }); } @@ -984,9 +1046,7 @@ export class LibrarianApi { 30000, ) .then((r) => { - if (r.error) { - throw new Error(r.error.message); - } + throwIfResponseError(r.error); }); } @@ -1005,10 +1065,8 @@ export class LibrarianApi { 30000, ) .then((r) => { - if (r.error) { - throw new Error(r.error.message); - } - return r["upload-sessions"] || []; + throwIfResponseError(r.error); + return r["upload-sessions"] ?? []; }); } @@ -1030,36 +1088,40 @@ export class LibrarianApi { const msg = message as { response?: StreamDocumentResponse; complete?: boolean; error?: string }; // Check for top-level error - if (msg.error) { + if (msg.error !== undefined) { onError(msg.error); return true; } const resp = msg.response; - if (!resp) { - return !!msg.complete; + if (resp === undefined) { + return msg.complete === true; } // Check for response-level error - if (resp.error) { + if (resp.error !== undefined) { onError(resp.error.message); return true; } - const complete = !!msg.complete; + const complete = msg.complete === true; onChunk(resp.content, resp["chunk-index"], resp["total-chunks"], complete); return complete; }; + const request: StreamDocumentRequest = { + operation: "stream-document", + "document-id": documentId, + user: this.api.user, + }; + if (chunkSize !== undefined) { + request["chunk-size"] = chunkSize; + } + this.api.makeRequestMulti( "librarian", - { - operation: "stream-document", - "document-id": documentId, - "chunk-size": chunkSize, - user: this.api.user, - }, + request, receiver, 300000, // 5 minute timeout for full document stream ); @@ -1089,7 +1151,7 @@ export class FlowsApi { }, 60000, ) - .then((r) => r["flow-ids"] || []); + .then((r) => r["flow-ids"] ?? []); } /** @@ -1105,7 +1167,7 @@ export class FlowsApi { }, 60000, ) - .then((r) => JSON.parse(r.flow || "{}")); // Parse JSON flow definition + .then((r) => JSON.parse(r.flow ?? "{}")); // Parse JSON flow definition } // Configuration management methods @@ -1145,7 +1207,7 @@ export class FlowsApi { const byType = new Map>(); for (const item of items) { let group = byType.get(item.type); - if (!group) { + if (group === undefined) { group = {}; byType.set(item.type, group); } @@ -1252,7 +1314,7 @@ export class FlowsApi { }, 60000, ) - .then((r) => JSON.parse(r["blueprint-definition"] || "{}")); + .then((r) => JSON.parse(r["blueprint-definition"] ?? "{}")); } /** @@ -1288,26 +1350,15 @@ export class FlowsApi { }; // Only include parameters if provided and not empty - if (parameters && Object.keys(parameters).length > 0) { + if (parameters !== undefined && Object.keys(parameters).length > 0) { request.parameters = parameters; } return this.api .makeRequest("flow", request, 30000) .then((response) => { - if (response.error) { - let errorMessage = "Flow start failed"; - if ( - typeof response.error === "object" && - response.error && - "message" in response.error - ) { - errorMessage = - (response.error as { message?: string }).message || errorMessage; - } else if (typeof response.error === "string") { - errorMessage = response.error; - } - throw new Error(errorMessage); + if (response.error !== undefined) { + throw new Error(toErrorMessage(response.error, "Flow start failed")); } return response; }); @@ -1363,18 +1414,28 @@ export class FlowApi { * Performs Graph RAG (Retrieval Augmented Generation) query */ graphRag(text: string, options?: GraphRagOptions, collection?: string) { + const request: GraphRagRequest = { + query: text, + user: this.api.user, + collection: withDefault(collection, "default"), + }; + if (options?.entityLimit !== undefined) { + request["entity-limit"] = options.entityLimit; + } + if (options?.tripleLimit !== undefined) { + request["triple-limit"] = options.tripleLimit; + } + if (options?.maxSubgraphSize !== undefined) { + request["max-subgraph-size"] = options.maxSubgraphSize; + } + if (options?.pathLength !== undefined) { + request["max-path-length"] = options.pathLength; + } + return this.api .makeRequest( "graph-rag", - { - query: text, - user: this.api.user, - collection: collection || "default", - "entity-limit": options?.entityLimit, - "triple-limit": options?.tripleLimit, - "max-subgraph-size": options?.maxSubgraphSize, - "max-path-length": options?.pathLength, - }, + request, 60000, // Longer timeout for complex graph operations undefined, this.flowId, @@ -1392,8 +1453,8 @@ export class FlowApi { { query: text, user: this.api.user, - collection: collection || "default", - "doc-limit": docLimit || 20, + collection: withDefault(collection, "default"), + "doc-limit": docLimit ?? 20, }, 60000, // Longer timeout for document operations undefined, @@ -1419,38 +1480,42 @@ export class FlowApi { const msg = message as { response?: AgentResponse; complete?: boolean; error?: string }; // Check for top-level error - if (msg.error) { + if (msg.error !== undefined) { error(msg.error); return true; } - const resp = msg.response || {}; + const resp = msg.response ?? {}; // Check for errors in response - if (resp.chunk_type === "error" || resp.error) { - error(resp.error?.message || "Unknown agent error"); + if (resp.chunk_type === "error" || resp.error !== undefined) { + error(resp.error?.message ?? "Unknown agent error"); return true; // End streaming on error } // Handle explainability events (agent uses chunk_type="explain") - if ((resp.chunk_type === "explain" || resp.message_type === "explain") && (resp.explain_id || resp.explain_triples)) { - onExplain?.({ + if ( + (resp.chunk_type === "explain" || resp.message_type === "explain") && + (resp.explain_id !== undefined || resp.explain_triples !== undefined) + ) { + const event: ExplainEvent = { explainId: resp.explain_id ?? "", explainGraph: resp.explain_graph ?? "", - explainTriples: resp.explain_triples as Triple[] | undefined, - }); + }; + if (resp.explain_triples !== undefined) { + event.explainTriples = resp.explain_triples as Triple[]; + } + onExplain?.(event); return false; } // Handle streaming chunks by chunk_type - const content = resp.content || ""; - const messageComplete = !!resp.end_of_message; - const dialogComplete = !!msg.complete || !!resp.end_of_dialog; + const content = resp.content ?? ""; + const messageComplete = resp.end_of_message === true; + const dialogComplete = msg.complete === true || resp.end_of_dialog === true; // Extract metadata from final message - const metadata: StreamingMetadata | undefined = dialogComplete && (resp.in_token || resp.out_token || resp.model) - ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } - : undefined; + const metadata = dialogComplete ? streamingMetadataFrom(resp) : undefined; switch (resp.chunk_type) { case "thought": @@ -1478,7 +1543,7 @@ export class FlowApi { { question: question, user: this.api.user, - collection: collection ?? "default", + collection: withDefault(collection, "default"), streaming: true, // Always use streaming mode }, receiver, @@ -1487,8 +1552,7 @@ export class FlowApi { this.flowId, ) .catch((err) => { - const errorMessage = - err instanceof Error ? err.message : err?.toString() || "Unknown error"; + const errorMessage = toErrorMessage(err, "Unknown error"); error(`Agent request failed: ${errorMessage}`); }); } @@ -1514,65 +1578,79 @@ export class FlowApi { const msg = message as { response?: GraphRagResponse; complete?: boolean; error?: string }; // Check for top-level error - if (msg.error) { + if (msg.error !== undefined) { onError(msg.error); return true; } - const resp = (msg.response || {}) as GraphRagResponse; + const resp = (msg.response ?? {}) as GraphRagResponse; // Check for response-level error - if (resp.error) { + if (resp.error !== undefined) { onError(resp.error.message); return true; } // Extract explain data if present (may be embedded in the answer message) - if (resp.message_type === "explain" && (resp.explain_id || resp.explain_triples)) { - onExplain?.({ + if ( + resp.message_type === "explain" && + (resp.explain_id !== undefined || resp.explain_triples !== undefined) + ) { + const event: ExplainEvent = { explainId: resp.explain_id ?? "", explainGraph: resp.explain_graph ?? "", - explainTriples: resp.explain_triples as Triple[] | undefined, - }); + }; + if (resp.explain_triples !== undefined) { + event.explainTriples = resp.explain_triples as Triple[]; + } + onExplain?.(event); // If this message also carries answer text, fall through to chunk handling. // If it's a standalone explain event (no answer text), stop here. - if (!resp.response && !resp.endOfStream && !resp.end_of_session) { + if (resp.response === undefined && resp.endOfStream !== true && resp.end_of_session !== true) { return false; } } // Handle chunk messages (default behavior) - const chunk = resp.response || resp.chunk || ""; - const complete = !!resp.end_of_session || !!resp.endOfStream || !!msg.complete; + const chunk = resp.response ?? resp.chunk ?? ""; + const complete = resp.end_of_session === true || resp.endOfStream === true || msg.complete === true; // Extract metadata from final message - const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) - ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } - : undefined; + const metadata = complete ? streamingMetadataFrom(resp) : undefined; receiver(chunk, complete, metadata); return complete; }; + const request: GraphRagRequest = { + query: text, + user: this.api.user, + collection: withDefault(collection, "default"), + streaming: true, + }; + if (options?.entityLimit !== undefined) { + request["entity-limit"] = options.entityLimit; + } + if (options?.tripleLimit !== undefined) { + request["triple-limit"] = options.tripleLimit; + } + if (options?.maxSubgraphSize !== undefined) { + request["max-subgraph-size"] = options.maxSubgraphSize; + } + if (options?.pathLength !== undefined) { + request["max-path-length"] = options.pathLength; + } + this.api.makeRequestMulti( "graph-rag", - { - query: text, - user: this.api.user, - collection: collection || "default", - "entity-limit": options?.entityLimit, - "triple-limit": options?.tripleLimit, - "max-subgraph-size": options?.maxSubgraphSize, - "max-path-length": options?.pathLength, - streaming: true, - }, + request, recv, 60000, undefined, this.flowId, ).catch((err) => { - const errorMessage = err instanceof Error ? err.message : err?.toString() || "Unknown error"; + const errorMessage = toErrorMessage(err, "Unknown error"); onError(`Graph RAG request failed: ${errorMessage}`); }); } @@ -1597,21 +1675,25 @@ export class FlowApi { const msg = message as { response?: DocumentRagResponse; complete?: boolean; error?: string }; // Check for top-level error - if (msg.error) { + if (msg.error !== undefined) { onError(msg.error); return true; } - const resp = (msg.response || {}) as DocumentRagResponse; + const resp = (msg.response ?? {}) as DocumentRagResponse; // Check for response-level error - if (resp.error) { + if (resp.error !== undefined) { onError(resp.error.message); return true; } // Handle explainability events - if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) { + if ( + resp.message_type === "explain" && + resp.explain_id !== undefined && + resp.explain_graph !== undefined + ) { onExplain?.({ explainId: resp.explain_id, explainGraph: resp.explain_graph, @@ -1619,34 +1701,36 @@ export class FlowApi { return false; } - const chunk = resp.response || resp.chunk || ""; - const complete = !!resp.end_of_session || !!resp.endOfStream || !!msg.complete; + const chunk = resp.response ?? resp.chunk ?? ""; + const complete = resp.end_of_session === true || resp.endOfStream === true || msg.complete === true; // Extract metadata from final message - const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) - ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } - : undefined; + const metadata = complete ? streamingMetadataFrom(resp) : undefined; receiver(chunk, complete, metadata); return complete; }; + const request: DocumentRagRequest = { + query: text, + user: this.api.user, + collection: withDefault(collection, "default"), + streaming: true, + }; + if (docLimit !== undefined) { + request["doc-limit"] = docLimit; + } + this.api.makeRequestMulti( "document-rag", - { - query: text, - user: this.api.user, - collection: collection || "default", - "doc-limit": docLimit, - streaming: true, - }, + request, recv, 60000, undefined, this.flowId, ).catch((err) => { - const errorMessage = err instanceof Error ? err.message : err?.toString() || "Unknown error"; + const errorMessage = toErrorMessage(err, "Unknown error"); onError(`Document RAG request failed: ${errorMessage}`); }); } @@ -1668,27 +1752,25 @@ export class FlowApi { const msg = message as { response?: TextCompletionResponse; complete?: boolean; error?: string }; // Check for top-level error - if (msg.error) { + if (msg.error !== undefined) { onError(msg.error); return true; } - const resp = (msg.response || {}) as TextCompletionResponse; + const resp = (msg.response ?? {}) as TextCompletionResponse; // Check for response-level error - if (resp.error) { + if (resp.error !== undefined) { onError(resp.error.message); return true; } // Text completion uses 'response' field for chunks - const chunk = resp.response || ""; - const complete = !!msg.complete; + const chunk = resp.response ?? ""; + const complete = msg.complete === true; // Extract metadata from final message - const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) - ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } - : undefined; + const metadata = complete ? streamingMetadataFrom(resp) : undefined; receiver(chunk, complete, metadata); @@ -1726,27 +1808,25 @@ export class FlowApi { const msg = message as { response?: PromptResponse; complete?: boolean; error?: string }; // Check for top-level error - if (msg.error) { + if (msg.error !== undefined) { onError(msg.error); return true; } - const resp = (msg.response || {}) as PromptResponse; + const resp = (msg.response ?? {}) as PromptResponse; // Check for response-level error - if (resp.error) { + if (resp.error !== undefined) { onError(resp.error.message); return true; } // Prompt service uses 'text' field for chunks - const chunk = resp.text || ""; - const complete = !!msg.complete; + const chunk = resp.text ?? ""; + const complete = msg.complete === true; // Extract metadata from final message - const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) - ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } - : undefined; + const metadata = complete ? streamingMetadataFrom(resp) : undefined; receiver(chunk, complete, metadata); @@ -1798,9 +1878,9 @@ export class FlowApi { "graph-embeddings", { vector: vec, - limit: limit ? limit : 20, // Default to 20 results + limit: limit ?? 20, // Default to 20 results user: this.api.user, - collection: collection || "default", + collection: withDefault(collection, "default"), }, 30000, undefined, @@ -1821,18 +1901,28 @@ export class FlowApi { collection?: string, graph?: string, ) { + const request: TriplesQueryRequest = { + limit: limit ?? 20, + user: this.api.user, + collection: withDefault(collection, "default"), + }; + if (s !== undefined) { + request.s = s; + } + if (p !== undefined) { + request.p = p; + } + if (o !== undefined) { + request.o = o; + } + if (graph !== undefined) { + request.g = graph; + } + return this.api .makeRequest( "triples", - { - s: s, // Subject - p: p, // Predicate - o: o, // Object - g: graph, // Named graph URI filter - limit: limit ? limit : 20, - user: this.api.user, - collection: collection || "default", - }, + request, 30000, undefined, this.flowId, @@ -1848,13 +1938,19 @@ export class FlowApi { id?: string, metadata?: Triple[], ) { + const request: LoadDocumentRequest = { + data: document, + }; + if (id !== undefined) { + request.id = id; + } + if (metadata !== undefined) { + request.metadata = metadata; + } + return this.api.makeRequest( "document-load", - { - id: id, - metadata: metadata, - data: document, - }, + request, 30000, undefined, this.flowId, @@ -1870,14 +1966,22 @@ export class FlowApi { metadata?: Triple[], charset?: string, // Character encoding ) { + const request: LoadTextRequest = { + text, + }; + if (id !== undefined) { + request.id = id; + } + if (metadata !== undefined) { + request.metadata = metadata; + } + if (charset !== undefined) { + request.charset = charset; + } + return this.api.makeRequest( "text-load", - { - id: id, - metadata: metadata, - text: text, - charset: charset, - }, + request, 30000, undefined, this.flowId, @@ -1893,16 +1997,22 @@ export class FlowApi { variables?: Record, operationName?: string, ) { + const request: RowsQueryRequest = { + query, + user: this.api.user, + collection: withDefault(collection, "default"), + }; + if (variables !== undefined) { + request.variables = variables; + } + if (operationName !== undefined) { + request.operation_name = operationName; + } + return this.api .makeRequest( "rows", - { - query: query, - user: this.api.user, - collection: collection || "default", - variables: variables, - operation_name: operationName, - }, + request, 30000, undefined, this.flowId, @@ -1911,8 +2021,8 @@ export class FlowApi { // Return the GraphQL response structure directly const result: Record = {}; if (r.data !== undefined) result.data = r.data; - if (r.errors) result.errors = r.errors; - if (r.extensions) result.extensions = r.extensions; + if (r.errors !== undefined) result.errors = r.errors; + if (r.extensions !== undefined) result.extensions = r.extensions; return result; }); } @@ -1926,7 +2036,7 @@ export class FlowApi { "nlp-query", { question: question, - max_results: maxResults || 100, + max_results: maxResults ?? 100, }, 30000, undefined, @@ -1946,7 +2056,7 @@ export class FlowApi { { question: question, user: this.api.user, - collection: collection || "default", + collection: withDefault(collection, "default"), }, 30000, undefined, @@ -1956,7 +2066,7 @@ export class FlowApi { // Return the response structure directly const result: Record = {}; if (r.data !== undefined) result.data = r.data; - if (r.errors) result.errors = r.errors; + if (r.errors !== undefined) result.errors = r.errors; return result; }); } @@ -1980,11 +2090,11 @@ export class FlowApi { vector: vector, schema_name: schemaName, user: this.api.user, - collection: collection || "default", - limit: limit || 10, + collection: withDefault(collection, "default"), + limit: limit ?? 10, }; - if (indexName) { + if (indexName !== undefined) { request.index_name = indexName; } @@ -1997,10 +2107,10 @@ export class FlowApi { this.flowId, ) .then((r) => { - if (r.error) { + if (r.error !== undefined) { throw new Error(r.error.message); } - return r.matches || []; + return r.matches ?? []; }); } } @@ -2051,7 +2161,7 @@ export class ConfigApi { const byType = new Map>(); for (const item of items) { let group = byType.get(item.type); - if (!group) { + if (group === undefined) { group = {}; byType.set(item.type, group); } @@ -2177,7 +2287,7 @@ export class ConfigApi { .then((r) => { // Parse JSON values and restructure data const response = r as RowsQueryResponse; - return (response.values || []).map((x: unknown) => { + return (response.values ?? []).map((x: unknown) => { const item = x as Record; return { key: item.key, value: JSON.parse(item.value) }; }); @@ -2221,7 +2331,7 @@ export class KnowledgeApi { }, 60000, ) - .then((r) => r.ids || []); + .then((r) => r.ids ?? []); } /** @@ -2234,7 +2344,7 @@ export class KnowledgeApi { operation: "delete-kg-core", id: id, user: this.api.user, - collection: collection || "default", + collection: withDefault(collection, "default"), }, 30000, ); @@ -2251,7 +2361,7 @@ export class KnowledgeApi { id: id, flow: flow, user: this.api.user, - collection: collection || "default", + collection: withDefault(collection, "default"), }, 30000, ); @@ -2270,7 +2380,7 @@ export class KnowledgeApi { // Wrapper to handle end-of-stream detection const recv = (msg: unknown) => { const response = msg as Record; - if (response.eos) { + if (response.eos === true) { // End of stream - notify receiver and signal completion receiver(msg, true); return true; @@ -2287,7 +2397,7 @@ export class KnowledgeApi { operation: "get-kg-core", id: id, user: this.api.user, - collection: collection || "default", + collection: withDefault(collection, "default"), }, recv, // Stream handler 30000, @@ -2317,7 +2427,7 @@ export class CollectionManagementApi { user: this.api.user, }; - if (tagFilter && tagFilter.length > 0) { + if (tagFilter !== undefined && tagFilter.length > 0) { request.tag_filter = tagFilter; } @@ -2326,7 +2436,7 @@ export class CollectionManagementApi { Record, Record >("collection-management", request, 30000) - .then((r) => r.collections || []); + .then((r) => r.collections ?? []); } /** @@ -2366,7 +2476,7 @@ export class CollectionManagementApi { >("collection-management", request, 30000) .then((r) => { if ( - r.collections && + r.collections !== undefined && Array.isArray(r.collections) && r.collections.length > 0 ) { diff --git a/ts/packages/client/src/socket/websocket-adapter.ts b/ts/packages/client/src/socket/websocket-adapter.ts index 22705c8b..40b3ad82 100644 --- a/ts/packages/client/src/socket/websocket-adapter.ts +++ b/ts/packages/client/src/socket/websocket-adapter.ts @@ -117,7 +117,9 @@ export function getDefaultSocketUrl(): string { */ export function getRandomValues(array: Uint32Array): Uint32Array { if (typeof globalThis.crypto?.getRandomValues === "function") { - return globalThis.crypto.getRandomValues(array); + const random = globalThis.crypto.getRandomValues(new Uint32Array(array.length)); + array.set(random); + return array; } // Node.js fallback for versions < 19 where globalThis.crypto may not exist try { diff --git a/ts/packages/client/tsconfig.json b/ts/packages/client/tsconfig.json index 8b942a8f..1efedf67 100644 --- a/ts/packages/client/tsconfig.json +++ b/ts/packages/client/tsconfig.json @@ -4,6 +4,7 @@ "outDir": "dist", "rootDir": "src", "lib": ["ES2022", "DOM"], + "types": ["node"], "composite": true }, "include": ["src"], diff --git a/ts/packages/flow/package.json b/ts/packages/flow/package.json index ae849509..4c1271bd 100644 --- a/ts/packages/flow/package.json +++ b/ts/packages/flow/package.json @@ -5,13 +5,14 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", + "build": "bunx --bun tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "test": "vitest run" + "test": "bunx --bun vitest run" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@effect/platform-bun": "4.0.0-beta.65", "@fastify/websocket": "^11.0.0", "@qdrant/js-client-rest": "^1.13.0", "@trustgraph/base": "workspace:*", @@ -20,12 +21,14 @@ "ollama": "^0.6.3", "@mistralai/mistralai": "^1.0.0", "@modelcontextprotocol/sdk": "^1.12.0", + "effect": "4.0.0-beta.65", "openai": "^4.85.0", "pdfjs-dist": "^5.6.205" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0" + "vitest": "^4.1.6" } } diff --git a/ts/packages/flow/src/__tests__/chunking-service.test.ts b/ts/packages/flow/src/__tests__/chunking-service.test.ts new file mode 100644 index 00000000..473679f3 --- /dev/null +++ b/ts/packages/flow/src/__tests__/chunking-service.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Fiber } from "effect"; +import { + MessagingRuntimeLive, + PubSub, + runProcessorScoped, + topics, + type BackendConsumer, + type BackendProducer, + type Chunk, + type CreateConsumerOptions, + type CreateProducerOptions, + type Message, + type PubSubBackend, + type TextDocument, +} from "@trustgraph/base"; +import { ChunkingService } from "../chunking/service.js"; +import { recursiveSplit } from "../chunking/recursive-splitter.js"; + +function createMessage(value: T, properties: Record = {}): Message { + return { + value: () => value, + properties: () => properties, + }; +} + +const waitFor = (condition: () => boolean, label: string) => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const deadline = Date.now() + 1000; + const check = () => { + if (condition()) { + resolve(); + return; + } + if (Date.now() > deadline) { + reject(new Error(`Timed out waiting for ${label}`)); + return; + } + setTimeout(check, 5); + }; + check(); + }), + catch: (error) => error, + }); + +class RecordingProducer implements BackendProducer { + readonly sent: Array<{ readonly message: T; readonly properties?: Record }> = []; + closeCount = 0; + flushCount = 0; + + async send(message: T, properties?: Record): Promise { + this.sent.push(properties === undefined ? { message } : { message, properties }); + } + + async flush(): Promise { + this.flushCount += 1; + } + + async close(): Promise { + this.closeCount += 1; + } +} + +class PushConsumer implements BackendConsumer { + readonly acknowledged: Array> = []; + readonly nacked: Array> = []; + closeCount = 0; + private readonly messages: Array> = []; + private readonly waiters: Array<(message: Message | null) => void> = []; + private closed = false; + + push(message: Message): void { + const waiter = this.waiters.shift(); + if (waiter !== undefined) { + waiter(message); + return; + } + this.messages.push(message); + } + + async receive(): Promise | null> { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return message ?? null; + } + return await new Promise((resolve) => { + this.waiters.push(resolve); + }); + } + + async acknowledge(message: Message): Promise { + this.acknowledged.push(message); + } + + async negativeAcknowledge(message: Message): Promise { + this.nacked.push(message); + } + + async unsubscribe(): Promise {} + + async close(): Promise { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter(null); + } + this.closeCount += 1; + } +} + +class ChunkingBackend implements PubSubBackend { + readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record }>(); + readonly consumersByTopic = new Map>(); + readonly producersByTopic = new Map>(); + readonly producerOptions: Array = []; + readonly consumerOptions: Array = []; + closeCount = 0; + + async createProducer(options: CreateProducerOptions): Promise> { + this.producerOptions.push(options); + const producer = new RecordingProducer(); + this.producersByTopic.set(options.topic, producer); + return producer as BackendProducer; + } + + async createConsumer(options: CreateConsumerOptions): Promise> { + this.consumerOptions.push(options); + if (options.topic === topics.configPush) { + return this.configConsumer as unknown as BackendConsumer; + } + const consumer = new PushConsumer(); + this.consumersByTopic.set(options.topic, consumer); + return consumer as BackendConsumer; + } + + async close(): Promise { + this.closeCount += 1; + } + + pushConfig(): void { + this.configConsumer.push( + createMessage({ + version: 1, + config: { + flows: { + default: { + topics: { + "chunk-input": "chunk-input-topic", + "chunk-output": "chunk-output-topic", + "chunk-triples": "chunk-triples-topic", + }, + parameters: { + "chunk-size": 18, + "chunk-overlap": 0, + }, + }, + }, + }, + }), + ); + } +} + +const fastMessagingConfig = ConfigProvider.layer( + ConfigProvider.fromEnv({ + TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1", + TG_CONSUMER_ERROR_BACKOFF_MS: "1", + TG_RATE_LIMIT_RETRY_MS: "1", + TG_REQUEST_TIMEOUT_MS: "250", + }), +); + +describe("ChunkingService", () => { + it.effect( + "handles chunk-input with native Effect flow resources", + Effect.fnUntraced(function* () { + const backend = new ChunkingBackend(); + + yield* Effect.scoped( + Effect.gen(function* () { + const fiber = yield* runProcessorScoped( + { + id: "chunking", + pubsubUrl: "nats://unused:4222", + metricsPort: 8000, + manageProcessSignals: true, + }, + (config) => new ChunkingService(config), + ).pipe( + Effect.provide(MessagingRuntimeLive), + Effect.provide(PubSub.layer(backend)), + Effect.provide(fastMessagingConfig), + Effect.forkChild, + ); + + backend.pushConfig(); + yield* waitFor(() => backend.consumersByTopic.has("chunk-input-topic"), "chunk consumer"); + yield* waitFor(() => backend.producersByTopic.has("chunk-output-topic"), "chunk producer"); + + const document: TextDocument = { + documentId: "doc-1", + metadata: { + id: "pipeline-1", + root: "root-1", + user: "user-1", + collection: "collection-1", + }, + text: "alpha beta gamma delta epsilon zeta eta theta", + }; + const inputConsumer = backend.consumersByTopic.get("chunk-input-topic") as PushConsumer; + inputConsumer.push(createMessage(document, { id: "request-1" })); + + const outputProducer = backend.producersByTopic.get("chunk-output-topic") as RecordingProducer; + const expectedChunks = recursiveSplit(document.text, 18, 0); + yield* waitFor(() => outputProducer.sent.length === expectedChunks.length, "chunk outputs"); + + expect(inputConsumer.acknowledged.length).toBe(1); + expect(inputConsumer.nacked).toEqual([]); + expect(outputProducer.sent.map(({ message }) => message.chunk)).toEqual(expectedChunks); + expect(outputProducer.sent.every(({ properties }) => properties?.id === "request-1")).toBe(true); + + yield* Fiber.interrupt(fiber); + }), + ); + + expect(backend.closeCount).toBe(1); + }), + ); +}); diff --git a/ts/packages/flow/src/__tests__/ollama-embeddings.test.ts b/ts/packages/flow/src/__tests__/ollama-embeddings.test.ts new file mode 100644 index 00000000..ac20becc --- /dev/null +++ b/ts/packages/flow/src/__tests__/ollama-embeddings.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { makeOllamaEmbeddings } from "../embeddings/ollama.js"; + +describe("Ollama embeddings provider", () => { + it.effect( + "posts embedding requests to Ollama", + Effect.fnUntraced(function* () { + const calls: Array<{ readonly input: RequestInfo | URL; readonly init?: RequestInit }> = []; + const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => { + calls.push(init === undefined ? { input } : { input, init }); + return Promise.resolve( + new Response(JSON.stringify({ embeddings: [[1, 2, 3]] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as typeof fetch; + const embeddings = makeOllamaEmbeddings({ + id: "embeddings", + model: "default-model", + ollamaHost: "http://ollama.local", + fetch: fetchImpl, + }); + + const vectors = yield* embeddings.embed(["alpha"], "override-model"); + + expect(vectors).toEqual([[1, 2, 3]]); + expect(calls).toHaveLength(1); + expect(String(calls[0]?.input)).toBe("http://ollama.local/api/embed"); + expect(calls[0]?.init?.method).toBe("POST"); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + model: "override-model", + input: ["alpha"], + }); + }), + ); + + it.effect( + "does not call Ollama for empty requests", + Effect.fnUntraced(function* () { + const calls: Array = []; + const fetchImpl = ((input: RequestInfo | URL) => { + calls.push(input); + return Promise.resolve(new Response(JSON.stringify({ embeddings: [] }))); + }) as typeof fetch; + const embeddings = makeOllamaEmbeddings({ + id: "embeddings", + fetch: fetchImpl, + }); + + const vectors = yield* embeddings.embed([]); + + expect(vectors).toEqual([]); + expect(calls).toEqual([]); + }), + ); + + it.effect( + "maps failed Ollama responses to EmbeddingsError", + Effect.fnUntraced(function* () { + const fetchImpl = (() => + Promise.resolve( + new Response("not found", { + status: 404, + }), + )) as typeof fetch; + const embeddings = makeOllamaEmbeddings({ + id: "embeddings", + ollamaHost: "http://ollama.local", + fetch: fetchImpl, + }); + + const error = yield* embeddings.embed(["alpha"]).pipe(Effect.flip); + + expect(error._tag).toBe("EmbeddingsError"); + expect(error.operation).toBe("ollama.embed"); + expect(error.provider).toBe("ollama"); + expect(error.message).toContain("Ollama embeddings request failed (404): not found"); + }), + ); +}); diff --git a/ts/packages/flow/src/agent/mcp-tool/service.ts b/ts/packages/flow/src/agent/mcp-tool/service.ts index 6fdc2be3..030e43e0 100644 --- a/ts/packages/flow/src/agent/mcp-tool/service.ts +++ b/ts/packages/flow/src/agent/mcp-tool/service.ts @@ -22,6 +22,7 @@ import { type ToolRequest, type ToolResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; interface McpServiceConfig { url: string; @@ -36,7 +37,7 @@ export class McpToolService extends FlowProcessor { super(config); this.registerSpecification( - new ConsumerSpec("mcp-tool-request", this.onRequest.bind(this)), + ConsumerSpec.fromPromise("mcp-tool-request", this.onRequest.bind(this)), ); this.registerSpecification(new ProducerSpec("mcp-tool-response")); @@ -77,14 +78,16 @@ export class McpToolService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const responseProducer = flowCtx.flow.producer("mcp-tool-response"); try { const result = await this.invokeTool( msg.name, - msg.parameters ? JSON.parse(msg.parameters) : {}, + msg.parameters !== undefined && msg.parameters.length > 0 + ? JSON.parse(msg.parameters) as Record + : {}, ); if (typeof result === "string") { @@ -110,7 +113,7 @@ export class McpToolService extends FlowProcessor { } const svcConfig = this.mcpServices[name]; - if (!svcConfig.url) { + if (svcConfig.url.length === 0) { throw new Error(`MCP service "${name}" URL not defined`); } @@ -118,7 +121,7 @@ export class McpToolService extends FlowProcessor { // Build headers with optional bearer token const headers: Record = {}; - if (svcConfig["auth-token"]) { + if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) { headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`; } @@ -133,7 +136,7 @@ export class McpToolService extends FlowProcessor { const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" }); try { - await client.connect(transport); + await client.connect(transport as unknown as Parameters[0]); const result = await client.callTool({ name: remoteName, @@ -141,11 +144,11 @@ export class McpToolService extends FlowProcessor { }); // Extract response — prefer structured content, fall back to text - if (result.structuredContent) { + if (result.structuredContent !== undefined && result.structuredContent !== null) { return result.structuredContent; } - if (result.content && Array.isArray(result.content)) { + if (result.content !== undefined && Array.isArray(result.content)) { return result.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) @@ -158,3 +161,8 @@ export class McpToolService extends FlowProcessor { } } } + +export const program = makeProcessorProgram({ + id: "mcp-tool", + make: (config) => new McpToolService(config), +}); diff --git a/ts/packages/flow/src/agent/react/parser.ts b/ts/packages/flow/src/agent/react/parser.ts index 572f11ad..809d7b41 100644 --- a/ts/packages/flow/src/agent/react/parser.ts +++ b/ts/packages/flow/src/agent/react/parser.ts @@ -25,13 +25,22 @@ const MAX_MARKER_LEN = Math.max(...MARKERS.map((m) => m.prefix.length)); export class StreamingReActParser { private state: ReActState = "initial"; private buffer = ""; + private onThought: (text: string) => void; + private onAction: (name: string) => void; + private onActionInput: (input: string) => void; + private onFinalAnswer: (text: string) => void; constructor( - private onThought: (text: string) => void, - private onAction: (name: string) => void, - private onActionInput: (input: string) => void, - private onFinalAnswer: (text: string) => void, - ) {} + onThought: (text: string) => void, + onAction: (name: string) => void, + onActionInput: (input: string) => void, + onFinalAnswer: (text: string) => void, + ) { + this.onThought = onThought; + this.onAction = onAction; + this.onActionInput = onActionInput; + this.onFinalAnswer = onFinalAnswer; + } /** * Feed a chunk of LLM output text into the parser. diff --git a/ts/packages/flow/src/agent/react/service.ts b/ts/packages/flow/src/agent/react/service.ts index 641f8905..72d74288 100644 --- a/ts/packages/flow/src/agent/react/service.ts +++ b/ts/packages/flow/src/agent/react/service.ts @@ -36,6 +36,7 @@ import { type ToolRequest, type ToolResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { createKnowledgeQueryTool, @@ -45,7 +46,7 @@ import { type ExplainData, } from "./tools.js"; import { buildReActPrompt } from "./prompt.js"; -import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js"; +import { filterToolsByGroupAndState } from "../tool-filter.js"; import type { AgentTool, ToolArg } from "./types.js"; const MAX_ITERATIONS = 10; @@ -59,7 +60,7 @@ export class AgentService extends FlowProcessor { // Consumer: agent requests this.registerSpecification( - new ConsumerSpec("agent-request", this.onRequest.bind(this)), + ConsumerSpec.fromPromise("agent-request", this.onRequest.bind(this)), ); // Producer: agent responses (streaming chunks) @@ -132,11 +133,12 @@ export class AgentService extends FlowProcessor { for (const [_toolId, toolValue] of Object.entries(toolConfig)) { try { const data = JSON.parse(toolValue) as Record; - const implType = data["type"] as string; - const name = data["name"] as string; - const description = data["description"] as string ?? ""; + const implType = typeof data["type"] === "string" ? data["type"] : ""; + const name = typeof data["name"] === "string" ? data["name"] : ""; + const description = + typeof data["description"] === "string" ? data["description"] : ""; - if (!name) { + if (name.length === 0) { console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`); continue; } @@ -148,7 +150,10 @@ export class AgentService extends FlowProcessor { // Will be wired to requestor at request time tool = { name, - description: description || "Query the knowledge graph for information about entities and their relationships.", + description: + description.length > 0 + ? description + : "Query the knowledge graph for information about entities and their relationships.", args: [{ name: "question", type: "string", description: "The question to ask" }], config: data, execute: async () => "", // placeholder — wired at request time @@ -158,7 +163,10 @@ export class AgentService extends FlowProcessor { case "document-query": tool = { name, - description: description || "Search documents for relevant information.", + description: + description.length > 0 + ? description + : "Search documents for relevant information.", args: [{ name: "question", type: "string", description: "The question to search for" }], config: data, execute: async () => "", @@ -168,7 +176,10 @@ export class AgentService extends FlowProcessor { case "triples-query": tool = { name, - description: description || "Query for specific triples in the knowledge graph.", + description: + description.length > 0 + ? description + : "Query for specific triples in the knowledge graph.", args: [ { name: "subject", type: "string", description: "Subject entity (optional)" }, { name: "predicate", type: "string", description: "Predicate/relationship (optional)" }, @@ -203,7 +214,7 @@ export class AgentService extends FlowProcessor { continue; } - if (tool) { + if (tool !== null) { tools.push(tool); console.log(`[AgentService] Registered tool: ${name} (${implType})`); } @@ -276,7 +287,7 @@ export class AgentService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const responseProducer = flowCtx.flow.producer("agent-response"); @@ -290,7 +301,7 @@ export class AgentService extends FlowProcessor { // Build tools — config-driven or hardcoded fallback let tools: AgentTool[]; - if (this.configuredTools) { + if (this.configuredTools !== null) { tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain); } else { // Hardcoded fallback (backward compat) @@ -339,7 +350,7 @@ export class AgentService extends FlowProcessor { prompt: conversation, }); - if (llmResponse.error) { + if (llmResponse.error !== undefined) { await responseProducer.send(requestId, { chunk_type: "error", content: `LLM error: ${llmResponse.error.message}`, @@ -354,7 +365,7 @@ export class AgentService extends FlowProcessor { const parsed = parseReActResponse(text); // Send thought chunk - if (parsed.thought) { + if (parsed.thought.length > 0) { await responseProducer.send(requestId, { chunk_type: "thought", content: parsed.thought, @@ -363,7 +374,7 @@ export class AgentService extends FlowProcessor { } // If we got a final answer, emit explain events then send the answer - if (parsed.finalAnswer) { + if (parsed.finalAnswer.length > 0) { // Emit explain events collected from tool calls for (const explain of explainEvents) { await responseProducer.send(requestId, { @@ -384,11 +395,11 @@ export class AgentService extends FlowProcessor { } // Execute tool if action was specified - if (parsed.action && parsed.actionInput) { + if (parsed.action.length > 0 && parsed.actionInput.length > 0) { const tool = tools.find((t) => t.name === parsed.action); let observation: string; - if (tool) { + if (tool !== undefined) { try { observation = await tool.execute(parsed.actionInput); } catch (err) { @@ -407,7 +418,7 @@ export class AgentService extends FlowProcessor { // Append the full exchange to conversation for the next iteration conversation += `\n${text}\nObservation: ${observation}\n`; - } else if (!parsed.finalAnswer) { + } else if (parsed.finalAnswer.length === 0) { // LLM didn't produce a valid action or final answer -- nudge it conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`; } @@ -464,30 +475,31 @@ function parseReActResponse(text: string): { // Everything from "Final Answer:" to end of text is the answer const firstLine = trimmed.slice("Final Answer:".length).trim(); const remainingLines = lines.slice(i + 1).join("\n").trim(); - finalAnswer = firstLine + (remainingLines ? "\n" + remainingLines : ""); + finalAnswer = + firstLine + (remainingLines.length > 0 ? "\n" + remainingLines : ""); break; } else if (trimmed.startsWith("Thought:")) { currentSection = "thought"; const content = trimmed.slice("Thought:".length).trim(); - if (content) { - thought += (thought ? "\n" : "") + content; + if (content.length > 0) { + thought += (thought.length > 0 ? "\n" : "") + content; } } else if (trimmed.startsWith("Action Input:")) { currentSection = "action_input"; const content = trimmed.slice("Action Input:".length).trim(); - if (content) { + if (content.length > 0) { actionInput += content; } } else if (trimmed.startsWith("Action:")) { currentSection = "action"; const content = trimmed.slice("Action:".length).trim(); - if (content) { + if (content.length > 0) { action = content; } } else if (trimmed.startsWith("Observation:")) { // Stop processing -- observations are injected by us, not the LLM currentSection = null; - } else if (trimmed.length > 0 && currentSection) { + } else if (trimmed.length > 0 && currentSection !== null) { // Continuation line for current section switch (currentSection) { case "thought": @@ -512,6 +524,11 @@ function parseReActResponse(text: string): { }; } +export const program = makeProcessorProgram({ + id: "agent", + make: (config) => new AgentService(config), +}); + export async function run(): Promise { await AgentService.launch("agent"); } diff --git a/ts/packages/flow/src/agent/react/tools.ts b/ts/packages/flow/src/agent/react/tools.ts index 9e07e42d..9cc6be11 100644 --- a/ts/packages/flow/src/agent/react/tools.ts +++ b/ts/packages/flow/src/agent/react/tools.ts @@ -6,7 +6,7 @@ */ import type { - RequestResponse, + FlowRequestor, GraphRagRequest, GraphRagResponse, DocumentRagRequest, @@ -68,7 +68,7 @@ export interface ExplainData { * Query the knowledge graph for information about entities and their relationships. */ export function createKnowledgeQueryTool( - client: RequestResponse, + client: FlowRequestor, collection?: string, onExplain?: (data: ExplainData) => void, ): AgentTool { @@ -86,19 +86,27 @@ export function createKnowledgeQueryTool( async execute(input: string): Promise { const question = parseQuestion(input); console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`); - const res = await client.request({ query: question, collection }); - console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`); + const request: GraphRagRequest = { + query: question, + ...(collection !== undefined ? { collection } : {}), + }; + const res = await client.request(request); + console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`); // Extract explain data if embedded in the response const rawRes = res as Record; - if (rawRes.message_type === "explain" && rawRes.explain_triples && onExplain) { + if ( + rawRes.message_type === "explain" && + rawRes.explain_triples !== undefined && + onExplain !== undefined + ) { onExplain({ explainId: (rawRes.explain_id as string) ?? "", triples: rawRes.explain_triples as Triple[], }); } - if (res.error) return `Error: ${res.error.message}`; + if (res.error !== undefined) return `Error: ${res.error.message}`; return res.response; }, }; @@ -108,7 +116,7 @@ export function createKnowledgeQueryTool( * Search documents for relevant information. */ export function createDocumentQueryTool( - client: RequestResponse, + client: FlowRequestor, collection?: string, ): AgentTool { return { @@ -124,8 +132,12 @@ export function createDocumentQueryTool( ], async execute(input: string): Promise { const question = parseQuestion(input); - const res = await client.request({ query: question, collection }); - if (res.error) return `Error: ${res.error.message}`; + const request: DocumentRagRequest = { + query: question, + ...(collection !== undefined ? { collection } : {}), + }; + const res = await client.request(request); + if (res.error !== undefined) return `Error: ${res.error.message}`; return res.response; }, }; @@ -153,13 +165,20 @@ function parseTriplesInput(input: string): { return undefined; }; - return { - s: toTerm(parsed.subject ?? parsed.s), - p: toTerm(parsed.predicate ?? parsed.p), - o: toTerm(parsed.object ?? parsed.o), - limit: - typeof parsed.limit === "number" ? parsed.limit : undefined, - }; + const result: { + s?: Term; + p?: Term; + o?: Term; + limit?: number; + } = {}; + const s = toTerm(parsed.subject ?? parsed.s); + const p = toTerm(parsed.predicate ?? parsed.p); + const o = toTerm(parsed.object ?? parsed.o); + if (s !== undefined) result.s = s; + if (p !== undefined) result.p = p; + if (o !== undefined) result.o = o; + if (typeof parsed.limit === "number") result.limit = parsed.limit; + return result; } catch { // If not valid JSON, treat as a subject search return { @@ -172,7 +191,7 @@ function parseTriplesInput(input: string): { * Query for specific triples (subject-predicate-object relationships) in the knowledge graph. */ export function createTriplesQueryTool( - client: RequestResponse, + client: FlowRequestor, collection?: string, ): AgentTool { return { @@ -199,17 +218,18 @@ export function createTriplesQueryTool( ], async execute(input: string): Promise { const { s, p, o, limit } = parseTriplesInput(input); - const res = await client.request({ - s, - p, - o, - collection, + const request: TriplesQueryRequest = { limit: limit ?? 20, - }); + ...(s !== undefined ? { s } : {}), + ...(p !== undefined ? { p } : {}), + ...(o !== undefined ? { o } : {}), + ...(collection !== undefined ? { collection } : {}), + }; + const res = await client.request(request); - if (res.error) return `Error: ${res.error.message}`; + if (res.error !== undefined) return `Error: ${res.error.message}`; - if (!res.triples || res.triples.length === 0) { + if (res.triples === undefined || res.triples.length === 0) { return "No triples found matching the query."; } @@ -229,7 +249,7 @@ export function createTriplesQueryTool( * this function just wraps it as an AgentTool the ReAct agent can invoke. */ export function createMcpTool( - client: RequestResponse, + client: FlowRequestor, toolName: string, description: string, args: ToolArg[], @@ -240,9 +260,9 @@ export function createMcpTool( args, async execute(input: string): Promise { const res = await client.request({ name: toolName, parameters: input }); - if (res.error) return `Error: ${res.error.message}`; - if (res.text) return res.text; - if (res.object) return res.object; + if (res.error !== undefined) return `Error: ${res.error.message}`; + if (res.text !== undefined) return res.text; + if (res.object !== undefined) return res.object; return "No content"; }, }; diff --git a/ts/packages/flow/src/agent/tool-filter.ts b/ts/packages/flow/src/agent/tool-filter.ts index 85b1e31d..b0094cf7 100644 --- a/ts/packages/flow/src/agent/tool-filter.ts +++ b/ts/packages/flow/src/agent/tool-filter.ts @@ -17,7 +17,7 @@ export function filterToolsByGroupAndState( currentState?: string, ): AgentTool[] { const groups = requestedGroups ?? ["default"]; - const state = currentState || "undefined"; + const state = currentState ?? "undefined"; return tools.filter((tool) => isToolAvailable(tool, groups, state)); } @@ -31,12 +31,12 @@ function isToolAvailable( // Get tool groups (default to ["default"]) let toolGroups = config["group"] as string[] | string | undefined; - if (!toolGroups) toolGroups = ["default"]; + if (toolGroups === undefined) toolGroups = ["default"]; if (!Array.isArray(toolGroups)) toolGroups = [toolGroups]; // Get tool applicable states (default to ["*"] = all states) let applicableStates = config["applicable-states"] as string[] | string | undefined; - if (!applicableStates) applicableStates = ["*"]; + if (applicableStates === undefined) applicableStates = ["*"]; if (!Array.isArray(applicableStates)) applicableStates = [applicableStates]; // Group match: wildcard in requested groups, or intersection non-empty @@ -57,5 +57,5 @@ function isToolAvailable( */ export function getNextState(tool: AgentTool, currentState: string): string { const nextState = tool.config?.["state"] as string | undefined; - return nextState || currentState; + return nextState ?? currentState; } diff --git a/ts/packages/flow/src/chunking/service.ts b/ts/packages/flow/src/chunking/service.ts index 03b8551e..5effdf8b 100644 --- a/ts/packages/flow/src/chunking/service.ts +++ b/ts/packages/flow/src/chunking/service.ts @@ -16,10 +16,14 @@ import { ParameterSpec, type ProcessorConfig, type FlowContext, + type FlowResourceNotFoundError, + type MessagingDeliveryError, type TextDocument, type Chunk, type Triples, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; +import { Effect } from "effect"; import { recursiveSplit } from "./recursive-splitter.js"; const DEFAULT_CHUNK_SIZE = 2000; @@ -30,7 +34,10 @@ export class ChunkingService extends FlowProcessor { super(config); this.registerSpecification( - new ConsumerSpec("chunk-input", this.onMessage.bind(this)), + new ConsumerSpec( + "chunk-input", + this.onMessageEffect.bind(this), + ), ); this.registerSpecification(new ProducerSpec("chunk-output")); this.registerSpecification(new ProducerSpec("chunk-triples")); @@ -40,55 +47,55 @@ export class ChunkingService extends FlowProcessor { console.log("[ChunkingService] Service initialized"); } - private async onMessage( + private onMessageEffect( msg: TextDocument, properties: Record, flowCtx: FlowContext, - ): Promise { - const requestId = properties.id; - if (!requestId) return; + ) { + return Effect.gen(function* () { + const requestId = properties.id; + if (requestId === undefined || requestId.length === 0) return; - let chunkSize: number; - let chunkOverlap: number; + const chunkSize = yield* flowCtx.flow.parameterEffect("chunk-size").pipe( + Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_SIZE)), + ); + const chunkOverlap = yield* flowCtx.flow.parameterEffect("chunk-overlap").pipe( + Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_OVERLAP)), + ); - try { - chunkSize = flowCtx.flow.parameter("chunk-size"); - } catch { - chunkSize = DEFAULT_CHUNK_SIZE; - } + const text = msg.text; + if (text.trim().length === 0) { + yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`); + return; + } - try { - chunkOverlap = flowCtx.flow.parameter("chunk-overlap"); - } catch { - chunkOverlap = DEFAULT_CHUNK_OVERLAP; - } + const chunks = recursiveSplit(text, chunkSize, chunkOverlap); - const text = msg.text; - if (!text || text.trim().length === 0) { - console.warn(`[ChunkingService] Empty text received for document ${msg.documentId}`); - return; - } + yield* Effect.log( + `[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`, + ); - const chunks = recursiveSplit(text, chunkSize, chunkOverlap); + const outputProducer = yield* flowCtx.flow.producerEffect("chunk-output"); - console.log( - `[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`, - ); - - const outputProducer = flowCtx.flow.producer("chunk-output"); - - for (const chunkText of chunks) { - const chunk: Chunk = { - metadata: msg.metadata, - chunk: chunkText, - documentId: msg.documentId, - }; - - await outputProducer.send(requestId, chunk); - } + yield* Effect.forEach( + chunks, + (chunkText) => + outputProducer.send(requestId, { + metadata: msg.metadata, + chunk: chunkText, + documentId: msg.documentId, + }), + { discard: true }, + ); + }); } } +export const program = makeProcessorProgram({ + id: "chunking", + make: (config) => new ChunkingService(config), +}); + export async function run(): Promise { await ChunkingService.launch("chunking"); } diff --git a/ts/packages/flow/src/config/service.ts b/ts/packages/flow/src/config/service.ts index 1dfd14cd..ba23df8c 100644 --- a/ts/packages/flow/src/config/service.ts +++ b/ts/packages/flow/src/config/service.ts @@ -11,17 +11,24 @@ * Python reference: trustgraph-flow/trustgraph/config/service/service.py */ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { dirname } from "node:path"; +import { Effect } from "effect"; +import * as S from "effect/Schema"; import { AsyncProcessor, type ProcessorConfig, topics, + ConfigRequest as ConfigRequestSchema, + ConfigResponse as ConfigResponseSchema, type ConfigRequest, type ConfigResponse, type ConfigOperation, + errorMessage, + loadProcessorRuntimeConfig, + makeProcessorProgram, + optionalStringConfig, } from "@trustgraph/base"; -import type { PubSubBackend, BackendProducer, BackendConsumer, Message } from "@trustgraph/base"; +import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base"; +import { readTextFile, writeTextFile } from "../runtime/effect-files.js"; export interface ConfigServiceConfig extends ProcessorConfig { persistPath?: string; @@ -32,6 +39,11 @@ interface ConfigPush { config: Record; } +const ConfigPushSchema = S.Struct({ + version: S.Number, + config: S.Record(S.String, S.Unknown), +}); + export class ConfigService extends AsyncProcessor { private store = new Map>(); private version = 0; @@ -42,27 +54,30 @@ export class ConfigService extends AsyncProcessor { constructor(config: ConfigServiceConfig) { super(config); - this.persistPath = config.persistPath ?? process.env.CONFIG_PERSIST_PATH ?? null; + this.persistPath = config.persistPath ?? null; } protected override async run(): Promise { // Optionally load persisted state - if (this.persistPath) { + if (this.persistPath !== null) { await this.loadFromDisk(); } // Create producers this.responseProducer = await this.pubsub.createProducer({ topic: topics.configResponse, + schema: ConfigResponseSchema, }); this.pushProducer = await this.pubsub.createProducer({ topic: topics.configPush, + schema: ConfigPushSchema, }); // Create consumer for config requests this.consumer = await this.pubsub.createConsumer({ topic: topics.configRequest, subscription: `${this.config.id}-config-request`, + schema: ConfigRequestSchema, }); // Push initial config @@ -73,11 +88,14 @@ export class ConfigService extends AsyncProcessor { // Main consume loop while (this.running) { try { - const msg = await this.consumer.receive(2000); - if (!msg) continue; + const consumer = this.consumer; + if (consumer === null) throw new Error("Config consumer not started"); + + const msg = await consumer.receive(2000); + if (msg === null) continue; await this.handleMessage(msg); - await this.consumer.acknowledge(msg); + await consumer.acknowledge(msg); } catch (err) { if (!this.running) break; console.error("[ConfigService] Error in consume loop:", err); @@ -87,21 +105,25 @@ export class ConfigService extends AsyncProcessor { } private async handleMessage(msg: Message): Promise { - const request = msg.value(); + const request = await Effect.runPromise(S.decodeUnknownEffect(ConfigRequestSchema)(msg.value())); const props = msg.properties(); const requestId = props.id; - if (!requestId) { + if (requestId === undefined || requestId.length === 0) { console.warn("[ConfigService] Received request without id, ignoring"); return; } try { const response = await this.handleOperation(request); - await this.responseProducer!.send(response, { id: requestId }); + const responseProducer = this.responseProducer; + if (responseProducer === null) throw new Error("Config response producer not started"); + await responseProducer.send(response, { id: requestId }); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await this.responseProducer!.send( + const message = errorMessage(err); + const responseProducer = this.responseProducer; + if (responseProducer === null) throw new Error("Config response producer not started"); + await responseProducer.send( { error: { type: "config-error", message }, }, @@ -146,7 +168,7 @@ export class ConfigService extends AsyncProcessor { const namespace = keys[0]; const subMap = this.store.get(namespace); - if (subMap) { + if (subMap !== undefined) { if (keys.length === 1) { // Return entire namespace for (const [k, v] of subMap) { @@ -176,7 +198,7 @@ export class ConfigService extends AsyncProcessor { const namespace = keys[0]; let subMap = this.store.get(namespace); - if (!subMap) { + if (subMap === undefined) { subMap = new Map(); this.store.set(namespace, subMap); } @@ -205,7 +227,7 @@ export class ConfigService extends AsyncProcessor { } else { // Delete specific keys within namespace const subMap = this.store.get(namespace); - if (subMap) { + if (subMap !== undefined) { for (let i = 1; i < keys.length; i++) { subMap.delete(keys[i]); } @@ -236,7 +258,7 @@ export class ConfigService extends AsyncProcessor { return { version: this.version, - directory: subMap ? [...subMap.keys()] : [], + directory: subMap !== undefined ? [...subMap.keys()] : [], }; } @@ -246,7 +268,12 @@ export class ConfigService extends AsyncProcessor { const values: { key: string; value: unknown }[] = []; for (const [namespace, subMap] of this.store) { - if (!type || namespace === type || namespace.startsWith(`${type}.`) || namespace.startsWith(`${type}/`)) { + if ( + type.length === 0 || + namespace === type || + namespace.startsWith(`${type}.`) || + namespace.startsWith(`${type}/`) + ) { for (const [k, v] of subMap) { values.push({ key: `${namespace}.${k}`, value: v }); } @@ -274,7 +301,8 @@ export class ConfigService extends AsyncProcessor { } private async pushConfig(): Promise { - if (!this.pushProducer) return; + const pushProducer = this.pushProducer; + if (pushProducer === null) return; const config: Record = {}; for (const [namespace, subMap] of this.store) { @@ -285,7 +313,7 @@ export class ConfigService extends AsyncProcessor { config[namespace] = obj; } - await this.pushProducer.send({ + await pushProducer.send({ version: this.version, config, }); @@ -294,7 +322,8 @@ export class ConfigService extends AsyncProcessor { } private async persist(): Promise { - if (!this.persistPath) return; + const persistPath = this.persistPath; + if (persistPath === null) return; try { const data: Record> = {}; @@ -313,18 +342,18 @@ export class ConfigService extends AsyncProcessor { 2, ); - await mkdir(dirname(this.persistPath), { recursive: true }); - await writeFile(this.persistPath, json, "utf-8"); + await writeTextFile(persistPath, json); } catch (err) { - console.error("[ConfigService] Failed to persist config:", err); + await Effect.runPromise(Effect.logError("[ConfigService] Failed to persist config", { error: errorMessage(err) })); } } private async loadFromDisk(): Promise { - if (!this.persistPath) return; + const persistPath = this.persistPath; + if (persistPath === null) return; try { - const raw = await readFile(this.persistPath, "utf-8"); + const raw = await readTextFile(persistPath); const parsed = JSON.parse(raw) as { version: number; data: Record>; @@ -346,20 +375,20 @@ export class ConfigService extends AsyncProcessor { ); } catch { // File doesn't exist yet or is invalid — start fresh - console.log("[ConfigService] No persisted config found, starting fresh"); + await Effect.runPromise(Effect.log("[ConfigService] No persisted config found, starting fresh")); } } override async stop(): Promise { - if (this.consumer) { + if (this.consumer !== null) { await this.consumer.close(); this.consumer = null; } - if (this.responseProducer) { + if (this.responseProducer !== null) { await this.responseProducer.close(); this.responseProducer = null; } - if (this.pushProducer) { + if (this.pushProducer !== null) { await this.pushProducer.close(); this.pushProducer = null; } @@ -371,6 +400,23 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export const loadConfigServiceRuntimeConfig = Effect.fn("loadConfigServiceRuntimeConfig")(function* () { + const processorConfig = yield* loadProcessorRuntimeConfig("config-svc", { + manageProcessSignals: false, + }); + const persistPath = yield* optionalStringConfig("CONFIG_PERSIST_PATH"); + return { + ...processorConfig, + ...(persistPath !== undefined ? { persistPath } : {}), + } satisfies ConfigServiceConfig; +}); + +export const program = makeProcessorProgram({ + id: "config-svc", + loadConfig: loadConfigServiceRuntimeConfig(), + make: (config) => new ConfigService(config), +}); + export async function run(): Promise { - await ConfigService.launch("config-svc"); + await Effect.runPromise(program); } diff --git a/ts/packages/flow/src/cores/service.ts b/ts/packages/flow/src/cores/service.ts index f0320b8d..d93edbca 100644 --- a/ts/packages/flow/src/cores/service.ts +++ b/ts/packages/flow/src/cores/service.ts @@ -10,8 +10,6 @@ * Python reference: trustgraph-flow/trustgraph/knowledge/service/service.py */ -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; import { AsyncProcessor, type ProcessorConfig, @@ -21,7 +19,9 @@ import { type Triple, type Term, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base"; +import { joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js"; export interface KnowledgeCoreServiceConfig extends ProcessorConfig { dataDir?: string; @@ -43,7 +43,7 @@ export class KnowledgeCoreService extends AsyncProcessor { constructor(config: KnowledgeCoreServiceConfig) { super(config); const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge"; - this.persistPath = join(dataDir, "knowledge-state.json"); + this.persistPath = joinPath(dataDir, "knowledge-state.json"); } private coreKey(user: string, id: string): string { @@ -71,7 +71,7 @@ export class KnowledgeCoreService extends AsyncProcessor { while (this.running) { try { const msg = await this.consumer.receive(2000); - if (!msg) continue; + if (msg === null) continue; await this.handleMessage(msg); await this.consumer.acknowledge(msg); @@ -88,7 +88,7 @@ export class KnowledgeCoreService extends AsyncProcessor { const props = msg.properties(); const requestId = props.id; - if (!requestId) { + if (requestId === undefined || requestId.length === 0) { console.warn("[KnowledgeCoreService] Received request without id, ignoring"); return; } @@ -123,11 +123,11 @@ export class KnowledgeCoreService extends AsyncProcessor { private async listKgCores(request: KnowledgeRequest, requestId: string): Promise { const user = request.user ?? ""; - const prefix = user ? `${user}:` : ""; + const prefix = user.length > 0 ? `${user}:` : ""; const ids: string[] = []; for (const key of this.cores.keys()) { - if (!prefix || key.startsWith(prefix)) { + if (prefix.length === 0 || key.startsWith(prefix)) { // Extract the ID portion after the user prefix const id = key.slice(prefix.length); ids.push(id); @@ -143,7 +143,7 @@ export class KnowledgeCoreService extends AsyncProcessor { const key = this.coreKey(user, coreId); const core = this.cores.get(key); - if (!core) { + if (core === undefined) { throw new Error(`Knowledge core not found: ${key}`); } @@ -196,18 +196,18 @@ export class KnowledgeCoreService extends AsyncProcessor { const key = this.coreKey(user, coreId); let core = this.cores.get(key); - if (!core) { + if (core === undefined) { core = { triples: [], graphEmbeddings: [] }; this.cores.set(key, core); } // Append triples if provided - if (request.triples && request.triples.length > 0) { + if (request.triples !== undefined && request.triples.length > 0) { core.triples.push(...request.triples); } // Append graph embeddings if provided - if (request.graphEmbeddings && request.graphEmbeddings.length > 0) { + if (request.graphEmbeddings !== undefined && request.graphEmbeddings.length > 0) { core.graphEmbeddings.push(...request.graphEmbeddings); } @@ -225,7 +225,7 @@ export class KnowledgeCoreService extends AsyncProcessor { const key = this.coreKey(user, coreId); const core = this.cores.get(key); - if (!core) { + if (core === undefined) { throw new Error(`Knowledge core not found: ${key}`); } @@ -248,8 +248,7 @@ export class KnowledgeCoreService extends AsyncProcessor { } const json = JSON.stringify(data, null, 2); - await mkdir(dirname(this.persistPath), { recursive: true }); - await writeFile(this.persistPath, json, "utf-8"); + await writeTextFile(this.persistPath, json); } catch (err) { console.error("[KnowledgeCoreService] Failed to persist state:", err); } @@ -257,7 +256,7 @@ export class KnowledgeCoreService extends AsyncProcessor { private async loadFromDisk(): Promise { try { - const raw = await readFile(this.persistPath, "utf-8"); + const raw = await readTextFile(this.persistPath); const parsed = JSON.parse(raw) as Record; this.cores.clear(); @@ -272,11 +271,11 @@ export class KnowledgeCoreService extends AsyncProcessor { } override async stop(): Promise { - if (this.consumer) { + if (this.consumer !== null) { await this.consumer.close(); this.consumer = null; } - if (this.responseProducer) { + if (this.responseProducer !== null) { await this.responseProducer.close(); this.responseProducer = null; } @@ -288,6 +287,11 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export const program = makeProcessorProgram({ + id: "knowledge-svc", + make: (config) => new KnowledgeCoreService(config), +}); + export async function run(): Promise { await KnowledgeCoreService.launch("knowledge-svc"); } diff --git a/ts/packages/flow/src/decoding/pdf-decoder.ts b/ts/packages/flow/src/decoding/pdf-decoder.ts index 958b40bf..870cbad1 100644 --- a/ts/packages/flow/src/decoding/pdf-decoder.ts +++ b/ts/packages/flow/src/decoding/pdf-decoder.ts @@ -30,13 +30,14 @@ import { type LibrarianRequest, type LibrarianResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class PdfDecoderService extends FlowProcessor { constructor(config: ProcessorConfig) { super(config); this.registerSpecification( - new ConsumerSpec("decode-input", this.onMessage.bind(this)), + ConsumerSpec.fromPromise("decode-input", this.onMessage.bind(this)), ); this.registerSpecification(new ProducerSpec("decode-output")); this.registerSpecification(new ProducerSpec("decode-triples")); @@ -57,7 +58,7 @@ export class PdfDecoderService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const { documentId } = msg; const user = msg.metadata.user; @@ -73,7 +74,7 @@ export class PdfDecoderService extends FlowProcessor { user, }); - if (metadataResp.error) { + if (metadataResp.error !== undefined) { console.error( `[PdfDecoder] Failed to get metadata for ${documentId}:`, metadataResp.error.message, @@ -96,7 +97,11 @@ export class PdfDecoderService extends FlowProcessor { user, }); - if (contentResp.error || !contentResp.content) { + if ( + contentResp.error !== undefined || + contentResp.content === undefined || + contentResp.content.length === 0 + ) { console.error( `[PdfDecoder] Failed to get content for ${documentId}:`, contentResp.error?.message ?? "no content", @@ -123,7 +128,7 @@ export class PdfDecoderService extends FlowProcessor { .map((item) => item.str) .join(" "); - if (!pageText.trim()) { + if (pageText.trim().length === 0) { console.log( `[PdfDecoder] Skipping empty page ${i} of document ${documentId}`, ); @@ -147,7 +152,7 @@ export class PdfDecoderService extends FlowProcessor { content: Buffer.from(pageText).toString("base64"), }); - if (childResp.error) { + if (childResp.error !== undefined) { console.error( `[PdfDecoder] Failed to save page ${i} of ${documentId}:`, childResp.error.message, @@ -198,6 +203,11 @@ function literalTerm(value: string): Term { return { type: "LITERAL", value }; } +export const program = makeProcessorProgram({ + id: "pdf-decoder", + make: (config) => new PdfDecoderService(config), +}); + export async function run(): Promise { await PdfDecoderService.launch("pdf-decoder"); } diff --git a/ts/packages/flow/src/embeddings/ollama.ts b/ts/packages/flow/src/embeddings/ollama.ts index 8d24b8e8..9c7a629b 100644 --- a/ts/packages/flow/src/embeddings/ollama.ts +++ b/ts/packages/flow/src/embeddings/ollama.ts @@ -1,77 +1,112 @@ /** - * Ollama embeddings service. - * - * Simple HTTP POST to a local Ollama instance to generate embeddings. - * Extends EmbeddingsService from @trustgraph/base so it plugs into the - * flow processor framework (consumer/producer wiring is handled by the base class). + * Ollama embeddings provider. * * Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py */ +import { Effect, Layer } from "effect"; +import * as S from "effect/Schema"; import { + Embeddings, EmbeddingsService, + embeddingsError, + type EmbeddingsServiceShape, type ProcessorConfig, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export interface OllamaEmbeddingsConfig extends ProcessorConfig { model?: string; ollamaHost?: string; + fetch?: typeof fetch; } interface OllamaEmbedResponse { embeddings: number[][]; } +export function makeOllamaEmbeddings(config: OllamaEmbeddingsConfig): EmbeddingsServiceShape { + const defaultModel = config.model ?? "mxbai-embed-large"; + const ollamaHost = + config.ollamaHost ?? + process.env.OLLAMA_URL ?? + process.env.OLLAMA_HOST ?? + "http://localhost:11434"; + const fetchImpl = config.fetch ?? globalThis.fetch; + + return { + embed: Effect.fn("OllamaEmbeddings.embed")((texts: ReadonlyArray, model?: string) => { + if (texts.length === 0) { + return Effect.succeed([]); + } + + const useModel = model ?? defaultModel; + const url = `${ollamaHost}/api/embed`; + + return Effect.gen(function* () { + const body = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)({ + model: useModel, + input: Array.from(texts), + }).pipe( + Effect.mapError((error) => embeddingsError("ollama.encode-request", error, "ollama")), + ); + + return yield* Effect.tryPromise({ + try: async () => { + const response = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Ollama embeddings request failed (${response.status}): ${errorBody}`, + ); + } + + const data = (await response.json()) as OllamaEmbedResponse; + return data.embeddings; + }, + catch: (error) => embeddingsError("ollama.embed", error, "ollama"), + }); + }); + }), + }; +} + +export function OllamaEmbeddingsLive(config: OllamaEmbeddingsConfig): Layer.Layer { + return Layer.succeed( + Embeddings, + Embeddings.of(makeOllamaEmbeddings(config)), + ); +} + export class OllamaEmbeddingsProcessor extends EmbeddingsService { - private defaultModel: string; - private ollamaHost: string; + private readonly embeddings: EmbeddingsServiceShape; constructor(config: OllamaEmbeddingsConfig) { super(config); - - this.defaultModel = config.model ?? "mxbai-embed-large"; - this.ollamaHost = - config.ollamaHost ?? - process.env.OLLAMA_URL ?? - process.env.OLLAMA_HOST ?? - "http://localhost:11434"; + this.embeddings = makeOllamaEmbeddings(config); console.log( - `[OllamaEmbeddings] Initialized (host=${this.ollamaHost}, model=${this.defaultModel})`, + `[OllamaEmbeddings] Initialized (host=${config.ollamaHost ?? process.env.OLLAMA_URL ?? process.env.OLLAMA_HOST ?? "http://localhost:11434"}, model=${config.model ?? "mxbai-embed-large"})`, ); } - async onEmbeddings(texts: string[], model?: string): Promise { - if (!texts || texts.length === 0) { - return []; - } - - const useModel = model ?? this.defaultModel; - - const url = `${this.ollamaHost}/api/embed`; - - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: useModel, - input: texts, - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error( - `Ollama embeddings request failed (${response.status}): ${body}`, - ); - } - - const data = (await response.json()) as OllamaEmbedResponse; - - return data.embeddings; + override startEffect() { + return super.startEffect().pipe( + Effect.provideService(Embeddings, Embeddings.of(this.embeddings)), + ); } } +export const program = makeProcessorProgram({ + id: "embeddings", + make: (config) => new OllamaEmbeddingsProcessor(config), +}); + export async function run(): Promise { await OllamaEmbeddingsProcessor.launch("embeddings"); } diff --git a/ts/packages/flow/src/extract/knowledge-extract.ts b/ts/packages/flow/src/extract/knowledge-extract.ts index 99778e9b..50a3ce1e 100644 --- a/ts/packages/flow/src/extract/knowledge-extract.ts +++ b/ts/packages/flow/src/extract/knowledge-extract.ts @@ -28,6 +28,7 @@ import { type Triple, type Term, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; // Well-known RDF/SKOS IRIs const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label"; @@ -49,7 +50,7 @@ export class KnowledgeExtractService extends FlowProcessor { super(config); this.registerSpecification( - new ConsumerSpec("extract-input", this.onMessage.bind(this)), + ConsumerSpec.fromPromise("extract-input", this.onMessage.bind(this)), ); this.registerSpecification(new ProducerSpec("extract-triples")); this.registerSpecification(new ProducerSpec("extract-entity-contexts")); @@ -78,10 +79,10 @@ export class KnowledgeExtractService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const text = msg.chunk; - if (!text || text.trim().length === 0) return; + if (text.trim().length === 0) return; const promptClient = flowCtx.flow.requestor("prompt-client"); const llmClient = flowCtx.flow.requestor("llm-client"); @@ -98,7 +99,7 @@ export class KnowledgeExtractService extends FlowProcessor { { timeoutMs: 10_000 }, ); - if (!relPrompt.error) { + if (relPrompt.error === undefined) { let relationships: ExtractedRelationship[] | null = null; for (let attempt = 0; attempt < 3; attempt++) { const relCompletion = await llmClient.request( @@ -106,18 +107,27 @@ export class KnowledgeExtractService extends FlowProcessor { { timeoutMs: 120_000 }, ); - if (!relCompletion.error && relCompletion.response) { + if ( + relCompletion.error === undefined && + relCompletion.response.length > 0 + ) { relationships = parseJsonResponse(relCompletion.response); - if (relationships) break; + if (relationships !== null) break; console.warn(`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`); } else { break; // LLM error, don't retry } } - if (relationships) { + if (relationships !== null) { for (const rel of relationships) { - if (!rel.subject || !rel.predicate || !rel.object) continue; + if ( + rel.subject.length === 0 || + rel.predicate.length === 0 || + rel.object.length === 0 + ) { + continue; + } const subjectIri = toEntityIri(rel.subject); const predicateIri = toEntityIri(rel.predicate); @@ -170,7 +180,7 @@ export class KnowledgeExtractService extends FlowProcessor { { timeoutMs: 10_000 }, ); - if (!defPrompt.error) { + if (defPrompt.error === undefined) { let definitions: ExtractedDefinition[] | null = null; for (let attempt = 0; attempt < 3; attempt++) { const defCompletion = await llmClient.request( @@ -178,18 +188,21 @@ export class KnowledgeExtractService extends FlowProcessor { { timeoutMs: 120_000 }, ); - if (!defCompletion.error && defCompletion.response) { + if ( + defCompletion.error === undefined && + defCompletion.response.length > 0 + ) { definitions = parseJsonResponse(defCompletion.response); - if (definitions) break; + if (definitions !== null) break; console.warn(`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`); } else { break; // LLM error, don't retry } } - if (definitions) { + if (definitions !== null) { for (const def of definitions) { - if (!def.entity || !def.definition) continue; + if (def.entity.length === 0 || def.definition.length === 0) continue; const entityIri = toEntityIri(def.entity); @@ -265,8 +278,8 @@ export function parseJsonResponse(raw: string): T | null { // Attempt 1: direct parse after stripping fences let cleaned = raw.trim(); const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/); - if (fenceMatch) { - cleaned = fenceMatch[1].trim(); + if (fenceMatch !== null) { + cleaned = (fenceMatch[1] ?? "").trim(); } try { @@ -275,7 +288,7 @@ export function parseJsonResponse(raw: string): T | null { // Attempt 2: extract first JSON array from the text const arrayMatch = cleaned.match(/\[[\s\S]*\]/); - if (arrayMatch) { + if (arrayMatch !== null) { try { return JSON.parse(arrayMatch[0]) as T; } catch { /* fall through */ } @@ -293,7 +306,7 @@ export function parseJsonResponse(raw: string): T | null { // Attempt 4: extract first JSON object, wrap in array const objMatch = cleaned.match(/\{[\s\S]*?\}/); - if (objMatch) { + if (objMatch !== null) { try { const obj = JSON.parse(objMatch[0]); return [obj] as unknown as T; @@ -304,6 +317,11 @@ export function parseJsonResponse(raw: string): T | null { return null; } +export const program = makeProcessorProgram({ + id: "knowledge-extract", + make: (config) => new KnowledgeExtractService(config), +}); + export async function run(): Promise { await KnowledgeExtractService.launch("knowledge-extract"); } diff --git a/ts/packages/flow/src/flow-manager/service.ts b/ts/packages/flow/src/flow-manager/service.ts index 5aeb2198..6b7c83d7 100644 --- a/ts/packages/flow/src/flow-manager/service.ts +++ b/ts/packages/flow/src/flow-manager/service.ts @@ -22,6 +22,7 @@ import { type ConfigRequest, type ConfigResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import type { BackendProducer, BackendConsumer, @@ -136,7 +137,7 @@ export class FlowManagerService extends AsyncProcessor { while (this.running) { try { const msg = await this.consumer.receive(2000); - if (!msg) continue; + if (msg === null) continue; await this.handleMessage(msg); await this.consumer.acknowledge(msg); @@ -155,7 +156,7 @@ export class FlowManagerService extends AsyncProcessor { const props = msg.properties(); const requestId = props.id; - if (!requestId) { + if (requestId === undefined || requestId.length === 0) { console.warn("[FlowManager] Received request without id, ignoring"); return; } @@ -218,12 +219,12 @@ export class FlowManagerService extends AsyncProcessor { request: Record, ): Record { const name = request["blueprint-name"] as string | undefined; - if (!name) { + if (name === undefined || name.length === 0) { throw new Error("Missing blueprint-name"); } const blueprint = this.blueprints.get(name); - if (!blueprint) { + if (blueprint === undefined) { throw new Error(`Blueprint not found: ${name}`); } @@ -236,7 +237,7 @@ export class FlowManagerService extends AsyncProcessor { request: Record, ): Record { const name = request["blueprint-name"] as string | undefined; - if (!name) { + if (name === undefined || name.length === 0) { throw new Error("Missing blueprint-name"); } @@ -264,12 +265,12 @@ export class FlowManagerService extends AsyncProcessor { request: Record, ): Record { const id = request["flow-id"] as string | undefined; - if (!id) { + if (id === undefined || id.length === 0) { throw new Error("Missing flow-id"); } const inst = this.flows.get(id); - if (!inst) { + if (inst === undefined) { throw new Error(`Flow not found: ${id}`); } @@ -290,7 +291,7 @@ export class FlowManagerService extends AsyncProcessor { const description = (request["description"] as string) ?? ""; const parameters = (request["parameters"] as Record) ?? {}; - if (!id) { + if (id === undefined || id.length === 0) { throw new Error("Missing flow-id"); } @@ -299,7 +300,7 @@ export class FlowManagerService extends AsyncProcessor { } const blueprint = this.blueprints.get(blueprintName); - if (!blueprint) { + if (blueprint === undefined) { throw new Error(`Blueprint not found: ${blueprintName}`); } @@ -327,12 +328,12 @@ export class FlowManagerService extends AsyncProcessor { request: Record, ): Promise> { const id = request["flow-id"] as string | undefined; - if (!id) { + if (id === undefined || id.length === 0) { throw new Error("Missing flow-id"); } const inst = this.flows.get(id); - if (!inst) { + if (inst === undefined) { throw new Error(`Flow not found: ${id}`); } @@ -353,12 +354,12 @@ export class FlowManagerService extends AsyncProcessor { * to the config service via a PUT operation. */ private async pushFlowsConfig(): Promise { - if (!this.configClient) return; + if (this.configClient === null) return; const flowsConfig: Record }> = {}; for (const [id, inst] of this.flows) { const blueprint = this.blueprints.get(inst.blueprintName); - if (blueprint) { + if (blueprint !== undefined) { flowsConfig[id] = { topics: blueprint.topics }; } } @@ -380,15 +381,15 @@ export class FlowManagerService extends AsyncProcessor { // ---------- Lifecycle ---------- override async stop(): Promise { - if (this.consumer) { + if (this.consumer !== null) { await this.consumer.close(); this.consumer = null; } - if (this.responseProducer) { + if (this.responseProducer !== null) { await this.responseProducer.close(); this.responseProducer = null; } - if (this.configClient) { + if (this.configClient !== null) { await this.configClient.stop(); this.configClient = null; } @@ -400,6 +401,11 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export const program = makeProcessorProgram({ + id: "flow-manager", + make: (config) => new FlowManagerService(config), +}); + export async function run(): Promise { await FlowManagerService.launch("flow-manager"); } diff --git a/ts/packages/flow/src/gateway/dispatch/manager.ts b/ts/packages/flow/src/gateway/dispatch/manager.ts index ea84ea97..bc44a256 100644 --- a/ts/packages/flow/src/gateway/dispatch/manager.ts +++ b/ts/packages/flow/src/gateway/dispatch/manager.ts @@ -94,7 +94,7 @@ export class DispatcherManager { key: string, ): Promise> { let pending = this.requestors.get(key); - if (!pending) { + if (pending === undefined) { pending = (async () => { const rr = new RequestResponse({ pubsub: this.pubsub, @@ -114,7 +114,7 @@ export class DispatcherManager { kind: string, ): { requestTopic: string; responseTopic: string } { const entry = GLOBAL_SERVICES.get(kind); - if (entry) { + if (entry !== undefined) { return { requestTopic: topicName(entry.request), responseTopic: topicName(entry.response), @@ -131,7 +131,7 @@ export class DispatcherManager { kind: string, ): { requestTopic: string; responseTopic: string } { const entry = FLOW_SERVICES.get(kind); - if (entry) { + if (entry !== undefined) { return { requestTopic: topicName(entry.request), responseTopic: topicName(entry.response), @@ -152,15 +152,15 @@ export class DispatcherManager { if (typeof response !== "object" || response === null) return true; const res = response as Record; return ( - !!res.complete || - !!res.endOfStream || - !!res.endOfSession || - !!res.end_of_stream || - !!res.end_of_session || - !!res.end_of_dialog || - !!res.eos || + res.complete === true || + res.endOfStream === true || + res.endOfSession === true || + res.end_of_stream === true || + res.end_of_session === true || + res.end_of_dialog === true || + res.eos === true || // error responses are always final - !!res.error + (res.error !== undefined && res.error !== null) ); } diff --git a/ts/packages/flow/src/gateway/dispatch/mux.ts b/ts/packages/flow/src/gateway/dispatch/mux.ts index 6c504843..6ecb4a45 100644 --- a/ts/packages/flow/src/gateway/dispatch/mux.ts +++ b/ts/packages/flow/src/gateway/dispatch/mux.ts @@ -25,8 +25,11 @@ export class Mux { private queue = new AsyncQueue(); private outstanding = 0; private running = true; + private readonly handler: MuxHandler; - constructor(private readonly handler: MuxHandler) {} + constructor(handler: MuxHandler) { + this.handler = handler; + } receive(request: MuxRequest): void { if (this.queue.length >= MAX_QUEUE_SIZE) { diff --git a/ts/packages/flow/src/gateway/dispatch/serialize.ts b/ts/packages/flow/src/gateway/dispatch/serialize.ts index 4eb7feb1..18a4f53d 100644 --- a/ts/packages/flow/src/gateway/dispatch/serialize.ts +++ b/ts/packages/flow/src/gateway/dispatch/serialize.ts @@ -65,14 +65,18 @@ export function clientTermToInternal(wire: ClientTerm): Term { return { type: "LITERAL", value: wire.v, - datatype: wire.dt, - language: wire.ln, + ...(wire.dt !== undefined ? { datatype: wire.dt } : {}), + ...(wire.ln !== undefined ? { language: wire.ln } : {}), }; - case "t": + case "t": { + if (wire.tr === undefined) { + throw new Error("Client triple term is missing tr"); + } return { type: "TRIPLE", - triple: wire.tr ? clientTripleToInternal(wire.tr) : undefined!, + triple: clientTripleToInternal(wire.tr), }; + } default: // Defensive: pass through unknown term types return wire as unknown as Term; @@ -105,14 +109,14 @@ export function internalTermToClient(term: Term): ClientTerm { return { t: "b", d: term.id }; case "LITERAL": { const lit: ClientLiteralTerm = { t: "l", v: term.value }; - if (term.datatype) lit.dt = term.datatype; - if (term.language) lit.ln = term.language; + if (term.datatype !== undefined) lit.dt = term.datatype; + if (term.language !== undefined) lit.ln = term.language; return lit; } case "TRIPLE": return { t: "t", - tr: term.triple ? internalTripleToClient(term.triple) : undefined, + tr: internalTripleToClient(term.triple), }; default: return term as unknown as ClientTerm; @@ -131,7 +135,10 @@ export function internalTripleToClient(triple: Triple): ClientTriple { result.g = g; } else { // If g is a Term, convert it back to client wire format - result.g = (g as Record).iri as string | undefined; + const iri = (g as Record).iri; + if (typeof iri === "string") { + result.g = iri; + } } } return result; diff --git a/ts/packages/flow/src/gateway/server.ts b/ts/packages/flow/src/gateway/server.ts index 6ad8271a..27f79b93 100644 --- a/ts/packages/flow/src/gateway/server.ts +++ b/ts/packages/flow/src/gateway/server.ts @@ -10,7 +10,9 @@ import Fastify from "fastify"; import websocketPlugin from "@fastify/websocket"; -import { registry } from "@trustgraph/base"; +import { Config, Effect } from "effect"; +import * as O from "effect/Option"; +import { errorMessage, optionalStringConfig, registry, toTgError } from "@trustgraph/base"; import { DispatcherManager } from "./dispatch/manager.js"; import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js"; @@ -33,9 +35,9 @@ export async function createGateway(config: GatewayConfig) { if (request.url === "/api/v1/metrics") return; if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param - if (config.secret) { + if (config.secret !== undefined && config.secret.length > 0) { const auth = request.headers.authorization; - if (!auth || auth !== `Bearer ${config.secret}`) { + if (auth === undefined || auth !== `Bearer ${config.secret}`) { reply.code(401).send({ error: "Unauthorized" }); } } @@ -49,13 +51,13 @@ export async function createGateway(config: GatewayConfig) { try { const result = await dispatcher.dispatchGlobalService(kind, body) as Record; const err = result?.error as { type?: string; message?: string } | undefined; - if (err) { + if (err !== undefined) { const statusCode = err.type === "not-found" ? 404 : 400; return reply.code(statusCode).send(result); } return result; } catch (err) { - reply.code(500).send({ error: { type: "internal", message: String(err) } }); + reply.code(500).send({ error: toTgError(err) }); } }); @@ -69,13 +71,13 @@ export async function createGateway(config: GatewayConfig) { try { const result = await dispatcher.dispatchFlowService(flow, kind, body) as Record; const err = result?.error as { type?: string; message?: string } | undefined; - if (err) { + if (err !== undefined) { const statusCode = err.type === "not-found" ? 404 : 400; return reply.code(statusCode).send(result); } return result; } catch (err) { - reply.code(500).send({ error: { type: "internal", message: String(err) } }); + reply.code(500).send({ error: toTgError(err) }); } }, ); @@ -91,7 +93,7 @@ export async function createGateway(config: GatewayConfig) { collection?: string; }; - if (!body.documentId) { + if (body.documentId === undefined || body.documentId.length === 0) { return reply.code(400).send({ error: { type: "bad-request", message: "documentId is required" }, }); @@ -116,7 +118,7 @@ export async function createGateway(config: GatewayConfig) { return { status: "processing", documentId, flow }; } catch (err) { reply.code(500).send({ - error: { type: "internal", message: String(err) }, + error: toTgError(err), }); } }, @@ -128,14 +130,14 @@ export async function createGateway(config: GatewayConfig) { // Auth via query param const url = new URL(request.url, `http://${request.headers.host}`); const token = url.searchParams.get("token"); - if (config.secret && token !== config.secret) { + if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) { socket.close(4001, "Unauthorized"); return; } // Build the MuxHandler that dispatches to the DispatcherManager const handler: MuxHandler = async (muxReq, respond) => { - if (muxReq.flow) { + if (muxReq.flow !== undefined && muxReq.flow.length > 0) { await dispatcher.dispatchFlowServiceStreaming( muxReq.flow, muxReq.service, @@ -171,7 +173,13 @@ export async function createGateway(config: GatewayConfig) { request?: Record; }; - if (!msg.id || !msg.service || !msg.request) { + if ( + msg.id === undefined || + msg.id.length === 0 || + msg.service === undefined || + msg.service.length === 0 || + msg.request === undefined + ) { socket.send( JSON.stringify({ id: msg.id ?? null, @@ -185,15 +193,15 @@ export async function createGateway(config: GatewayConfig) { const muxReq: MuxRequest = { id: msg.id, service: msg.service, - flow: msg.flow, request: msg.request, + ...(msg.flow !== undefined ? { flow: msg.flow } : {}), }; mux.receive(muxReq); } catch (err) { socket.send( JSON.stringify({ - error: { type: "parse-error", message: String(err) }, + error: { type: "parse-error", message: errorMessage(err) }, complete: true, }), ); @@ -234,14 +242,36 @@ export async function createGateway(config: GatewayConfig) { } export async function run(): Promise { - const config: GatewayConfig = { - port: parseInt(process.env.GATEWAY_PORT ?? "8088", 10), - metricsPort: parseInt(process.env.METRICS_PORT ?? "8000", 10), - secret: process.env.GATEWAY_SECRET, - natsUrl: process.env.NATS_URL, - }; - - const gateway = await createGateway(config); - await gateway.start(); - console.log(`[Gateway] Listening on port ${config.port}`); + await Effect.runPromise(program); } + +export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () { + const secret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option)); + const natsUrl = yield* optionalStringConfig("NATS_URL"); + const port = yield* Config.number("GATEWAY_PORT").pipe(Config.withDefault(8088)); + const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000)); + return { + port, + metricsPort, + ...(secret !== undefined ? { secret } : {}), + ...(natsUrl !== undefined ? { natsUrl } : {}), + } satisfies GatewayConfig; +}); + +export const program = Effect.scoped( + Effect.gen(function* () { + const config = yield* loadGatewayConfig(); + const gateway = yield* Effect.promise(() => createGateway(config)).pipe(Effect.orDie); + yield* Effect.addFinalizer(() => Effect.promise(() => gateway.stop()).pipe(Effect.orDie)); + yield* Effect.promise(() => gateway.start()).pipe( + Effect.orDie, + Effect.withSpan("trustgraph.gateway.start", { + attributes: { + "trustgraph.gateway.port": config.port, + }, + }), + ); + yield* Effect.log(`[Gateway] Listening on port ${config.port}`); + return yield* Effect.never; + }), +); diff --git a/ts/packages/flow/src/index.ts b/ts/packages/flow/src/index.ts index f14788ac..73a37892 100644 --- a/ts/packages/flow/src/index.ts +++ b/ts/packages/flow/src/index.ts @@ -37,7 +37,12 @@ export { } from "./query/embeddings/qdrant-graph.js"; // Embeddings services -export { OllamaEmbeddingsProcessor, type OllamaEmbeddingsConfig } from "./embeddings/ollama.js"; +export { + OllamaEmbeddingsLive, + OllamaEmbeddingsProcessor, + makeOllamaEmbeddings, + type OllamaEmbeddingsConfig, +} from "./embeddings/ollama.js"; // Prompt template service export { PromptTemplateService, type PromptTemplate, type PromptTemplateConfig } from "./prompt/template.js"; diff --git a/ts/packages/flow/src/librarian/collection-manager.ts b/ts/packages/flow/src/librarian/collection-manager.ts index bf7a49bc..c92953fa 100644 --- a/ts/packages/flow/src/librarian/collection-manager.ts +++ b/ts/packages/flow/src/librarian/collection-manager.ts @@ -54,7 +54,7 @@ export class CollectionManager { ensureCollectionExists(user: string, collection: string): CollectionEntry { const existing = this.getCollection(user, collection); - if (existing) return existing; + if (existing !== undefined) return existing; return this.updateCollection(user, collection, collection, "", []); } diff --git a/ts/packages/flow/src/librarian/service.ts b/ts/packages/flow/src/librarian/service.ts index 9cb72a8b..3e76b60f 100644 --- a/ts/packages/flow/src/librarian/service.ts +++ b/ts/packages/flow/src/librarian/service.ts @@ -10,9 +10,6 @@ * Python reference: trustgraph-flow/trustgraph/librarian/service/service.py */ -import { randomUUID } from "node:crypto"; -import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; -import { dirname, join } from "node:path"; import { AsyncProcessor, type ProcessorConfig, @@ -24,8 +21,18 @@ import { type DocumentMetadata, type ProcessingMetadata, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base"; import { CollectionManager } from "./collection-manager.js"; +import { + ensureDirectory, + joinPath, + readBinaryFile, + readTextFile, + removePath, + writeBinaryFile, + writeTextFile, +} from "../runtime/effect-files.js"; export interface LibrarianServiceConfig extends ProcessorConfig { dataDir?: string; @@ -49,12 +56,12 @@ export class LibrarianService extends AsyncProcessor { constructor(config: LibrarianServiceConfig) { super(config); this.dataDir = config.dataDir ?? process.env.LIBRARIAN_DATA_DIR ?? "./data/librarian"; - this.persistPath = join(this.dataDir, "librarian-state.json"); + this.persistPath = joinPath(this.dataDir, "librarian-state.json"); } protected override async run(): Promise { // Ensure directories exist - await mkdir(join(this.dataDir, "docs"), { recursive: true }); + await ensureDirectory(joinPath(this.dataDir, "docs")); // Load persisted state await this.loadFromDisk(); @@ -84,14 +91,14 @@ export class LibrarianService extends AsyncProcessor { try { // Poll librarian requests const libMsg = await this.libConsumer.receive(2000); - if (libMsg) { + if (libMsg !== null) { await this.handleLibrarianMessage(libMsg); await this.libConsumer.acknowledge(libMsg); } // Poll collection management requests const colMsg = await this.colConsumer.receive(2000); - if (colMsg) { + if (colMsg !== null) { await this.handleCollectionMessage(colMsg); await this.colConsumer.acknowledge(colMsg); } @@ -110,7 +117,7 @@ export class LibrarianService extends AsyncProcessor { const props = msg.properties(); const requestId = props.id; - if (!requestId) { + if (requestId === undefined || requestId.length === 0) { console.warn("[LibrarianService] Received request without id, ignoring"); return; } @@ -156,9 +163,9 @@ export class LibrarianService extends AsyncProcessor { private async addDocument(request: LibrarianRequest): Promise { const meta = request.documentMetadata; - if (!meta) throw new Error("add-document requires documentMetadata"); + if (meta === undefined) throw new Error("add-document requires documentMetadata"); - const id = randomUUID(); + const id = crypto.randomUUID(); const now = Date.now(); const doc: DocumentMetadata = { @@ -170,10 +177,10 @@ export class LibrarianService extends AsyncProcessor { this.documents.set(id, doc); // Store file content if provided - if (request.content) { - const filePath = join(this.dataDir, "docs", `${id}.bin`); + if (request.content !== undefined && request.content.length > 0) { + const filePath = joinPath(this.dataDir, "docs", `${id}.bin`); const buf = Buffer.from(request.content, "base64"); - await writeFile(filePath, buf); + await writeBinaryFile(filePath, buf); } await this.persist(); @@ -184,14 +191,16 @@ export class LibrarianService extends AsyncProcessor { private async removeDocument(request: LibrarianRequest): Promise { const id = request.documentId; - if (!id) throw new Error("remove-document requires documentId"); + if (id === undefined || id.length === 0) { + throw new Error("remove-document requires documentId"); + } // Remove the document itself this.documents.delete(id); // Remove the file try { - await unlink(join(this.dataDir, "docs", `${id}.bin`)); + await removePath(joinPath(this.dataDir, "docs", `${id}.bin`)); } catch { // File may not exist — that's fine } @@ -204,7 +213,7 @@ export class LibrarianService extends AsyncProcessor { for (const childId of childIds) { this.documents.delete(childId); try { - await unlink(join(this.dataDir, "docs", `${childId}.bin`)); + await removePath(joinPath(this.dataDir, "docs", `${childId}.bin`)); } catch { // ignore } @@ -231,9 +240,9 @@ export class LibrarianService extends AsyncProcessor { for (const doc of this.documents.values()) { // Filter by user - if (user && doc.user !== user) continue; + if (user.length > 0 && doc.user !== user) continue; // Exclude children (only top-level documents) unless explicitly requested - if (doc.parentId) continue; + if (doc.parentId !== undefined && doc.parentId.length > 0) continue; docs.push(doc); } @@ -242,25 +251,29 @@ export class LibrarianService extends AsyncProcessor { private getDocumentMetadata(request: LibrarianRequest): LibrarianResponse { const id = request.documentId; - if (!id) throw new Error("get-document-metadata requires documentId"); + if (id === undefined || id.length === 0) { + throw new Error("get-document-metadata requires documentId"); + } const doc = this.documents.get(id); - if (!doc) throw new Error(`Document not found: ${id}`); + if (doc === undefined) throw new Error(`Document not found: ${id}`); return { documentMetadata: doc }; } private async getDocumentContent(request: LibrarianRequest): Promise { const id = request.documentId; - if (!id) throw new Error("get-document-content requires documentId"); + if (id === undefined || id.length === 0) { + throw new Error("get-document-content requires documentId"); + } const doc = this.documents.get(id); - if (!doc) throw new Error(`Document not found: ${id}`); + if (doc === undefined) throw new Error(`Document not found: ${id}`); try { - const filePath = join(this.dataDir, "docs", `${id}.bin`); - const buf = await readFile(filePath); - const content = buf.toString("base64"); + const filePath = joinPath(this.dataDir, "docs", `${id}.bin`); + const buf = await readBinaryFile(filePath); + const content = Buffer.from(buf).toString("base64"); return { documentMetadata: doc, content }; } catch { throw new Error(`Document content not found on disk: ${id}`); @@ -269,15 +282,19 @@ export class LibrarianService extends AsyncProcessor { private async addChildDocument(request: LibrarianRequest): Promise { const meta = request.documentMetadata; - if (!meta) throw new Error("add-child-document requires documentMetadata"); - if (!meta.parentId) throw new Error("add-child-document requires parentId in metadata"); + if (meta === undefined) { + throw new Error("add-child-document requires documentMetadata"); + } + if (meta.parentId === undefined || meta.parentId.length === 0) { + throw new Error("add-child-document requires parentId in metadata"); + } // Verify parent exists if (!this.documents.has(meta.parentId)) { throw new Error(`Parent document not found: ${meta.parentId}`); } - const id = randomUUID(); + const id = crypto.randomUUID(); const now = Date.now(); const doc: DocumentMetadata = { @@ -289,10 +306,10 @@ export class LibrarianService extends AsyncProcessor { this.documents.set(id, doc); // Store file content if provided - if (request.content) { - const filePath = join(this.dataDir, "docs", `${id}.bin`); + if (request.content !== undefined && request.content.length > 0) { + const filePath = joinPath(this.dataDir, "docs", `${id}.bin`); const buf = Buffer.from(request.content, "base64"); - await writeFile(filePath, buf); + await writeBinaryFile(filePath, buf); } await this.persist(); @@ -303,7 +320,9 @@ export class LibrarianService extends AsyncProcessor { private listChildren(request: LibrarianRequest): LibrarianResponse { const parentId = request.documentId; - if (!parentId) throw new Error("list-children requires documentId"); + if (parentId === undefined || parentId.length === 0) { + throw new Error("list-children requires documentId"); + } const children: DocumentMetadata[] = []; for (const doc of this.documents.values()) { @@ -317,9 +336,9 @@ export class LibrarianService extends AsyncProcessor { private async addProcessing(request: LibrarianRequest): Promise { const proc = request.processingMetadata; - if (!proc) throw new Error("add-processing requires processingMetadata"); + if (proc === undefined) throw new Error("add-processing requires processingMetadata"); - const id = randomUUID(); + const id = crypto.randomUUID(); const now = Date.now(); const record: ProcessingMetadata = { @@ -337,7 +356,9 @@ export class LibrarianService extends AsyncProcessor { private async removeProcessing(request: LibrarianRequest): Promise { const id = request.processingId; - if (!id) throw new Error("remove-processing requires processingId"); + if (id === undefined || id.length === 0) { + throw new Error("remove-processing requires processingId"); + } this.processing.delete(id); await this.persist(); @@ -350,7 +371,9 @@ export class LibrarianService extends AsyncProcessor { const records: ProcessingMetadata[] = []; for (const proc of this.processing.values()) { - if (documentId && proc.documentId !== documentId) continue; + if (documentId !== undefined && documentId.length > 0 && proc.documentId !== documentId) { + continue; + } records.push(proc); } @@ -364,7 +387,7 @@ export class LibrarianService extends AsyncProcessor { const props = msg.properties(); const requestId = props.id; - if (!requestId) { + if (requestId === undefined || requestId.length === 0) { console.warn("[LibrarianService] Received collection request without id, ignoring"); return; } @@ -430,8 +453,7 @@ export class LibrarianService extends AsyncProcessor { }; const json = JSON.stringify(data, null, 2); - await mkdir(dirname(this.persistPath), { recursive: true }); - await writeFile(this.persistPath, json, "utf-8"); + await writeTextFile(this.persistPath, json); } catch (err) { console.error("[LibrarianService] Failed to persist state:", err); } @@ -439,7 +461,7 @@ export class LibrarianService extends AsyncProcessor { private async loadFromDisk(): Promise { try { - const raw = await readFile(this.persistPath, "utf-8"); + const raw = await readTextFile(this.persistPath); const parsed = JSON.parse(raw) as { documents?: Record; processing?: Record; @@ -447,20 +469,20 @@ export class LibrarianService extends AsyncProcessor { }; this.documents.clear(); - if (parsed.documents) { + if (parsed.documents !== undefined) { for (const [id, doc] of Object.entries(parsed.documents)) { this.documents.set(id, doc); } } this.processing.clear(); - if (parsed.processing) { + if (parsed.processing !== undefined) { for (const [id, proc] of Object.entries(parsed.processing)) { this.processing.set(id, proc); } } - if (parsed.collections) { + if (parsed.collections !== undefined) { this.collectionManager.loadFromJSON(parsed.collections); } @@ -473,19 +495,19 @@ export class LibrarianService extends AsyncProcessor { } override async stop(): Promise { - if (this.libConsumer) { + if (this.libConsumer !== null) { await this.libConsumer.close(); this.libConsumer = null; } - if (this.libProducer) { + if (this.libProducer !== null) { await this.libProducer.close(); this.libProducer = null; } - if (this.colConsumer) { + if (this.colConsumer !== null) { await this.colConsumer.close(); this.colConsumer = null; } - if (this.colProducer) { + if (this.colProducer !== null) { await this.colProducer.close(); this.colProducer = null; } @@ -497,6 +519,11 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export const program = makeProcessorProgram({ + id: "librarian-svc", + make: (config) => new LibrarianService(config), +}); + export async function run(): Promise { await LibrarianService.launch("librarian-svc"); } diff --git a/ts/packages/flow/src/model/text-completion/azure-openai.ts b/ts/packages/flow/src/model/text-completion/azure-openai.ts index 770c037d..71f40eb3 100644 --- a/ts/packages/flow/src/model/text-completion/azure-openai.ts +++ b/ts/packages/flow/src/model/text-completion/azure-openai.ts @@ -14,8 +14,9 @@ import { type ProcessorConfig, type LlmResult, type LlmChunk, - TooManyRequestsError, + tooManyRequestsError, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class AzureOpenAIProcessor extends LlmService { private client: AzureOpenAI; @@ -40,10 +41,14 @@ export class AzureOpenAIProcessor extends LlmService { this.maxOutput = config.maxOutput ?? 4096; const apiKey = config.apiKey ?? process.env.AZURE_TOKEN; - if (!apiKey) throw new Error("Azure OpenAI API key not specified"); + if (apiKey === undefined || apiKey.length === 0) { + throw new Error("Azure OpenAI API key not specified"); + } const endpoint = config.endpoint ?? process.env.AZURE_ENDPOINT; - if (!endpoint) throw new Error("Azure OpenAI endpoint not specified"); + if (endpoint === undefined || endpoint.length === 0) { + throw new Error("Azure OpenAI endpoint not specified"); + } const apiVersion = config.apiVersion ?? @@ -83,7 +88,7 @@ export class AzureOpenAIProcessor extends LlmService { }; } catch (err) { if ((err as any)?.status === 429) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } @@ -119,9 +124,10 @@ export class AzureOpenAIProcessor extends LlmService { let totalOutputTokens = 0; for await (const chunk of stream) { - if (chunk.choices?.[0]?.delta?.content) { + const content = chunk.choices[0]?.delta?.content; + if (content !== null && content !== undefined && content.length > 0) { yield { - text: chunk.choices[0].delta.content, + text: content, inToken: null, outToken: null, model: modelName, @@ -129,7 +135,7 @@ export class AzureOpenAIProcessor extends LlmService { }; } - if (chunk.usage) { + if (chunk.usage !== null && chunk.usage !== undefined) { totalInputTokens = chunk.usage.prompt_tokens; totalOutputTokens = chunk.usage.completion_tokens; } @@ -144,13 +150,18 @@ export class AzureOpenAIProcessor extends LlmService { }; } catch (err) { if ((err as any)?.status === 429) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } } } +export const program = makeProcessorProgram({ + id: "text-completion", + make: (config) => new AzureOpenAIProcessor(config), +}); + export async function run(): Promise { await AzureOpenAIProcessor.launch("text-completion"); } diff --git a/ts/packages/flow/src/model/text-completion/claude.ts b/ts/packages/flow/src/model/text-completion/claude.ts index 098dd278..461c5492 100644 --- a/ts/packages/flow/src/model/text-completion/claude.ts +++ b/ts/packages/flow/src/model/text-completion/claude.ts @@ -5,7 +5,8 @@ */ import Anthropic from "@anthropic-ai/sdk"; -import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, TooManyRequestsError } from "@trustgraph/base"; +import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class ClaudeProcessor extends LlmService { private client: Anthropic; @@ -26,7 +27,9 @@ export class ClaudeProcessor extends LlmService { this.maxOutput = config.maxOutput ?? 8192; const apiKey = config.apiKey ?? process.env.CLAUDE_KEY; - if (!apiKey) throw new Error("Claude API key not specified"); + if (apiKey === undefined || apiKey.length === 0) { + throw new Error("Claude API key not specified"); + } this.client = new Anthropic({ apiKey }); @@ -65,7 +68,7 @@ export class ClaudeProcessor extends LlmService { }; } catch (err) { if (err instanceof Anthropic.RateLimitError) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } @@ -117,13 +120,18 @@ export class ClaudeProcessor extends LlmService { }; } catch (err) { if (err instanceof Anthropic.RateLimitError) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } } } +export const program = makeProcessorProgram({ + id: "text-completion", + make: (config) => new ClaudeProcessor(config), +}); + export async function run(): Promise { await ClaudeProcessor.launch("text-completion"); } diff --git a/ts/packages/flow/src/model/text-completion/mistral.ts b/ts/packages/flow/src/model/text-completion/mistral.ts index cef090b2..c87573e6 100644 --- a/ts/packages/flow/src/model/text-completion/mistral.ts +++ b/ts/packages/flow/src/model/text-completion/mistral.ts @@ -12,8 +12,9 @@ import { type ProcessorConfig, type LlmResult, type LlmChunk, - TooManyRequestsError, + tooManyRequestsError, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class MistralProcessor extends LlmService { private client: Mistral; @@ -37,7 +38,9 @@ export class MistralProcessor extends LlmService { this.maxOutput = config.maxOutput ?? 4096; const apiKey = config.apiKey ?? process.env.MISTRAL_TOKEN; - if (!apiKey) throw new Error("Mistral API key not specified"); + if (apiKey === undefined || apiKey.length === 0) { + throw new Error("Mistral API key not specified"); + } this.client = new Mistral({ apiKey }); @@ -72,7 +75,7 @@ export class MistralProcessor extends LlmService { }; } catch (err) { if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } @@ -107,9 +110,10 @@ export class MistralProcessor extends LlmService { for await (const chunk of stream) { const delta = chunk.data?.choices?.[0]?.delta; - if (delta?.content) { + const content = delta?.content; + if (typeof content === "string" && content.length > 0) { yield { - text: delta.content as string, + text: content, inToken: null, outToken: null, model: modelName, @@ -117,7 +121,7 @@ export class MistralProcessor extends LlmService { }; } - if (chunk.data?.usage) { + if (chunk.data?.usage !== undefined) { totalInputTokens = chunk.data.usage.promptTokens ?? 0; totalOutputTokens = chunk.data.usage.completionTokens ?? 0; } @@ -132,13 +136,18 @@ export class MistralProcessor extends LlmService { }; } catch (err) { if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } } } +export const program = makeProcessorProgram({ + id: "text-completion", + make: (config) => new MistralProcessor(config), +}); + export async function run(): Promise { await MistralProcessor.launch("text-completion"); } diff --git a/ts/packages/flow/src/model/text-completion/ollama.ts b/ts/packages/flow/src/model/text-completion/ollama.ts index 55935664..ed89ee15 100644 --- a/ts/packages/flow/src/model/text-completion/ollama.ts +++ b/ts/packages/flow/src/model/text-completion/ollama.ts @@ -8,6 +8,7 @@ import { Ollama } from "ollama"; import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class OllamaProcessor extends LlmService { private client: Ollama; @@ -90,7 +91,7 @@ export class OllamaProcessor extends LlmService { totalOutputTokens = chunk.eval_count; } - if (chunk.response) { + if (chunk.response.length > 0) { yield { text: chunk.response, inToken: null, @@ -112,6 +113,11 @@ export class OllamaProcessor extends LlmService { } } +export const program = makeProcessorProgram({ + id: "text-completion", + make: (config) => new OllamaProcessor(config), +}); + export async function run(): Promise { await OllamaProcessor.launch("text-completion"); } diff --git a/ts/packages/flow/src/model/text-completion/openai-compatible.ts b/ts/packages/flow/src/model/text-completion/openai-compatible.ts index 73ed47cf..15b56d0d 100644 --- a/ts/packages/flow/src/model/text-completion/openai-compatible.ts +++ b/ts/packages/flow/src/model/text-completion/openai-compatible.ts @@ -16,6 +16,7 @@ import { type LlmResult, type LlmChunk, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class OpenAICompatibleProcessor extends LlmService { private client: OpenAI; @@ -40,10 +41,11 @@ export class OpenAICompatibleProcessor extends LlmService { this.maxOutput = config.maxOutput ?? 4096; const baseURL = config.baseUrl ?? process.env.OPENAI_COMPAT_URL; - if (!baseURL) + if (baseURL === undefined || baseURL.length === 0) { throw new Error( "OpenAI-compatible server URL not specified (set OPENAI_COMPAT_URL)", ); + } const apiKey = config.apiKey ?? process.env.OPENAI_COMPAT_KEY ?? "sk-no-key-required"; @@ -108,9 +110,10 @@ export class OpenAICompatibleProcessor extends LlmService { let totalOutputTokens = 0; for await (const chunk of stream) { - if (chunk.choices?.[0]?.delta?.content) { + const content = chunk.choices[0]?.delta?.content; + if (content !== null && content !== undefined && content.length > 0) { yield { - text: chunk.choices[0].delta.content, + text: content, inToken: null, outToken: null, model: modelName, @@ -118,7 +121,7 @@ export class OpenAICompatibleProcessor extends LlmService { }; } - if (chunk.usage) { + if (chunk.usage !== null && chunk.usage !== undefined) { totalInputTokens = chunk.usage.prompt_tokens; totalOutputTokens = chunk.usage.completion_tokens; } @@ -134,6 +137,11 @@ export class OpenAICompatibleProcessor extends LlmService { } } +export const program = makeProcessorProgram({ + id: "text-completion", + make: (config) => new OpenAICompatibleProcessor(config), +}); + export async function run(): Promise { await OpenAICompatibleProcessor.launch("text-completion"); } diff --git a/ts/packages/flow/src/model/text-completion/openai.ts b/ts/packages/flow/src/model/text-completion/openai.ts index e22cc1cd..ec23e66a 100644 --- a/ts/packages/flow/src/model/text-completion/openai.ts +++ b/ts/packages/flow/src/model/text-completion/openai.ts @@ -5,7 +5,8 @@ */ import OpenAI from "openai"; -import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, TooManyRequestsError } from "@trustgraph/base"; +import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export class OpenAIProcessor extends LlmService { private client: OpenAI; @@ -27,7 +28,9 @@ export class OpenAIProcessor extends LlmService { this.maxOutput = config.maxOutput ?? 4096; const apiKey = config.apiKey ?? process.env.OPENAI_TOKEN; - if (!apiKey) throw new Error("OpenAI API key not specified"); + if (apiKey === undefined || apiKey.length === 0) { + throw new Error("OpenAI API key not specified"); + } this.client = new OpenAI({ apiKey, @@ -65,7 +68,7 @@ export class OpenAIProcessor extends LlmService { }; } catch (err) { if (err instanceof OpenAI.RateLimitError) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } @@ -101,9 +104,10 @@ export class OpenAIProcessor extends LlmService { let totalOutputTokens = 0; for await (const chunk of stream) { - if (chunk.choices?.[0]?.delta?.content) { + const content = chunk.choices[0]?.delta?.content; + if (content !== null && content !== undefined && content.length > 0) { yield { - text: chunk.choices[0].delta.content, + text: content, inToken: null, outToken: null, model: modelName, @@ -111,7 +115,7 @@ export class OpenAIProcessor extends LlmService { }; } - if (chunk.usage) { + if (chunk.usage !== null && chunk.usage !== undefined) { totalInputTokens = chunk.usage.prompt_tokens; totalOutputTokens = chunk.usage.completion_tokens; } @@ -126,13 +130,18 @@ export class OpenAIProcessor extends LlmService { }; } catch (err) { if (err instanceof OpenAI.RateLimitError) { - throw new TooManyRequestsError(); + throw tooManyRequestsError(); } throw err; } } } +export const program = makeProcessorProgram({ + id: "text-completion", + make: (config) => new OpenAIProcessor(config), +}); + export async function run(): Promise { await OpenAIProcessor.launch("text-completion"); } diff --git a/ts/packages/flow/src/prompt/template.ts b/ts/packages/flow/src/prompt/template.ts index 23cc5a3a..4c61a7ce 100644 --- a/ts/packages/flow/src/prompt/template.ts +++ b/ts/packages/flow/src/prompt/template.ts @@ -33,6 +33,7 @@ import { type PromptRequest, type PromptResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; export interface PromptTemplate { system: string; @@ -53,7 +54,7 @@ export class PromptTemplateService extends FlowProcessor { this.configKey = config.configKey ?? "prompt"; this.registerSpecification( - new ConsumerSpec( + ConsumerSpec.fromPromise( "prompt-request", this.onRequest.bind(this), ), @@ -75,7 +76,7 @@ export class PromptTemplateService extends FlowProcessor { | Record | undefined; - if (!promptConfig) { + if (promptConfig === undefined) { console.warn(`[PromptTemplate] No key "${this.configKey}" in config`); return; } @@ -104,13 +105,13 @@ export class PromptTemplateService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const responseProducer = flowCtx.flow.producer("prompt-response"); try { const template = this.templates.get(msg.name); - if (!template) { + if (template === undefined) { throw new Error(`Unknown prompt template: "${msg.name}"`); } @@ -149,6 +150,11 @@ function renderTemplate( }); } +export const program = makeProcessorProgram({ + id: "prompt", + make: (config) => new PromptTemplateService(config), +}); + export async function run(): Promise { await PromptTemplateService.launch("prompt"); } diff --git a/ts/packages/flow/src/query/embeddings/qdrant-doc-service.ts b/ts/packages/flow/src/query/embeddings/qdrant-doc-service.ts index 87dc79e9..03b0a829 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-doc-service.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-doc-service.ts @@ -16,6 +16,7 @@ import { type DocumentEmbeddingsRequest, type DocumentEmbeddingsResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { QdrantDocEmbeddingsQuery } from "./qdrant-doc.js"; export class DocEmbeddingsQueryService extends FlowProcessor { @@ -26,7 +27,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor { this.query = new QdrantDocEmbeddingsQuery(); this.registerSpecification( - new ConsumerSpec( + ConsumerSpec.fromPromise( "document-embeddings-request", this.onMessage.bind(this), ), @@ -44,7 +45,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const producer = flowCtx.flow.producer("document-embeddings-response"); const collection = msg.collection ?? "default"; @@ -64,7 +65,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor { allChunks.push({ chunkId: match.chunkId, score: match.score, - content: match.content, + ...(match.content !== undefined ? { content: match.content } : {}), }); } } @@ -80,6 +81,11 @@ export class DocEmbeddingsQueryService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "doc-embeddings-query", + make: (config) => new DocEmbeddingsQueryService(config), +}); + export async function run(): Promise { await DocEmbeddingsQueryService.launch("doc-embeddings-query"); } diff --git a/ts/packages/flow/src/query/embeddings/qdrant-doc.ts b/ts/packages/flow/src/query/embeddings/qdrant-doc.ts index 94259513..5132cec5 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-doc.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-doc.ts @@ -34,7 +34,10 @@ export class QdrantDocEmbeddingsQuery { const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; - this.client = new QdrantClient({ url, apiKey }); + this.client = new QdrantClient({ + url, + ...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), + }); console.log("[QdrantDocQuery] Query service initialized"); } @@ -42,7 +45,7 @@ export class QdrantDocEmbeddingsQuery { async query(request: DocEmbeddingsQueryRequest): Promise { const { vector, user, collection, limit } = request; - if (!vector || vector.length === 0) { + if (vector.length === 0) { return []; } @@ -68,11 +71,11 @@ export class QdrantDocEmbeddingsQuery { for (const point of searchResult) { const payload = point.payload as Record | undefined; const chunkId = payload?.chunk_id as string | undefined; - if (chunkId) { + if (chunkId !== undefined && chunkId.length > 0) { chunks.push({ chunkId, score: point.score, - content: (payload?.content as string) ?? undefined, + ...(typeof payload?.content === "string" ? { content: payload.content } : {}), }); } } diff --git a/ts/packages/flow/src/query/embeddings/qdrant-graph-service.ts b/ts/packages/flow/src/query/embeddings/qdrant-graph-service.ts index 71f74b8c..660ba0bc 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-graph-service.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-graph-service.ts @@ -16,6 +16,7 @@ import { type GraphEmbeddingsRequest, type GraphEmbeddingsResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { QdrantGraphEmbeddingsQuery } from "./qdrant-graph.js"; export class GraphEmbeddingsQueryService extends FlowProcessor { @@ -26,7 +27,7 @@ export class GraphEmbeddingsQueryService extends FlowProcessor { this.query = new QdrantGraphEmbeddingsQuery(); this.registerSpecification( - new ConsumerSpec( + ConsumerSpec.fromPromise( "graph-embeddings-request", this.onMessage.bind(this), ), @@ -44,7 +45,7 @@ export class GraphEmbeddingsQueryService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const producer = flowCtx.flow.producer("graph-embeddings-response"); const user = msg.user ?? "default"; @@ -79,6 +80,11 @@ export class GraphEmbeddingsQueryService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "graph-embeddings-query", + make: (config) => new GraphEmbeddingsQueryService(config), +}); + export async function run(): Promise { await GraphEmbeddingsQueryService.launch("graph-embeddings-query"); } diff --git a/ts/packages/flow/src/query/embeddings/qdrant-graph.ts b/ts/packages/flow/src/query/embeddings/qdrant-graph.ts index 3f26f742..0eaa4ef8 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-graph.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-graph.ts @@ -44,7 +44,10 @@ export class QdrantGraphEmbeddingsQuery { const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; - this.client = new QdrantClient({ url, apiKey }); + this.client = new QdrantClient({ + url, + ...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), + }); console.log("[QdrantGraphQuery] Query service initialized"); } @@ -52,7 +55,7 @@ export class QdrantGraphEmbeddingsQuery { async query(request: GraphEmbeddingsQueryRequest): Promise { const { vector, user, collection, limit } = request; - if (!vector || vector.length === 0) { + if (vector.length === 0) { return []; } @@ -82,7 +85,7 @@ export class QdrantGraphEmbeddingsQuery { for (const point of searchResult) { const payload = point.payload as Record | undefined; const entityValue = payload?.entity as string | undefined; - if (!entityValue) continue; + if (entityValue === undefined || entityValue.length === 0) continue; // Deduplicate by entity value, keeping the highest score (results are // already sorted by score descending from Qdrant) diff --git a/ts/packages/flow/src/query/triples/falkordb-service.ts b/ts/packages/flow/src/query/triples/falkordb-service.ts index f5f6a931..9004ff06 100644 --- a/ts/packages/flow/src/query/triples/falkordb-service.ts +++ b/ts/packages/flow/src/query/triples/falkordb-service.ts @@ -16,6 +16,7 @@ import { type TriplesQueryRequest, type TriplesQueryResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { FalkorDBTriplesQuery } from "./falkordb.js"; export class TriplesQueryService extends FlowProcessor { @@ -26,7 +27,7 @@ export class TriplesQueryService extends FlowProcessor { this.query = new FalkorDBTriplesQuery(); this.registerSpecification( - new ConsumerSpec("triples-request", this.onMessage.bind(this)), + ConsumerSpec.fromPromise("triples-request", this.onMessage.bind(this)), ); this.registerSpecification(new ProducerSpec("triples-response")); @@ -39,7 +40,7 @@ export class TriplesQueryService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const producer = flowCtx.flow.producer("triples-response"); @@ -62,6 +63,11 @@ export class TriplesQueryService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "triples-query", + make: (config) => new TriplesQueryService(config), +}); + export async function run(): Promise { await TriplesQueryService.launch("triples-query"); } diff --git a/ts/packages/flow/src/query/triples/falkordb.ts b/ts/packages/flow/src/query/triples/falkordb.ts index be7fc961..231fc812 100644 --- a/ts/packages/flow/src/query/triples/falkordb.ts +++ b/ts/packages/flow/src/query/triples/falkordb.ts @@ -15,7 +15,7 @@ export interface FalkorDBQueryConfig { } function termToValue(term: Term | undefined): string | null { - if (!term) return null; + if (term === undefined) return null; switch (term.type) { case "IRI": return term.iri; case "LITERAL": return term.value; @@ -25,7 +25,7 @@ function termToValue(term: Term | undefined): string | null { } function createTerm(value: string): Term { - if (!value) { + if (value.length === 0) { return { type: "LITERAL", value: "" }; } if (value.startsWith("http://") || value.startsWith("https://")) { @@ -75,25 +75,25 @@ export class FalkorDBTriplesQuery { const rawTriples: [string, string, string][] = []; // Query both Node and Literal targets for each pattern - if (sv && pv && ov) { + if (sv !== null && pv !== null && ov !== null) { // SPO — exact match await this.matchPattern(rawTriples, sv, pv, ov, limit); - } else if (sv && pv) { + } else if (sv !== null && pv !== null) { // SP — known subject + predicate await this.matchSP(rawTriples, sv, pv, limit); - } else if (sv && ov) { + } else if (sv !== null && ov !== null) { // SO — known subject + object await this.matchSO(rawTriples, sv, ov, limit); - } else if (pv && ov) { + } else if (pv !== null && ov !== null) { // PO — known predicate + object await this.matchPO(rawTriples, pv, ov, limit); - } else if (sv) { + } else if (sv !== null) { // S only await this.matchS(rawTriples, sv, limit); - } else if (pv) { + } else if (pv !== null) { // P only await this.matchP(rawTriples, pv, limit); - } else if (ov) { + } else if (ov !== null) { // O only await this.matchO(rawTriples, ov, limit); } else { @@ -102,7 +102,7 @@ export class FalkorDBTriplesQuery { } return rawTriples - .filter(([s, p, o]) => s != null && p != null && o != null) + .filter(([s, p, o]) => s !== null && p !== null && o !== null) .slice(0, limit) .map(([s, p, o]) => ({ s: createTerm(s), diff --git a/ts/packages/flow/src/retrieval/document-rag-service.ts b/ts/packages/flow/src/retrieval/document-rag-service.ts index b679b6f1..d3e46ee5 100644 --- a/ts/packages/flow/src/retrieval/document-rag-service.ts +++ b/ts/packages/flow/src/retrieval/document-rag-service.ts @@ -27,6 +27,7 @@ import { type PromptRequest, type PromptResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { DocumentRag } from "./document-rag.js"; export class DocumentRagService extends FlowProcessor { @@ -35,7 +36,7 @@ export class DocumentRagService extends FlowProcessor { // Consumer: document RAG requests this.registerSpecification( - new ConsumerSpec("document-rag-request", this.onRequest.bind(this)), + ConsumerSpec.fromPromise("document-rag-request", this.onRequest.bind(this)), ); // Producer: document RAG responses @@ -80,7 +81,7 @@ export class DocumentRagService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const producer = flowCtx.flow.producer("document-rag-response"); @@ -93,7 +94,7 @@ export class DocumentRagService extends FlowProcessor { }); const response = await documentRag.query(msg.query, { - collection: msg.collection, + ...(msg.collection !== undefined ? { collection: msg.collection } : {}), }); await producer.send(requestId, { response, endOfStream: true }); @@ -107,6 +108,11 @@ export class DocumentRagService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "document-rag", + make: (config) => new DocumentRagService(config), +}); + export async function run(): Promise { await DocumentRagService.launch("document-rag"); } diff --git a/ts/packages/flow/src/retrieval/document-rag.ts b/ts/packages/flow/src/retrieval/document-rag.ts index 3f0a3971..379d0899 100644 --- a/ts/packages/flow/src/retrieval/document-rag.ts +++ b/ts/packages/flow/src/retrieval/document-rag.ts @@ -8,7 +8,7 @@ */ import type { - RequestResponse, + FlowRequestor, TextCompletionRequest, TextCompletionResponse, EmbeddingsRequest, @@ -20,16 +20,20 @@ import type { } from "@trustgraph/base"; export interface DocumentRagClients { - llm: RequestResponse; - embeddings: RequestResponse; - docEmbeddings: RequestResponse; - prompt: RequestResponse; + llm: FlowRequestor; + embeddings: FlowRequestor; + docEmbeddings: FlowRequestor; + prompt: FlowRequestor; } export type ChunkCallback = (text: string, endOfStream: boolean) => Promise; export class DocumentRag { - constructor(private readonly clients: DocumentRagClients) {} + private readonly clients: DocumentRagClients; + + constructor(clients: DocumentRagClients) { + this.clients = clients; + } async query( queryText: string, @@ -57,8 +61,9 @@ export class DocumentRag { // Step 3: Build context from chunks const context = chunks - .filter((c) => c.content) - .map((c) => c.content) + .flatMap((c) => + c.content !== undefined && c.content.length > 0 ? [c.content] : [], + ) .join("\n\n---\n\n"); // Step 4: Synthesize answer diff --git a/ts/packages/flow/src/retrieval/graph-rag-service.ts b/ts/packages/flow/src/retrieval/graph-rag-service.ts index 41e3c2ee..257afe8d 100644 --- a/ts/packages/flow/src/retrieval/graph-rag-service.ts +++ b/ts/packages/flow/src/retrieval/graph-rag-service.ts @@ -31,6 +31,7 @@ import { type PromptRequest, type PromptResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { GraphRag } from "./graph-rag.js"; export class GraphRagService extends FlowProcessor { @@ -39,7 +40,7 @@ export class GraphRagService extends FlowProcessor { // Consumer: graph RAG requests this.registerSpecification( - new ConsumerSpec("graph-rag-request", this.onRequest.bind(this)), + ConsumerSpec.fromPromise("graph-rag-request", this.onRequest.bind(this)), ); // Producer: graph RAG responses @@ -91,7 +92,7 @@ export class GraphRagService extends FlowProcessor { flowCtx: FlowContext, ): Promise { const requestId = properties.id; - if (!requestId) return; + if (requestId === undefined || requestId.length === 0) return; const producer = flowCtx.flow.producer("graph-rag-response"); console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`); @@ -107,15 +108,17 @@ export class GraphRagService extends FlowProcessor { prompt: flowCtx.flow.requestor("prompt"), }, { - entityLimit: msg.entityLimit, - tripleLimit: msg.tripleLimit, - maxSubgraphSize: msg.maxSubgraphSize, - maxPathLength: msg.maxPathLength, + ...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}), + ...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}), + ...(msg.maxSubgraphSize !== undefined + ? { maxSubgraphSize: msg.maxSubgraphSize } + : {}), + ...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}), }, ); const result = await graphRag.query(msg.query, { - collection: msg.collection, + ...(msg.collection !== undefined ? { collection: msg.collection } : {}), }); // Send answer with explain data embedded in a SINGLE message. @@ -145,6 +148,11 @@ export class GraphRagService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "graph-rag", + make: (config) => new GraphRagService(config), +}); + export async function run(): Promise { await GraphRagService.launch("graph-rag"); } diff --git a/ts/packages/flow/src/retrieval/graph-rag.ts b/ts/packages/flow/src/retrieval/graph-rag.ts index 08cc3316..11f2b34b 100644 --- a/ts/packages/flow/src/retrieval/graph-rag.ts +++ b/ts/packages/flow/src/retrieval/graph-rag.ts @@ -16,9 +16,9 @@ import type { EmbeddingsResponse, GraphEmbeddingsRequest, GraphEmbeddingsResponse, + FlowRequestor, PromptRequest, PromptResponse, - RequestResponse, Term, TextCompletionRequest, TextCompletionResponse, @@ -37,11 +37,11 @@ export interface GraphRagConfig { } export interface GraphRagClients { - llm: RequestResponse; - embeddings: RequestResponse; - graphEmbeddings: RequestResponse; - triples: RequestResponse; - prompt: RequestResponse; + llm: FlowRequestor; + embeddings: FlowRequestor; + graphEmbeddings: FlowRequestor; + triples: FlowRequestor; + prompt: FlowRequestor; } export type ChunkCallback = (text: string, endOfStream: boolean) => Promise; @@ -52,12 +52,14 @@ export interface GraphRagResult { } export class GraphRag { + private readonly clients: GraphRagClients; private config: Required; constructor( - private readonly clients: GraphRagClients, + clients: GraphRagClients, config: GraphRagConfig = {}, ) { + this.clients = clients; this.config = { entityLimit: config.entityLimit ?? 50, tripleLimit: config.tripleLimit ?? 30, @@ -125,7 +127,7 @@ export class GraphRag { return (llmResp as TextCompletionResponse).response .split("\n") .map((c) => c.trim()) - .filter(Boolean); + .filter((c) => c.length > 0); } private async getVectors(concepts: string[]): Promise { @@ -166,11 +168,12 @@ export class GraphRag { // Query each entity as subject to get outgoing edges const queries = unvisited.map((entityStr) => { const term = stringToTerm(entityStr); - return this.clients.triples.request({ + const request: TriplesQueryRequest = { s: term, - collection, limit: this.config.tripleLimit, - }); + ...(collection !== undefined ? { collection } : {}), + }; + return this.clients.triples.request(request); }); const results = await Promise.all(queries); @@ -257,7 +260,12 @@ export class GraphRag { const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>; if (Array.isArray(parsed)) { for (const item of parsed) { - if (item && typeof item.id === "string" && typeof item.score === "number") { + if ( + typeof item === "object" && + item !== null && + typeof item.id === "string" && + typeof item.score === "number" + ) { scored.push({ id: item.id, score: item.score }); } } @@ -266,10 +274,15 @@ export class GraphRag { // Fall back to parsing line-by-line JSON objects for (const line of responseText.split("\n")) { const trimmed = line.trim(); - if (!trimmed) continue; + if (trimmed.length === 0) continue; try { const obj = JSON.parse(trimmed) as { id?: string; score?: number }; - if (obj && typeof obj.id === "string" && typeof obj.score === "number") { + if ( + typeof obj === "object" && + obj !== null && + typeof obj.id === "string" && + typeof obj.score === "number" + ) { scored.push({ id: obj.id, score: obj.score }); } } catch { @@ -281,8 +294,6 @@ export class GraphRag { // Sort by score descending and keep top N scored.sort((a, b) => b.score - a.score); const topN = scored.slice(0, this.config.edgeLimit); - const selectedIds = new Set(topN.map((e) => e.id)); - // Map back to triples const result: Triple[] = []; for (const entry of topN) { @@ -317,7 +328,7 @@ export class GraphRag { variables: { query, context }, }); - if (chunkCallback) { + if (chunkCallback !== undefined) { // Streaming response let fullText = ""; await this.clients.llm.request( @@ -329,11 +340,11 @@ export class GraphRag { { recipient: async (resp) => { const r = resp as TextCompletionResponse; - if (r.response) { + if (r.response.length > 0) { fullText += r.response; - await chunkCallback(r.response, !!r.endOfStream); + await chunkCallback(r.response, r.endOfStream === true); } - return !!r.endOfStream; + return r.endOfStream === true; }, }, ); diff --git a/ts/packages/flow/src/runtime/effect-files.ts b/ts/packages/flow/src/runtime/effect-files.ts new file mode 100644 index 00000000..66f09044 --- /dev/null +++ b/ts/packages/flow/src/runtime/effect-files.ts @@ -0,0 +1,40 @@ +export function joinPath(...segments: string[]): string { + const joined = segments + .filter((segment) => segment.length > 0) + .join("/"); + + return joined.replace(/\/+/g, "/"); +} + +export function dirnamePath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + + if (index < 0) return "."; + if (index === 0) return "/"; + return normalized.slice(0, index); +} + +export function ensureDirectory(path: string): Promise { + return Bun.$`mkdir -p ${path}`.quiet().then(() => undefined); +} + +export function readTextFile(path: string): Promise { + return Bun.file(path).text(); +} + +export async function readBinaryFile(path: string): Promise { + return new Uint8Array(await Bun.file(path).arrayBuffer()); +} + +export function writeTextFile(path: string, data: string): Promise { + return Bun.write(path, data).then(() => undefined); +} + +export function writeBinaryFile(path: string, data: Uint8Array): Promise { + return Bun.write(path, data).then(() => undefined); +} + +export function removePath(path: string): Promise { + return Bun.file(path).delete(); +} diff --git a/ts/packages/flow/src/storage/embeddings/graph-embeddings-service.ts b/ts/packages/flow/src/storage/embeddings/graph-embeddings-service.ts index 1f335f61..33d1021a 100644 --- a/ts/packages/flow/src/storage/embeddings/graph-embeddings-service.ts +++ b/ts/packages/flow/src/storage/embeddings/graph-embeddings-service.ts @@ -19,6 +19,7 @@ import { type EmbeddingsRequest, type EmbeddingsResponse, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { QdrantGraphEmbeddingsStore } from "./qdrant-graph.js"; export class GraphEmbeddingsStoreService extends FlowProcessor { @@ -29,7 +30,7 @@ export class GraphEmbeddingsStoreService extends FlowProcessor { this.store = new QdrantGraphEmbeddingsStore(); this.registerSpecification( - new ConsumerSpec( + ConsumerSpec.fromPromise( "store-graph-embeddings-input", this.onMessage.bind(this), ), @@ -47,10 +48,10 @@ export class GraphEmbeddingsStoreService extends FlowProcessor { private async onMessage( msg: EntityContexts, - properties: Record, + _properties: Record, flowCtx: FlowContext, ): Promise { - if (!msg.entities || msg.entities.length === 0) return; + if (msg.entities.length === 0) return; const embeddingsClient = flowCtx.flow.requestor("embeddings-client"); @@ -63,7 +64,7 @@ export class GraphEmbeddingsStoreService extends FlowProcessor { // Call embeddings service const embResponse = await embeddingsClient.request({ text: texts }); - if (embResponse.error) { + if (embResponse.error !== undefined) { console.error( "[GraphEmbeddingsStore] Embeddings error:", embResponse.error.message, @@ -86,6 +87,11 @@ export class GraphEmbeddingsStoreService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "graph-embeddings-store", + make: (config) => new GraphEmbeddingsStoreService(config), +}); + export async function run(): Promise { await GraphEmbeddingsStoreService.launch("graph-embeddings-store"); } diff --git a/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts b/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts index f348c905..58648262 100644 --- a/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts +++ b/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts @@ -9,7 +9,6 @@ */ import { QdrantClient } from "@qdrant/js-client-rest"; -import { randomUUID } from "node:crypto"; export interface QdrantDocEmbeddingsConfig { url?: string; @@ -36,7 +35,10 @@ export class QdrantDocEmbeddingsStore { const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; - this.client = new QdrantClient({ url, apiKey }); + this.client = new QdrantClient({ + url, + ...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), + }); console.log("[QdrantDocEmbeddings] Store initialized"); } @@ -61,8 +63,8 @@ export class QdrantDocEmbeddingsStore { async store(message: DocEmbeddingsMessage): Promise { for (const chunk of message.chunks) { - if (!chunk.chunkId || chunk.chunkId === "") continue; - if (!chunk.vector || chunk.vector.length === 0) continue; + if (chunk.chunkId.length === 0) continue; + if (chunk.vector.length === 0) continue; const dim = chunk.vector.length; const name = this.collectionName(message.user, message.collection, dim); @@ -72,11 +74,13 @@ export class QdrantDocEmbeddingsStore { await this.client.upsert(name, { points: [ { - id: randomUUID(), + id: crypto.randomUUID(), vector: chunk.vector, payload: { chunk_id: chunk.chunkId, - ...(chunk.content ? { content: chunk.content } : {}), + ...(chunk.content !== undefined && chunk.content.length > 0 + ? { content: chunk.content } + : {}), }, }, ], diff --git a/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts b/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts index 7b6c27ef..a0e2ff3e 100644 --- a/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts +++ b/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts @@ -9,7 +9,6 @@ */ import { QdrantClient } from "@qdrant/js-client-rest"; -import { randomUUID } from "node:crypto"; import type { Term } from "@trustgraph/base"; export interface QdrantGraphEmbeddingsConfig { @@ -50,7 +49,10 @@ export class QdrantGraphEmbeddingsStore { const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; - this.client = new QdrantClient({ url, apiKey }); + this.client = new QdrantClient({ + url, + ...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), + }); console.log("[QdrantGraphEmbeddings] Store initialized"); } @@ -76,8 +78,8 @@ export class QdrantGraphEmbeddingsStore { async store(message: GraphEmbeddingsMessage): Promise { for (const entry of message.entities) { const entityValue = getTermValue(entry.entity); - if (!entityValue || entityValue === "") continue; - if (!entry.vector || entry.vector.length === 0) continue; + if (entityValue === null || entityValue.length === 0) continue; + if (entry.vector.length === 0) continue; const dim = entry.vector.length; const name = this.collectionName(message.user, message.collection, dim); @@ -85,14 +87,14 @@ export class QdrantGraphEmbeddingsStore { await this.ensureCollection(name, dim); const payload: Record = { entity: entityValue }; - if (entry.chunkId) { + if (entry.chunkId !== undefined && entry.chunkId.length > 0) { payload.chunk_id = entry.chunkId; } await this.client.upsert(name, { points: [ { - id: randomUUID(), + id: crypto.randomUUID(), vector: entry.vector, payload, }, diff --git a/ts/packages/flow/src/storage/triples/falkordb-service.ts b/ts/packages/flow/src/storage/triples/falkordb-service.ts index a203396e..8b9d3dd2 100644 --- a/ts/packages/flow/src/storage/triples/falkordb-service.ts +++ b/ts/packages/flow/src/storage/triples/falkordb-service.ts @@ -15,6 +15,7 @@ import { type FlowContext, type Triples, } from "@trustgraph/base"; +import { makeProcessorProgram } from "@trustgraph/base"; import { FalkorDBTriplesStore } from "./falkordb.js"; export class TriplesStoreService extends FlowProcessor { @@ -25,7 +26,7 @@ export class TriplesStoreService extends FlowProcessor { this.store = new FalkorDBTriplesStore(); this.registerSpecification( - new ConsumerSpec("store-triples-input", this.onMessage.bind(this)), + ConsumerSpec.fromPromise("store-triples-input", this.onMessage.bind(this)), ); console.log("[TriplesStore] Service initialized"); @@ -33,10 +34,10 @@ export class TriplesStoreService extends FlowProcessor { private async onMessage( msg: Triples, - properties: Record, - flowCtx: FlowContext, + _properties: Record, + _flowCtx: FlowContext, ): Promise { - if (!msg.triples || msg.triples.length === 0) return; + if (msg.triples.length === 0) return; const user = msg.metadata?.user ?? "default"; const collection = msg.metadata?.collection ?? "default"; @@ -49,6 +50,11 @@ export class TriplesStoreService extends FlowProcessor { } } +export const program = makeProcessorProgram({ + id: "triples-store", + make: (config) => new TriplesStoreService(config), +}); + export async function run(): Promise { await TriplesStoreService.launch("triples-store"); } diff --git a/ts/packages/flow/tsconfig.json b/ts/packages/flow/tsconfig.json index e0b169e5..04e240eb 100644 --- a/ts/packages/flow/tsconfig.json +++ b/ts/packages/flow/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", + "types": ["node", "bun"], "composite": true }, "include": ["src"], diff --git a/ts/packages/flow/vitest.config.ts b/ts/packages/flow/vitest.config.ts index 83d22186..2d4eea7f 100644 --- a/ts/packages/flow/vitest.config.ts +++ b/ts/packages/flow/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + exclude: ["dist/**", "node_modules/**"], globals: true, }, }); diff --git a/ts/packages/mcp/package.json b/ts/packages/mcp/package.json index e5dbffdb..18901f99 100644 --- a/ts/packages/mcp/package.json +++ b/ts/packages/mcp/package.json @@ -5,20 +5,22 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", + "build": "bunx --bun tsc", "dev": "tsc --watch", "clean": "rm -rf dist", - "test": "vitest run --passWithNoTests" + "test": "bunx --bun vitest run --passWithNoTests" }, "dependencies": { "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", + "effect": "4.0.0-beta.65", "@modelcontextprotocol/sdk": "^1.8.0", "zod": "^3.23.0" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@types/node": "^22.0.0", "typescript": "^5.8.0", - "vitest": "^3.1.0" + "vitest": "^4.1.6" } } diff --git a/ts/packages/mcp/src/server.ts b/ts/packages/mcp/src/server.ts index 9ca8f2a4..7e449709 100644 --- a/ts/packages/mcp/src/server.ts +++ b/ts/packages/mcp/src/server.ts @@ -63,7 +63,10 @@ export function createMcpServer(config: { const flow = socket.flow(flowId); const response = await flow.graphRag( query, - { entityLimit: entity_limit, tripleLimit: triple_limit }, + { + ...(entity_limit !== undefined ? { entityLimit: entity_limit } : {}), + ...(triple_limit !== undefined ? { tripleLimit: triple_limit } : {}), + }, collection, ); return { content: [{ type: "text" as const, text: response }] }; @@ -141,9 +144,9 @@ export function createMcpServer(config: { }, async ({ s, p, o, limit, collection }) => { const flow = socket.flow(flowId); - const sTerm: Term | undefined = s ? { t: "i", i: s } : undefined; - const pTerm: Term | undefined = p ? { t: "i", i: p } : undefined; - const oTerm: Term | undefined = o ? { t: "i", i: o } : undefined; + const sTerm: Term | undefined = s !== undefined && s.length > 0 ? { t: "i", i: s } : undefined; + const pTerm: Term | undefined = p !== undefined && p.length > 0 ? { t: "i", i: p } : undefined; + const oTerm: Term | undefined = o !== undefined && o.length > 0 ? { t: "i", i: o } : undefined; const triples = await flow.triplesQuery(sTerm, pTerm, oTerm, limit, collection); return { content: [{ type: "text" as const, text: JSON.stringify(triples, null, 2) }] }; }, @@ -417,8 +420,10 @@ export async function run(): Promise { const { server, socket } = createMcpServer({ gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/socket", user: process.env.USER_ID ?? "mcp", - token: process.env.GATEWAY_SECRET, flowId: process.env.FLOW_ID ?? "default", + ...(process.env.GATEWAY_SECRET !== undefined + ? { token: process.env.GATEWAY_SECRET } + : {}), }); const transport = new StdioServerTransport(); diff --git a/ts/packages/mcp/tsconfig.json b/ts/packages/mcp/tsconfig.json index 944bd1d6..54abbe18 100644 --- a/ts/packages/mcp/tsconfig.json +++ b/ts/packages/mcp/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", + "types": ["node"], "composite": true }, "include": ["src"], diff --git a/ts/packages/workbench/package.json b/ts/packages/workbench/package.json index e4b1ba23..c14162bb 100644 --- a/ts/packages/workbench/package.json +++ b/ts/packages/workbench/package.json @@ -22,6 +22,7 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.65", "@tailwindcss/vite": "^4.1.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", diff --git a/ts/packages/workbench/src/components/chat/explain-graph.tsx b/ts/packages/workbench/src/components/chat/explain-graph.tsx index 2a242dcc..3d4539c0 100644 --- a/ts/packages/workbench/src/components/chat/explain-graph.tsx +++ b/ts/packages/workbench/src/components/chat/explain-graph.tsx @@ -61,10 +61,10 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { // Track container width for the force graph useEffect(() => { - if (!expanded || !containerRef.current) return; + if (!expanded || containerRef.current === null) return; const ro = new ResizeObserver((entries) => { const entry = entries[0]; - if (entry) setContainerWidth(Math.floor(entry.contentRect.width)); + if (entry !== undefined) setContainerWidth(Math.floor(entry.contentRect.width)); }); ro.observe(containerRef.current); return () => ro.disconnect(); @@ -83,7 +83,10 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { } // Fall back to fetching from named graph - const graphUris = explainEvents.filter((ev) => ev.explainGraph); + const graphUris = explainEvents.filter( + (ev): ev is ExplainEvent & { explainGraph: string } => + ev.explainGraph !== undefined && ev.explainGraph.length > 0, + ); if (graphUris.length === 0) return; setLoading(true); @@ -117,7 +120,11 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { // Auto-fit once data loads const hasAutoFit = useRef(false); useEffect(() => { - if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) { + if ( + graphData.nodes.length > 0 && + fgRef.current !== undefined && + hasAutoFit.current === false + ) { hasAutoFit.current = true; const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 20), 500); return () => clearTimeout(timer); @@ -155,7 +162,14 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { if (globalScale < 1.5) return; const src = link.source as unknown as GraphNode; const tgt = link.target as unknown as GraphNode; - if (!src.x || !tgt.x) return; + if ( + src.x === undefined || + src.y === undefined || + tgt.x === undefined || + tgt.y === undefined + ) { + return; + } const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2; const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2; @@ -210,13 +224,13 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { )} - {error && ( + {error !== null && (

Failed to load graph: {error}

)} - {!loading && !error && graphData.nodes.length === 0 && ( + {!loading && error === null && graphData.nodes.length === 0 && (

No graph data available for this query.

@@ -278,7 +292,7 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { linkDirectionalArrowLength={3} linkDirectionalArrowRelPos={0.85} backgroundColor="transparent" - width={containerWidth || undefined} + {...(containerWidth > 0 ? { width: containerWidth } : {})} height={280} /> diff --git a/ts/packages/workbench/src/components/error-boundary.tsx b/ts/packages/workbench/src/components/error-boundary.tsx index cee712a9..6d2586bd 100644 --- a/ts/packages/workbench/src/components/error-boundary.tsx +++ b/ts/packages/workbench/src/components/error-boundary.tsx @@ -22,7 +22,7 @@ export class ErrorBoundary extends Component { return { hasError: true, error }; } - componentDidCatch(error: Error, info: ErrorInfo) { + override componentDidCatch(error: Error, info: ErrorInfo) { console.error("[ErrorBoundary]", error, info.componentStack); } @@ -30,9 +30,9 @@ export class ErrorBoundary extends Component { this.setState({ hasError: false, error: null }); }; - render() { + override render() { if (this.state.hasError) { - if (this.props.fallback) return this.props.fallback; + if (this.props.fallback !== undefined) return this.props.fallback; return (
@@ -42,7 +42,7 @@ export class ErrorBoundary extends Component { Something went wrong

- {this.state.error?.message || "An unexpected error occurred."} + {this.state.error?.message ?? "An unexpected error occurred."}

- {expanded && (content || isActive) && ( + {expanded && (content.length > 0 || isActive) && (

- {content || (isActive ? "..." : "")} + {content.length > 0 ? content : isActive ? "..." : ""}

- {isActive && content && ( + {isActive && content.length > 0 && ( )}
@@ -124,7 +124,7 @@ function AgentPhaseBlock({ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) { const isUser = msg.role === "user"; - const hasAgentPhases = msg.agentPhases != null; + const agentPhases = msg.agentPhases; const isError = !isUser && msg.content.startsWith("Error:"); return ( @@ -139,23 +139,23 @@ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: stri )} > {/* Agent phase blocks (only for agent messages) */} - {hasAgentPhases && msg.agentPhases && ( + {agentPhases !== undefined && (
} label="Thinking" - content={msg.agentPhases.think} + content={agentPhases.think} isActive={msg.activePhase === "think"} /> } label="Observing" - content={msg.agentPhases.observe} + content={agentPhases.observe} isActive={msg.activePhase === "observe"} /> - {msg.agentPhases.answer && ( + {agentPhases.answer.length > 0 && (
Answer @@ -174,19 +174,19 @@ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: stri
) : (
- {msg.content || (msg.isStreaming ? "" : "(empty)")} + {msg.content.length > 0 ? msg.content : msg.isStreaming === true ? "" : "(empty)"}
)} {/* Streaming indicator */} - {msg.isStreaming && ( + {msg.isStreaming === true && ( )} {/* Token metadata */} - {msg.metadata && ( + {msg.metadata !== undefined && (
- {msg.metadata.model && {msg.metadata.model}} + {msg.metadata.model !== undefined && msg.metadata.model.length > 0 && {msg.metadata.model}} {msg.metadata.inTokens != null && ( in: {msg.metadata.inTokens} )} @@ -197,7 +197,7 @@ function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: stri )} {/* Explainability graph */} - {!isUser && !isError && !msg.isStreaming && msg.explainEvents && msg.explainEvents.length > 0 && ( + {!isUser && !isError && msg.isStreaming !== true && msg.explainEvents !== undefined && msg.explainEvents.length > 0 && ( )}
@@ -239,7 +239,7 @@ export default function ChatPage() { }, [messages]); const handleSubmit = useCallback(() => { - if (input.trim()) { + if (input.trim().length > 0) { submitMessage({ input }); } }, [input, submitMessage]); @@ -317,12 +317,12 @@ export default function ChatPage() { return (
- {!msg.isStreaming && ( + {msg.isStreaming !== true && ( deleteMessage(msg.id)} - onRegenerate={isLastAssistant ? regenerateLastMessage : undefined} + {...(isLastAssistant ? { onRegenerate: regenerateLastMessage } : {})} /> )} @@ -359,7 +359,7 @@ export default function ChatPage() { />
@@ -226,7 +226,7 @@ function StartFlowDialog({ ))} )} - {submitted && !blueprint && ( + {submitted && blueprint.length === 0 && (

Blueprint is required

)} @@ -237,7 +237,7 @@ function StartFlowDialog({
)} - {blueprintDef && !loadingDef && ( + {blueprintDef !== null && !loadingDef && (
@@ -245,7 +245,7 @@ function StartFlowDialog({
{/* Description from definition */} - {!!(blueprintDef.description || blueprintDef.desc) && ( + {(blueprintDef.description !== undefined || blueprintDef.desc !== undefined) && (

{String(blueprintDef.description ?? blueprintDef.desc)}

@@ -258,7 +258,9 @@ function StartFlowDialog({ blueprintDef.params ?? blueprintDef["parameters"] ?? blueprintDef["params"]; - if (!paramsDef || typeof paramsDef !== "object") return null; + if (paramsDef === undefined || paramsDef === null || typeof paramsDef !== "object") { + return null; + } const entries = Object.entries(paramsDef as Record); if (entries.length === 0) return null; return ( @@ -267,16 +269,16 @@ function StartFlowDialog({
{entries.map(([name, schema]) => { const s = schema as Record | null; - const type = s?.type ? String(s.type) : undefined; - const defaultVal = s && "default" in s ? s.default : undefined; - const desc = s?.description ? String(s.description) : undefined; + const type = s?.type !== undefined ? String(s.type) : undefined; + const defaultVal = s !== null && "default" in s ? s.default : undefined; + const desc = s?.description !== undefined ? String(s.description) : undefined; return (
{name} - {type && ( + {type !== undefined && ( {type} @@ -286,7 +288,7 @@ function StartFlowDialog({ default: {JSON.stringify(defaultVal)} )} - {desc && - {desc}} + {desc !== undefined && - {desc}}
); })} @@ -330,7 +332,7 @@ function StartFlowDialog({ placeholder="Human-readable description" className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" /> - {submitted && !description.trim() && ( + {submitted && description.trim().length === 0 && (

Description is required

)}
@@ -350,12 +352,12 @@ function StartFlowDialog({ rows={4} className={cn( "w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1", - paramsError + paramsError !== null ? "border-error focus:border-error focus:ring-error" : "border-border focus:border-brand-500 focus:ring-brand-500", )} /> - {paramsError && ( + {paramsError !== null && (

{paramsError}

)}
@@ -462,7 +464,7 @@ function FlowRow({
- {flow.description || "--"} + {(flow.description ?? "").length > 0 ? flow.description : "--"} Running @@ -550,7 +552,7 @@ export default function FlowsPage() { }; const handleStop = async () => { - if (!stopTarget) return; + if (stopTarget === null || stopTarget.length === 0) return; try { await stopFlow(stopTarget); notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`); @@ -602,13 +604,13 @@ export default function FlowsPage() { )} - {error && ( + {error !== null && (

{error}

)} - {!loading && !error && flows.length === 0 && ( + {!loading && error === null && flows.length === 0 && (

No flows configured.

@@ -650,7 +652,7 @@ export default function FlowsPage() { /> setStopTarget(null)} onConfirm={handleStop} diff --git a/ts/packages/workbench/src/pages/graph.tsx b/ts/packages/workbench/src/pages/graph.tsx index c31f56e8..1d52d14b 100644 --- a/ts/packages/workbench/src/pages/graph.tsx +++ b/ts/packages/workbench/src/pages/graph.tsx @@ -18,8 +18,6 @@ import { ArrowRight, ArrowLeft, Filter, - ChevronDown, - ChevronRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useSocket } from "@/providers/socket-provider"; @@ -193,7 +191,10 @@ export default function GraphPage() { const [objectFilter, setObjectFilter] = useState(""); const [tripleLimit, setTripleLimit] = useState(2000); const [showLegend, setShowLegend] = useState(false); - const hasActiveFilters = subjectFilter || predicateFilter || objectFilter; + const hasActiveFilters = + subjectFilter.length > 0 || + predicateFilter.length > 0 || + objectFilter.length > 0; const fgRef = useRef | undefined>( undefined, @@ -210,14 +211,14 @@ export default function GraphPage() { // Ref callback — attaches ResizeObserver when the container mounts const containerRef = useCallback((el: HTMLDivElement | null) => { // Disconnect previous observer - if (roRef.current) { + if (roRef.current !== null) { roRef.current.disconnect(); roRef.current = null; } - if (!el) return; + if (el === null) return; const ro = new ResizeObserver((entries) => { const entry = entries[0]; - if (entry) { + if (entry !== undefined) { const { width, height } = entry.contentRect; setContainerSize({ width: Math.floor(width), height: Math.floor(height) }); } @@ -236,9 +237,9 @@ export default function GraphPage() { hasAutoFit.current = false; const flow = socket.flow(flowId); - const s: Term | undefined = subjectFilter ? { t: "i", i: subjectFilter } : undefined; - const p: Term | undefined = predicateFilter ? { t: "i", i: predicateFilter } : undefined; - const o: Term | undefined = objectFilter ? { t: "i", i: objectFilter } : undefined; + const s: Term | undefined = subjectFilter.length > 0 ? { t: "i", i: subjectFilter } : undefined; + const p: Term | undefined = predicateFilter.length > 0 ? { t: "i", i: predicateFilter } : undefined; + const o: Term | undefined = objectFilter.length > 0 ? { t: "i", i: objectFilter } : undefined; const result = await flow.triplesQuery( s, @@ -281,7 +282,7 @@ export default function GraphPage() { // Search filter -- highlight matching nodes const searchLower = searchTerm.toLowerCase(); const matchingIds = useMemo(() => { - if (!searchLower) return new Set(); + if (searchLower.length === 0) return new Set(); return new Set( graphData.nodes .filter( @@ -293,13 +294,17 @@ export default function GraphPage() { ); }, [graphData.nodes, searchLower]); - const selectedLabel = selectedNode + const selectedLabel = selectedNode !== null ? labelMap.get(selectedNode) ?? localName(selectedNode) : ""; // Auto-fit graph to view once data loads useEffect(() => { - if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) { + if ( + graphData.nodes.length > 0 && + fgRef.current !== undefined && + hasAutoFit.current === false + ) { hasAutoFit.current = true; // Wait for force simulation to settle briefly before fitting const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 40), 500); @@ -387,7 +392,14 @@ export default function GraphPage() { const src = link.source as unknown as GraphNode; const tgt = link.target as unknown as GraphNode; - if (!src.x || !tgt.x) return; + if ( + src.x === undefined || + src.y === undefined || + tgt.x === undefined || + tgt.y === undefined + ) { + return; + } const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2; const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2; @@ -427,7 +439,7 @@ export default function GraphPage() { aria-label="Search nodes" className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" /> - {searchTerm && ( + {searchTerm.length > 0 && (
)} - {error && ( + {error !== null && (

Error: {error}

)} - {!loading && !error && documents.length === 0 && ( + {!loading && error === null && documents.length === 0 && (

@@ -658,7 +669,7 @@ export default function LibraryPage() { )} {/* Search results info */} - {searchTerm && documents.length > 0 && ( + {searchTerm.length > 0 && documents.length > 0 && (

{filteredDocuments.length} of {documents.length} documents match

@@ -677,12 +688,12 @@ export default function LibraryPage() { - {filteredDocuments.map((doc) => ( - + {filteredDocuments.map((doc, index) => ( +
- {doc.title || "Untitled"} + {(doc.title ?? "").length > 0 ? doc.title : "Untitled"}
@@ -693,7 +704,7 @@ export default function LibraryPage() { {(doc.tags ?? []).map((tag) => ( {tag} ))} - {(!doc.tags || doc.tags.length === 0) && ( + {(doc.tags ?? []).length === 0 && ( -- )}
@@ -729,7 +740,7 @@ export default function LibraryPage() { )} {/* Empty search results */} - {searchTerm && filteredDocuments.length === 0 && documents.length > 0 && ( + {searchTerm.length > 0 && filteredDocuments.length === 0 && documents.length > 0 && (

No documents match "{searchTerm}"

@@ -746,7 +757,7 @@ export default function LibraryPage() { /> setDeleteTarget(null)} onConfirm={handleDelete} diff --git a/ts/packages/workbench/src/pages/mcp-tools.tsx b/ts/packages/workbench/src/pages/mcp-tools.tsx index 080f9e4e..05b0a433 100644 --- a/ts/packages/workbench/src/pages/mcp-tools.tsx +++ b/ts/packages/workbench/src/pages/mcp-tools.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Plug, Server, @@ -55,7 +55,7 @@ function McpServerDialog({ initial?: McpServerEntry; existingKeys: string[]; }) { - const isEditing = initial != null; + const isEditing = initial !== undefined; const [key, setKey] = useState(initial?.key ?? ""); const [url, setUrl] = useState(initial?.config.url ?? ""); const [remoteName, setRemoteName] = useState( @@ -81,14 +81,14 @@ function McpServerDialog({ }, [open, initial]); const handleSave = async () => { - if (!key.trim() || !url.trim()) return; + if (key.trim().length === 0 || url.trim().length === 0) return; if (!isEditing && existingKeys.includes(key.trim())) { setKeyError("A server with this key already exists"); return; } const config: McpServerConfig = { url: url.trim() }; - if (remoteName.trim()) config["remote-name"] = remoteName.trim(); - if (authToken.trim()) config["auth-token"] = authToken.trim(); + if (remoteName.trim().length > 0) config["remote-name"] = remoteName.trim(); + if (authToken.trim().length > 0) config["auth-token"] = authToken.trim(); setSaving(true); try { await onSave(key.trim(), config); @@ -113,7 +113,7 @@ function McpServerDialog({
@@ -220,7 +220,7 @@ function McpToolDialog({ existingKeys: string[]; serverKeys: string[]; }) { - const isEditing = initial != null; + const isEditing = initial !== undefined; const [key, setKey] = useState(initial?.key ?? ""); const [name, setName] = useState(initial?.config.name ?? ""); const [description, setDescription] = useState( @@ -259,7 +259,11 @@ function McpToolDialog({ setArgs((prev) => prev.filter((_, j) => j !== i)); const handleSave = async () => { - if (!key.trim() || !name.trim() || !mcpTool.trim()) return; + if ( + key.trim().length === 0 || + name.trim().length === 0 || + mcpTool.trim().length === 0 + ) return; if (!isEditing && existingKeys.includes(key.trim())) { setKeyError("A tool with this key already exists"); return; @@ -272,8 +276,8 @@ function McpToolDialog({ group: group .split(",") .map((g) => g.trim()) - .filter(Boolean), - arguments: args.filter((a) => a.name.trim()), + .filter((g) => g.length > 0), + arguments: args.filter((a) => a.name.trim().length > 0), }; setSaving(true); try { @@ -300,7 +304,12 @@ function McpToolDialog({ - - - ))} + ); + })} {/* About */} diff --git a/ts/packages/workbench/src/pages/token-cost.tsx b/ts/packages/workbench/src/pages/token-cost.tsx index b6d5c092..12a24f42 100644 --- a/ts/packages/workbench/src/pages/token-cost.tsx +++ b/ts/packages/workbench/src/pages/token-cost.tsx @@ -60,7 +60,7 @@ export default function TokenCostPage() { }, [connectionState.status, loadCosts]); const formatPrice = (price: number) => { - if (price == null) return "--"; + if (!Number.isFinite(price)) return "--"; return `$${price.toFixed(2)}`; }; @@ -96,13 +96,13 @@ export default function TokenCostPage() { )} - {error && ( + {error !== null && error.length > 0 && (

{error}

)} - {!loading && !error && costs.length === 0 && ( + {!loading && error === null && costs.length === 0 && (

No token cost data available.

diff --git a/ts/packages/workbench/src/providers/notification-provider.tsx b/ts/packages/workbench/src/providers/notification-provider.tsx index 79c93e3d..6e72f358 100644 --- a/ts/packages/workbench/src/providers/notification-provider.tsx +++ b/ts/packages/workbench/src/providers/notification-provider.tsx @@ -57,7 +57,12 @@ export const useNotification = create()((set, get) => { description, ) => { const id = nextId(); - const notification: Notification = { id, type, title, description }; + const notification: Notification = { + id, + type, + title, + ...(description !== undefined ? { description } : {}), + }; set((state) => ({ notifications: [...state.notifications, notification], diff --git a/ts/packages/workbench/src/providers/settings-provider.tsx b/ts/packages/workbench/src/providers/settings-provider.tsx index d1360a13..384784da 100644 --- a/ts/packages/workbench/src/providers/settings-provider.tsx +++ b/ts/packages/workbench/src/providers/settings-provider.tsx @@ -79,7 +79,7 @@ export const useSettings = create()( persist( (set) => ({ settings: DEFAULT_SETTINGS, - isLoaded: true, + isLoaded: true as boolean, setSettings: (settings) => set({ settings }), @@ -103,7 +103,7 @@ export const useSettings = create()( name: "trustgraph-settings", // Mark loaded once rehydration completes onRehydrateStorage: () => (state) => { - if (state) state.isLoaded = true; + if (state !== undefined) state.isLoaded = true; }, }, ), diff --git a/ts/packages/workbench/src/providers/socket-provider.tsx b/ts/packages/workbench/src/providers/socket-provider.tsx index 67d07a56..0a519e96 100644 --- a/ts/packages/workbench/src/providers/socket-provider.tsx +++ b/ts/packages/workbench/src/providers/socket-provider.tsx @@ -69,7 +69,7 @@ export function SocketProvider({ }, [user, apiKey, socketUrl]); // Don't render children until the first API instance is ready - if (!apiRef.current) return null; + if (apiRef.current === null) return null; return ( "); } return ctx.api; diff --git a/ts/packages/workbench/tsconfig.json b/ts/packages/workbench/tsconfig.json index 8ef343ec..dd40dc52 100644 --- a/ts/packages/workbench/tsconfig.json +++ b/ts/packages/workbench/tsconfig.json @@ -7,7 +7,6 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "outDir": "dist", "rootDir": "src", - "baseUrl": ".", "paths": { "@/*": ["./src/*"] }, diff --git a/ts/tsconfig.base.json b/ts/tsconfig.base.json index 2ec96e64..8265529a 100644 --- a/ts/tsconfig.base.json +++ b/ts/tsconfig.base.json @@ -1,18 +1,124 @@ { + "$schema": "https://raw.githubusercontent.com/Effect-TS/tsgo/refs/heads/main/schema.json", + "include": [], "compilerOptions": { + "outDir": "${configDir}/dist", + "rootDir": "${configDir}/src", + "tsBuildInfoFile": "${configDir}/node_modules/.tmp/tsconfig.tsbuildinfo", + // Use incremental builds with project references. + "incremental": true, + "composite": true, + // Target modern JavaScript (ES2022+) whilst staying closely compatible with the Node.js module system. "target": "ES2022", "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, + "moduleResolution": "nodenext", + "moduleDetection": "force", // Treat every non-declaration file as a module. + "verbatimModuleSyntax": true, // Only transform/eliminate type-only import/export statements. + "allowImportingTsExtensions": true, + "allowJs": false, // Keep JavaScript out of TypeScript project graphs. + "rewriteRelativeImportExtensions": true, // Rewrite `.ts` imports to `.js` at build time. + "erasableSyntaxOnly": true, // Allows to run directly with bun and type removal + // Emit source- & declaration maps. "declaration": true, "declarationMap": true, "sourceMap": true, - "outDir": "dist", - "rootDir": "src" + // Opt-in to stricter type checking and correctness guard rails. The more the merrier. + "strict": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + // Miscellaneous + "stripInternal": false, // We temporarily override this to `true` when publishing packages. + "skipLibCheck": true, // Skip type checking of third party libraries. + "noErrorTruncation": true, // Do not truncate error messages. + "types": [], // Disable automatic loading of `@types/*` packages. + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["effect", "@effect/*", "@beep/*"], + "ignoreEffectSuggestionsInTscExitCode": false, + "ignoreEffectWarningsInTscExitCode": false, + "ignoreEffectErrorsInTscExitCode": false, + "includeSuggestionsInTsc": true, + "skipDisabledOptimization": false, + "effectFn": ["span", "inferred-span", "suggested-span"], + "overrides": [ + { + "include": ["**/test/**/*.ts", "**/test/**/*.tsx"], + "options": { + "diagnosticSeverity": { + "missingEffectContext": "off", + "nodeBuiltinImport": "off", + "strictEffectProvide": "off" + } + } + } + ], + + "importAliases": { + "Array": "A", + "Option": "O", + "Predicate": "P", + "Record": "R", + "Schema": "S", + "Equal": "Eq" + }, + "diagnosticSeverity": { + "anyUnknownInErrorContext": "error", + "catchAllToMapError": "error", + "classSelfMismatch": "error", + "floatingEffect": "error", + "catchUnfailableEffect": "error", + "deterministicKeys": "error", + "duplicatePackage": "error", + "effectFnIife": "error", + "effectFnOpportunity": "error", + "effectGenUsesAdapter": "error", + "effectInFailure": "error", + "effectInVoidSuccess": "error", + "effectMapVoid": "error", + "effectSucceedWithVoid": "error", + "extendsNativeError": "error", + "genericEffectServices": "error", + "missingEffectContext": "error", + "missingEffectError": "error", + "globalErrorInEffectCatch": "error", + "globalErrorInEffectFailure": "error", + "missingLayerContext": "error", + "instanceOfSchema": "error", + "missingReturnYieldStar": "error", + "missingStarInYieldEffectGen": "error", + "layerMergeAllWithDependencies": "error", + "overriddenSchemaConstructor": "error", + + "leakingRequirements": "error", + "missedPipeableOpportunity": "error", + "missingEffectServiceDependency": "error", + "multipleEffectProvide": "error", + "nodeBuiltinImport": "error", + "outdatedApi": "error", + "preferSchemaOverJson": "error", + "redundantSchemaTagIdentifier": "error", + "returnEffectInGen": "error", + "runEffectInsideEffect": "error", + "schemaStructWithTag": "error", + "schemaSyncInEffect": "error", + "schemaUnionOfLiterals": "error", + "scopeInLayerEffect": "error", + "serviceNotAsClass": "error", + "strictBooleanExpressions": "error", + "strictEffectProvide": "error", + "tryCatchInEffectGen": "error", + "unknownInEffectCatch": "error", + "unnecessaryEffectGen": "error", + "unnecessaryFailYieldableError": "error", + "unnecessaryPipe": "error", + "unnecessaryPipeChain": "error" + } + } + ] } } diff --git a/ts/tsconfig.json b/ts/tsconfig.json index 038e069b..2d752d4a 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -1,13 +1,20 @@ { + "$schema": "http://json.schemastore.org/tsconfig", "extends": "./tsconfig.base.json", "compilerOptions": { - "noEmit": true + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.root.tsbuildinfo", + "rootDir": ".", + "noEmit": true, + "rewriteRelativeImportExtensions": false, + "erasableSyntaxOnly": false, + "types": ["node", "bun"] }, "references": [ { "path": "packages/base" }, { "path": "packages/client" }, { "path": "packages/flow" }, { "path": "packages/cli" }, - { "path": "packages/mcp" } + { "path": "packages/mcp" }, + { "path": "packages/workbench" } ] }