diff --git a/ts/EFFECT_NATIVE_REWRITE_PLAN.md b/ts/EFFECT_NATIVE_REWRITE_PLAN.md new file mode 100644 index 00000000..5a7d0b39 --- /dev/null +++ b/ts/EFFECT_NATIVE_REWRITE_PLAN.md @@ -0,0 +1,127 @@ +# TrustGraph TS Effect-Native Rewrite Plan + +## Summary + +Bring the TypeScript port to a genuinely Effect-native shape while keeping existing TrustGraph capabilities working. The current native MCP rewrite should be preserved. The remaining work is to remove Promise-first TrustGraph APIs and replace manual host/framework plumbing with Effect v4-native backend, messaging, processor, HTTP/RPC, and CLI layers. + +The implementation should proceed in green checkpoints rather than one broad sweep. Each stage should leave the repo closer to Effect-native and should run the smallest relevant gate before moving on. + +## Non-Negotiable Architecture Decisions + +- TrustGraph-authored production APIs should be Effect-first, not Promise-first. +- Remove exported Promise APIs from backend, messaging, processor, flow service, gateway, CLI, and service runner surfaces. +- Do not keep dual Promise and Effect methods on the same TrustGraph objects. +- Do not rely on Fastify, `@fastify/websocket`, Commander, or production `Effect.runPromise` / `ManagedRuntime` compatibility bridges. +- External JavaScript SDKs that are inherently Promise-based may be wrapped with `Effect.tryPromise`, but that Promise shape must stay behind Effect APIs. +- Gateway HTTP and WebSocket behavior should move to Effect v4 native modules: `effect/unstable/httpapi`, `effect/unstable/http`, `effect/unstable/rpc`, `effect/unstable/socket`, and platform Bun/Node HTTP/socket layers. +- CLI behavior should move to `effect/unstable/cli`. +- The MCP package should stay on the native `effect/unstable/ai/McpServer` implementation and must not reintroduce server-side `@modelcontextprotocol/sdk` or `zod`. + +## Current Grounding + +- `bun run check:tsgo` is green. +- `bun run build` is green. +- `bun run --cwd packages/mcp test` is green. +- `bun run test` currently fails only in `@trustgraph/base`, in `packages/base/src/__tests__/nats-backend.test.ts`. +- The NATS failures are a symptom of the backend still sitting between old Promise-shaped abstractions and newer JetStream calls. The rewrite should fix the contract, not only update the mock. +- The Effect v4 source of truth for APIs and examples is available at `/home/elpresidank/YeeBois/projects/beep-effect/.repos/effect-v4`. + +## Stage 1: Backend And NATS + +- Redefine `Message`, `BackendProducer`, `BackendConsumer`, and `PubSubBackend` around `Effect.Effect` return values. +- Make NATS connection, JetStream manager/client, stream initialization, and durable consumer handles scoped resources. +- Replace deprecated receive plumbing with modern JetStream consumer APIs. +- Preserve behavior for stream creation, durable consumer creation, headers, JSON/schema encode/decode, receive timeout returning `null`, ack/nak, close/drain, and tagged `PubSubError` mapping. +- Update NATS tests and in-memory fake backends to the new Effect-native interface. + +Verification: + +- `bun run --cwd packages/base test src/__tests__/nats-backend.test.ts` +- `bun run --cwd packages/base test` +- `bun run check:tsgo` + +## Stage 2: Messaging And Processor Runtime + +- Remove Promise facades from `makeProducer`, `makeConsumer`, `makeRequestResponse`, processor `start` / `stop` / `run`, and flow compatibility wrappers. +- Expose Effect values, scoped constructors, Context services, Layers, and finalizers as the only TrustGraph API shape. +- Convert message handlers, config handlers, shutdown callbacks, producer send/flush/close, consumer receive/ack/nak/close, and request/response operations to Effect functions. +- Remove internal `ManagedRuntime.make(Layer.empty)` compatibility runtimes from production source. + +Verification: + +- `bun run --cwd packages/base test` +- `bun run check:tsgo` + +## Stage 3: Flow Service Call Sites + +- Convert flow service state methods that currently call `Effect.runPromise` into Effect-valued service methods. +- Keep public feature behavior unchanged for config, flow manager, librarian, knowledge cores, retrieval, embeddings, triples, agent, MCP tool, and text-completion services. +- Convert test fakes to the same Effect-native backend/messaging contracts. + +Verification: + +- `bun run --cwd packages/flow test` +- `bun run test` +- `bun run check:tsgo` + +## Stage 4: Gateway HTTP And RPC + +- Remove Fastify and `@fastify/websocket` from `packages/flow`. +- Rebuild the gateway with Effect v4 `HttpApi` groups/endpoints and native RPC/socket layers. +- Preserve existing behavior: + - `POST /api/v1/workbench/dispatch` + - `POST /api/v1/:kind` + - `POST /api/v1/flow/:flow/service/:kind` + - `POST /api/v1/flow/:flow/load` + - `GET /api/v1/rpc` + - `GET /api/v1/metrics` + - bearer auth behavior and RPC token behavior + - existing response/error shapes expected by clients and workbench +- Use native Effect HTTP test utilities or route-level protocol tests instead of Fastify injection. + +Verification: + +- `bun run --cwd packages/flow test` +- `bun run --cwd packages/client test` +- `bun run test` +- `bun run build` + +## Stage 5: CLI And Runner Scripts + +- Remove Commander from `packages/cli`. +- Rebuild CLI commands with `effect/unstable/cli` and Effect-native socket/API clients. +- Convert service runner scripts to launch Effect programs directly with platform runtime `runMain` style execution. +- Remove `run(): Promise` exports from flow services; export Effect programs/layers and Effect-native `runMain` helpers instead. +- Leave script-only demo/seed tools as follow-up only if they are outside production package source, but do not let production packages depend on Promise facades. + +Verification: + +- `bun run --cwd packages/cli test` +- `bun run check:tsgo` +- `bun run build` +- `bun run test` + +## Stage 6: Cleanup And Acceptance + +- Remove obsolete dependencies from package manifests and lockfile: + - `fastify` + - `@fastify/websocket` + - `commander` + - any legacy dependency made unnecessary by the rewrite +- Preserve unrelated dirty work. Do not revert user changes. +- Use parallel agents for bounded audits or disjoint rewrite slices when useful. +- Use Graphiti memory if available; if unavailable, continue safely and report it skipped. + +Final verification: + +- `bun run check:tsgo` +- `bun run build` +- `bun run lint` +- `bun run test` +- `bun run --cwd packages/mcp test` +- `bun run workbench:qa` after installing the matching Playwright browser if needed +- `git diff --check` +- `rg -n "fastify|@fastify/websocket|commander" packages package.json bun.lock` has no production dependency/use hits +- `rg -n "Effect\\.runPromise|ManagedRuntime\\.make|Promise<|async function main" packages/base/src packages/flow/src packages/cli/src scripts -g "*.ts"` has no production-source hits except tests or unavoidable external type declarations that are not TrustGraph APIs + +Do not mark the goal complete until all required gates are green, or until a real external blocker is reported with the exact failing command, error, and smallest next action. diff --git a/ts/GOAL.md b/ts/GOAL.md new file mode 100644 index 00000000..896d64ce --- /dev/null +++ b/ts/GOAL.md @@ -0,0 +1,37 @@ +# Goal Prompt + +```text +/goal In `/home/elpresidank/YeeBois/dev/trustgraph/ts`, implement the Effect-native rewrite described in `EFFECT_NATIVE_REWRITE_PLAN.md`. + +Follow the plan as the source of truth. The desired end state is that production TrustGraph APIs are Effect-first throughout backend/pubsub, messaging, processor/runtime, flow services, gateway, CLI, and service runners. + +Hard constraints: +- Preserve existing features and wire behavior. +- Remove Promise-returning TrustGraph production APIs instead of keeping dual Promise/Effect methods. +- Remove production `Effect.runPromise` / `ManagedRuntime` compatibility bridges. +- Remove Fastify, `@fastify/websocket`, and Commander. +- Rebuild gateway surfaces with Effect v4 native `effect/unstable/httpapi`, `effect/unstable/http`, `effect/unstable/rpc`, and socket/platform layers. +- Rebuild CLI surfaces with `effect/unstable/cli`. +- Keep the existing native `effect/unstable/ai/McpServer` MCP rewrite; do not reintroduce server-side `@modelcontextprotocol/sdk` or `zod` in `packages/mcp`. +- Use `/home/elpresidank/YeeBois/projects/beep-effect/.repos/effect-v4` as the Effect v4 source reference. +- Preserve unrelated dirty work. + +Work in staged gates: +1. Backend and NATS Effect API. +2. Messaging and processor API cleanup. +3. Flow service call-site migration. +4. Gateway HttpApi/RPC migration. +5. CLI/script migration. +6. Dependency cleanup and final verification. + +Run the smallest relevant tests after each stage. Final done means all of these are green: +`bun run check:tsgo` +`bun run build` +`bun run lint` +`bun run test` +`bun run --cwd packages/mcp test` +`bun run workbench:qa` +`git diff --check` + +Also verify no production Fastify/Commander use remains, and no production `Effect.runPromise`, `ManagedRuntime.make`, Promise API, or `async function main` remains in TrustGraph package/source surfaces except tests or unavoidable external type declarations. +``` diff --git a/ts/bun.lock b/ts/bun.lock index 43e157c1..69f27400 100644 --- a/ts/bun.lock +++ b/ts/bun.lock @@ -5,12 +5,12 @@ "": { "name": "trustgraph-ts", "dependencies": { - "effect": "4.0.0-beta.75", + "effect": "4.0.0-beta.78", }, "devDependencies": { - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@types/bun": "^1.3.13", "@types/node": "^25.7.0", "@typescript/native-preview": "7.0.0-dev.20260511.1", @@ -27,19 +27,19 @@ "name": "@trustgraph/base", "version": "0.1.0", "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "effect": "4.0.0-beta.75", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "effect": "4.0.0-beta.78", "nats": "^2.29.0", }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^4.1.6", @@ -52,21 +52,20 @@ "tg": "dist/index.js", }, "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", - "commander": "^13.1.0", "ws": "^8.18.0", }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/ws": "^8.5.0", "typescript": "^5.8.0", "vitest": "^4.1.6", @@ -76,10 +75,10 @@ "name": "@trustgraph/client", "version": "0.1.0", "dependencies": { - "effect": "4.0.0-beta.75", + "effect": "4.0.0-beta.78", }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "happy-dom": "^20.0.0", @@ -97,32 +96,30 @@ "name": "@trustgraph/flow", "version": "0.1.0", "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", - "@fastify/websocket": "^11.0.0", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/platform-node": "4.0.0-beta.78", + "@effect/platform-node-shared": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@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.75", + "effect": "4.0.0-beta.78", "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.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^4.1.6", @@ -132,25 +129,23 @@ "name": "@trustgraph/mcp", "version": "0.1.0", "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", - "@modelcontextprotocol/sdk": "^1.8.0", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/platform-node": "4.0.0-beta.78", + "@effect/platform-node-shared": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", - "zod": "^3.23.0", }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^4.1.6", @@ -160,18 +155,18 @@ "name": "@trustgraph/workbench", "version": "0.1.0", "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/platform-node": "4.0.0-beta.78", + "@effect/platform-node-shared": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@tanstack/react-query": "^5.75.0", "@trustgraph/client": "workspace:*", "clsx": "^2.1.0", @@ -186,7 +181,7 @@ "zustand": "^5.0.0", }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.0", "@types/react": "^19.1.0", @@ -237,43 +232,43 @@ "@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/ai-anthropic": ["@effect/ai-anthropic@4.0.0-beta.75", "", { "peerDependencies": { "effect": "^4.0.0-beta.75" } }, "sha512-6o2F1RgMKXSenS0mDRde9o9rNh4N+gDnI0KS95DD+BMnArTnBSEcArTkskfogKC6b9vtzExpTU+xo6TNj3cdKw=="], + "@effect/ai-anthropic": ["@effect/ai-anthropic@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-FZzwvKx2k+UIDJyv+FWMtC2CKRxJMWTII+z8EmMcT6tw1uzkkn+ouIyJ32AU+lfveKScNV1SXL4ml3VdcYGy/g=="], - "@effect/ai-openai": ["@effect/ai-openai@4.0.0-beta.75", "", { "peerDependencies": { "effect": "^4.0.0-beta.75" } }, "sha512-Qp6h8TwAdIxiU6ASW3h4R8b0XgnJv/+gviQsPs9kkhRr73O3ctvBnDeq3G0zyFCoKPx57PUDKKdkR0GAE8vZsw=="], + "@effect/ai-openai": ["@effect/ai-openai@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-tx953rRkLqW2BeEkWK12/nRBPO0b1eS6pI+2YyWI0nQvX2JTTijrGlBv/qDVa5kxDkLm63+tA04xnxgZMlA8NA=="], - "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.0-beta.75", "", { "peerDependencies": { "effect": "^4.0.0-beta.75" } }, "sha512-+guPiVUytZPlCn0NsvJhZMrc10YzJ2nDLj1iNZnBMvQvDNKJsZp+O5R0sGc35/y61eomsMCqDwdG7hbOHFaEkA=="], + "@effect/ai-openrouter": ["@effect/ai-openrouter@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-9GIRU9stAnDU5EJ5ZghUWrQXaE+rECCWI/eKVfYeC7UqjZmmmJmTcEbid3tvz2NMsnvIn0ymeKsJAohWCys39w=="], - "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.75", "", { "peerDependencies": { "effect": "^4.0.0-beta.75", "react": "^19.2.4", "scheduler": "*" } }, "sha512-h5fkV3IJx3BGAqf+ehd/voLvpMUONMY3fH/p7SIRAooxx9b7Sl2UeNIlSAm9rzkBHWIQ1kYnQsEz86eZfP1QEw=="], + "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78", "react": "^19.2.4", "scheduler": "*" } }, "sha512-cgxDXJaD0wlbQXbp6tiEmmY+yajwurB0ynkFG20RVucvH4LsQMB3ogiHe0mt42wGggfbVYMEDxgBpQdqDRY8yA=="], - "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.75", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.75", "effect": "^4.0.0-beta.75" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-kuVREGa2Es8F2cvAcC4kwI6FYPARVHeC66U+OXii7lKHMJO0MnB5kqxbjCJHS4TevCH6zX6cL4nRDdEuc5XcvA=="], + "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.78", "", { "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.78", "effect": "^4.0.0-beta.78" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-qhKRcZCNQ5b0Klrct+AC/tPQgIDBxVsD0MkQLIzqvLU3qRHaNd5yHo7kxFf/DuhCyyL++xZfbHsPdq3VdLIByg=="], - "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.75", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.75" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-dvO2HQcJSj4F91CP9pK/n1w1zat3fIWcXYBMeP9rM+Wxwx7ZzUy99PF88vdrPiqbR+iQ9/FYnRxndjg4+svlsA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.78", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.78" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OJGnlNkxfhUmZ/8aLIfQly8ic2tntcnwidAP0BdrTUKa1/sbZjq5xTrhVUjvmehFra2Thsef0k4UPTgsOrBG1A=="], - "@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.75", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^4.0.0-beta.75" } }, "sha512-AOLG8d8FMEEhu9TMiYFGuSTMUr+NL9OjVbBDeKciWJxEl+aBcK+ch5MLzBwGMdUztsD6txB9MYRPQXIg5D+97A=="], + "@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.78", "", { "dependencies": { "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-8r9MVuZ8xJRyVyi+C8SKSYLbMsHr7qOiUgLV6lKMECuAWyMhlbK/7Ka9SQGr0ZPqOe5ShLEvV7DevnGkG+owAQ=="], - "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.75", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.75" }, "peerDependencies": { "effect": "^4.0.0-beta.75" } }, "sha512-OIIi1kI/DFkzgIHPzy7kqLI1VVbRVpq3HkgH72LHPjVt9mIO3+YXvt8rnjSmTGpSpjEzzBsas8rZ4cnFbYKESA=="], + "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.78", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.78" }, "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-lmPCL1G7SlkCWCguX3rDPS7kKuvJ/AN4pjS7IXb/5SoauHPd67iUdc1ZbB7o6lwTChJaIfWNNPkUWygiaUeJiA=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.75", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.75", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.75", "ioredis": "^5.7.0" } }, "sha512-3A7yOMwQUV8p743QRWC2Qqao3ASmolbh2i4TGbJZ3bZLBGVcRQqBSg1sg2zpSFihf0R+RZg/dlV6OYFIanK/zA=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.78", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.78", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.78", "ioredis": "^5.7.0" } }, "sha512-8ONrIS5/R9dq+0BJ6v3kUXNEkfjU6S3GzIYCH5gmHdiriRvIoBhXYNAITfRvZpfx1JPrKuP70cHyuQDjmJcDkQ=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.75", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.75" } }, "sha512-ERxuFDuCuMAiSHLrNVTvSXPnUajcZV4PUVibXbk1lwiymhbkxRRBs1Zeuicwho8jZdpn7niXCDI24CiB9PIozA=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.78", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.78" } }, "sha512-mo0ddTPATyCMyqzQasYDL7+NI29vozoMplom+qu9f/onDTd4xG5hvEEfGxfL0Ljygui6keG/YE/E9OZVf2z5WA=="], - "@effect/tsgo": ["@effect/tsgo@0.13.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.13.0", "@effect/tsgo-darwin-x64": "0.13.0", "@effect/tsgo-linux-arm": "0.13.0", "@effect/tsgo-linux-arm64": "0.13.0", "@effect/tsgo-linux-x64": "0.13.0", "@effect/tsgo-win32-arm64": "0.13.0", "@effect/tsgo-win32-x64": "0.13.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.js" } }, "sha512-oOBoz8iFVhrBpvr0R6vLE01ydPLbyRu1eN8eU97E17T+LUDMgcgm4AqdTu1pUaCIhRAWIZcmRxD+Jvd38940yQ=="], + "@effect/tsgo": ["@effect/tsgo@0.14.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.14.0", "@effect/tsgo-darwin-x64": "0.14.0", "@effect/tsgo-linux-arm": "0.14.0", "@effect/tsgo-linux-arm64": "0.14.0", "@effect/tsgo-linux-x64": "0.14.0", "@effect/tsgo-win32-arm64": "0.14.0", "@effect/tsgo-win32-x64": "0.14.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.js" } }, "sha512-UThRMJJwXtCWDlQyNTfPTAWTyG3nuODZWu3txLRXoHg/C8q2IlvKD+ap6RUU1kK+SR+pTa73mZTtkScQAD8N7g=="], - "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.13.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DGpDMwmE+fVx+/w7DurQJz1iGPiIp4kUoIZ/iUb0zBdPuhycH5bqm8x4lMEYKdO9D6k4VIDY3zeSoPAs3yqGfQ=="], + "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.14.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bNJDM6CrIYIEz2PLMpQjxHAvPzcaiP0IRfLr1a+0Kfr1+bd/Ck5qpt+tLsukWcg+WTiiIJqwp0zP1gBuJA3SIA=="], - "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.13.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-KfoQu1SeKUdoegK1M3B9ZqxNtJqZM+Odqlyg48G6K7CQ4tBPlpOwAschVr4h4IBANMEQNFuQADPGy0gSSZwQvQ=="], + "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.14.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-daJpJhYhG25t8N8cN/u6F6v88UFwttVx6McnyV6VS4qNaOI3F8cYKHkndL6LoouC4rjgEli0BplA7jSxSSvSbQ=="], - "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.13.0", "", { "os": "linux", "cpu": "arm" }, "sha512-txp7VxQIYXBpJo66G77JKoki6ouEJk/HokEIrp+mBHN6BNx8O5h6kjUtLCeFbIiaKuX5UrqHN7Yvx/vj4qLkSQ=="], + "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.14.0", "", { "os": "linux", "cpu": "arm" }, "sha512-8+3akhHgNCIZu/vXBKd30h+eSmgQge4RmU/7YMw1e+UA30Ch5UVEgPiJDMqtDDCCALL8RKpHbeFKY/cP6N+C4Q=="], - "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.13.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9KadPsq9b5sQqh6pRRTr0mNcRVM8Jk9l3Y9SgsmwnfEjxHFoo/NThDQNgfctdBawOCwImQdm/YtC5oj7AwCFrA=="], + "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.14.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-z67ZXpcPpxZgcatfZSWI7LEgNh+ba5LrqbuHovh9l2ywtOpiXf7d5kU2VPlEA8YVfU7dhnJviDGY8hgJQIIt5g=="], - "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.13.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Osp1yIFPmibHvLTkVHk86RODr/hP44yXQU8NqyGauFDm93FdFEOn8v96UkcXziST76v0wReKvB/Ng39g7lafSw=="], + "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.14.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nJ/4cU0aWnof2A23/Cmry/M4Ek6F0/Igv+BCX09x5EC8sbn4hGIJ5rlQQqjhj5XF7Zd1BEkN5oxhLzwA6i1veA=="], - "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.13.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2nUvQyW/iAqYKjn/BrFKt2BxWQGlW8fGdq7MRTLs6cOrwwa4XnqeVsomyqIyeBcXB5s3HilRolF4xnFvJpTKw=="], + "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.14.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-lSwV3IP7MvCr/ElWIBywmjfdRmfq98lFeIVR7NpuuSSicVvpAPv4ACpw6z93SUKSPjJrw7jMvZn9MOrwKuPx8g=="], - "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.13.0", "", { "os": "win32", "cpu": "x64" }, "sha512-c+tQDZ+oOGj1PVgoTa4SswoqbF5t9fMHse0cKh3QMJPxh1nkJ+E7cqAyFcpL8vy2apKZQUB1UbGEAXCQRm1u1Q=="], + "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.14.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5yRE1NAtBCd1IYSU2rLe/W9NGpyTOqhsJvCs4ZV81j3U6xNk/+kk1jVfMPZXZjal4cIRWI1UVxbt4uSN4pw/lw=="], - "@effect/vitest": ["@effect/vitest@4.0.0-beta.75", "", { "peerDependencies": { "effect": "^4.0.0-beta.75", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-9R6s/9j/tZUc8lMmkOKXfgwnSRhk5EvKS89Rwwj8TaSYh+n9ZCDvvepxGcE9DpNghqDd7fC3Gcbgk0c56hSs3Q=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.78", "", { "peerDependencies": { "effect": "^4.0.0-beta.78", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -331,20 +326,6 @@ "@falkordb/graph": ["@falkordb/graph@2.0.1", "", { "peerDependencies": { "@falkordb/client": "1.6.0" } }, "sha512-pwLdV4lbgBgNQlwSa4qTHwQHNTXCKVlDvH+a7cjgF5bcnDQyLtunk6FouUjBX0y7SKkSY/+Dk+RwV1EAU5e7Nw=="], - "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", "fast-uri": "3.1.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], - - "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], - - "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "6.3.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], - - "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], - - "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], - - "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "3.0.1", "ipaddr.js": "2.3.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], - - "@fastify/websocket": ["@fastify/websocket@11.2.0", "", { "dependencies": { "duplexify": "4.1.3", "fastify-plugin": "5.1.0", "ws": "8.20.0" } }, "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w=="], - "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "4.12.10" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], @@ -407,8 +388,6 @@ "@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=="], - "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], "@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=="], @@ -605,8 +584,6 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "5.0.1" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], - "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accessor-fn": ["accessor-fn@1.5.3", "", {}, "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA=="], @@ -621,10 +598,6 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - - "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "4.2.0", "fastq": "1.20.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ=="], @@ -667,8 +640,6 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -741,18 +712,14 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "1.4.5", "inherits": "2.0.4", "readable-stream": "3.6.2", "stream-shift": "1.0.3" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.75", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-KrDuecxuN8rC7dytVs5GFfUhtC+/D8pOyfVb1LNZGjJY7qigQG9xXJ4R1Bp+tBo7sKbZOXBswCfuQ3ombaEldQ=="], + "effect": ["effect@4.0.0-beta.78", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-j79Rl9QpHwMz/ZJWLNpZoiVj9N7zHqiLKN5EcYd/A8J1oqejILWQLfc4HPlvqHqKC8SK55LJ+X4gy4ONJ+JpfQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.2" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -797,28 +764,14 @@ "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=="], - "fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "0.2.1", "ajv": "8.18.0", "ajv-formats": "3.0.1", "fast-uri": "3.1.0", "json-schema-ref-resolver": "3.0.0", "rfdc": "1.4.1" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="], - - "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "4.0.5", "@fastify/error": "4.2.0", "@fastify/fast-json-stringify-compiler": "5.0.3", "@fastify/proxy-addr": "5.1.0", "abstract-logging": "2.0.1", "avvio": "9.2.0", "fast-json-stringify": "6.3.0", "find-my-way": "9.5.0", "light-my-request": "6.6.0", "pino": "10.3.1", "process-warning": "5.0.0", "rfdc": "1.4.1", "secure-json-parse": "4.1.0", "semver": "7.7.4", "toad-cache": "3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="], - - "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], - - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "1.1.0" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "on-finished": "2.4.1", "parseurl": "1.3.3", "statuses": "2.0.2" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "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=="], @@ -889,7 +842,7 @@ "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -915,8 +868,6 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -927,8 +878,6 @@ "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=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1067,8 +1016,6 @@ "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=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1095,12 +1042,6 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "0.4.0", "atomic-sleep": "1.0.0", "on-exit-leak-free": "2.1.2", "pino-abstract-transport": "3.0.0", "pino-std-serializers": "7.1.0", "process-warning": "5.0.0", "quick-format-unescaped": "4.0.4", "real-require": "0.2.0", "safe-stable-stringify": "2.5.0", "sonic-boom": "4.2.1", "thread-stream": "4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], - - "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "4.2.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], - - "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], @@ -1111,8 +1052,6 @@ "preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], - "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "1.4.0", "object-assign": "4.1.1", "react-is": "16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -1123,8 +1062,6 @@ "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=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.1", "iconv-lite": "0.7.2", "unpipe": "1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1147,10 +1084,6 @@ "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "1.1.1", "set-cookie-parser": "2.7.2" }, "optionalDependencies": { "react-dom": "19.2.4" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "2.0.4", "string_decoder": "1.3.0", "util-deprecate": "1.0.2" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], - "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], @@ -1163,29 +1096,15 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], - - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - - "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "4.4.3", "depd": "2.0.0", "is-promise": "4.0.0", "parseurl": "1.3.3", "path-to-regexp": "8.4.2" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], - - "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "fresh": "2.0.0", "http-errors": "2.0.1", "mime-types": "3.0.2", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "1.2.1", "statuses": "2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -1209,14 +1128,10 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -1225,10 +1140,6 @@ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], - "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "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=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -1241,8 +1152,6 @@ "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], - "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], @@ -1253,8 +1162,6 @@ "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=="], @@ -1297,8 +1204,6 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.2" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1341,10 +1246,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@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=="], - "@qdrant/js-client-rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "@trustgraph/base/@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], @@ -1367,8 +1268,6 @@ "ioredis/cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], - "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], - "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "5.26.5" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], @@ -1377,8 +1276,6 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "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=="], diff --git a/ts/package.json b/ts/package.json index b3fff7ea..aade7951 100644 --- a/ts/package.json +++ b/ts/package.json @@ -44,9 +44,9 @@ "llm:mistral": "bun scripts/run-llm-mistral.ts" }, "devDependencies": { - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@types/bun": "^1.3.13", "@types/node": "^25.7.0", "@typescript/native-preview": "7.0.0-dev.20260511.1", @@ -60,7 +60,7 @@ "vitest": "^4.1.6" }, "dependencies": { - "effect": "4.0.0-beta.75" + "effect": "4.0.0-beta.78" }, "packageManager": "bun@1.3.13", "workspaces": [ diff --git a/ts/packages/base/package.json b/ts/packages/base/package.json index 61d31daf..f7d8b5fd 100644 --- a/ts/packages/base/package.json +++ b/ts/packages/base/package.json @@ -20,19 +20,19 @@ "test": "bunx --bun vitest run" }, "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "effect": "4.0.0-beta.75", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "effect": "4.0.0-beta.78", "nats": "^2.29.0" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "typescript": "^5.8.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 3e69c5e9..1ff9a5b9 100644 --- a/ts/packages/base/src/__tests__/consumer.test.ts +++ b/ts/packages/base/src/__tests__/consumer.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Effect } from "effect"; import { makeConsumer, type ConsumerOptions, type FlowContext } from "../messaging/consumer.js"; import type { PubSubBackend, @@ -25,14 +26,17 @@ function createMockBackendConsumer(): BackendConsumer & { acknowledge: ReturnType; negativeAcknowledge: ReturnType; unsubscribe: ReturnType; - close: ReturnType; + close: Effect.Effect; + closeMock: ReturnType; } { + const closeMock = vi.fn(); return { - receive: vi.fn().mockResolvedValue(null), - acknowledge: vi.fn().mockResolvedValue(undefined), - negativeAcknowledge: vi.fn().mockResolvedValue(undefined), - unsubscribe: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), + receive: vi.fn().mockReturnValue(Effect.succeed(null)), + acknowledge: vi.fn().mockReturnValue(Effect.void), + negativeAcknowledge: vi.fn().mockReturnValue(Effect.void), + unsubscribe: vi.fn().mockReturnValue(Effect.void), + close: Effect.sync(closeMock), + closeMock, }; } @@ -41,9 +45,9 @@ function createMockPubSub( backendConsumer: BackendConsumer, ): PubSubBackend { return { - createProducer: vi.fn().mockResolvedValue({} as BackendProducer), - createConsumer: vi.fn().mockResolvedValue(backendConsumer), - close: vi.fn().mockResolvedValue(undefined), + createProducer: vi.fn().mockReturnValue(Effect.succeed({} as BackendProducer)), + createConsumer: vi.fn().mockReturnValue(Effect.succeed(backendConsumer)), + close: Effect.void, }; } @@ -94,7 +98,7 @@ describe("Consumer", () => { expect(consumer).toMatchObject({ start: expect.any(Function), - stop: expect.any(Function), + stop: expect.any(Object), }); }); @@ -111,13 +115,13 @@ describe("Consumer", () => { expect(consumer).toMatchObject({ start: expect.any(Function), - stop: expect.any(Function), + stop: expect.any(Object), }); }); // ── start() creates consumer and calls handler ───────────────── it("starts a scoped consumer and invokes handler for received messages", async () => { - const handler = vi.fn().mockResolvedValue(undefined); + const handler = vi.fn().mockReturnValue(Effect.void); const msg = createMockMessage({ data: "hello" }, { id: "1" }); const consumer = makeConsumer({ @@ -127,11 +131,11 @@ describe("Consumer", () => { handler, }); - backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null); + backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null)); - await consumer.start(flowCtx); + await Effect.runPromise(consumer.start(flowCtx)); await advanceUntil(() => handler.mock.calls.length > 0); - await consumer.stop(); + await Effect.runPromise(consumer.stop); expect(pubsub.createConsumer).toHaveBeenCalledWith({ topic: "topic-a", @@ -143,7 +147,7 @@ describe("Consumer", () => { // ── Messages are acknowledged after successful handling ──────── it("acknowledges messages after successful handling", async () => { - const handler = vi.fn().mockResolvedValue(undefined); + const handler = vi.fn().mockReturnValue(Effect.void); const msg = createMockMessage("payload"); const consumer = makeConsumer({ @@ -153,11 +157,11 @@ describe("Consumer", () => { handler, }); - backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null); + backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null)); - await consumer.start(flowCtx); + await Effect.runPromise(consumer.start(flowCtx)); await advanceUntil(() => backendConsumer.acknowledge.mock.calls.length > 0); - await consumer.stop(); + await Effect.runPromise(consumer.stop); expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg); expect(backendConsumer.negativeAcknowledge).not.toHaveBeenCalled(); @@ -165,7 +169,7 @@ describe("Consumer", () => { // ── Messages are negatively acknowledged on handler error ────── it("negatively acknowledges messages when the handler throws", async () => { - const handler = vi.fn().mockRejectedValue("handler boom"); + const handler = vi.fn().mockReturnValue(Effect.fail("handler boom")); const msg = createMockMessage("bad-payload"); const consumer = makeConsumer({ @@ -175,11 +179,11 @@ describe("Consumer", () => { handler, }); - backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null); + backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null)); - await consumer.start(flowCtx); + await Effect.runPromise(consumer.start(flowCtx)); await advanceUntil(() => backendConsumer.negativeAcknowledge.mock.calls.length > 0); - await consumer.stop(); + await Effect.runPromise(consumer.stop); expect(backendConsumer.negativeAcknowledge).toHaveBeenCalledWith(msg); expect(backendConsumer.acknowledge).not.toHaveBeenCalled(); @@ -188,12 +192,17 @@ describe("Consumer", () => { // ── TooManyRequestsError triggers retry ──────────────────────── it("retries the handler on TooManyRequestsError", async () => { let handlerCalls = 0; - const handler = vi.fn().mockImplementation(async () => { - handlerCalls++; - if (handlerCalls === 1) { - throw tooManyRequestsError("rate limited"); - } - // Second call succeeds + const handler = vi.fn().mockImplementation(() => { + return Effect.sync(() => { + handlerCalls++; + return handlerCalls; + }).pipe( + Effect.flatMap((attempt) => + attempt === 1 + ? Effect.fail(tooManyRequestsError("rate limited")) + : Effect.void + ), + ); }); const msg = createMockMessage("rate-limited-payload"); @@ -206,17 +215,17 @@ describe("Consumer", () => { rateLimitRetryMs: 500, }); - backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null); + backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null)); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - await consumer.start(flowCtx); + await Effect.runPromise(consumer.start(flowCtx)); await vi.advanceTimersByTimeAsync(600); await advanceUntil(() => handler.mock.calls.length >= 2); - await consumer.stop(); + await Effect.runPromise(consumer.stop); // Handler called twice: first throws TooManyRequestsError, second succeeds - expect(handler).toHaveBeenCalledTimes(2); + expect(handlerCalls).toBe(2); // Message should be acknowledged (retry succeeded) expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg); @@ -225,11 +234,17 @@ describe("Consumer", () => { it("retries repeated TooManyRequestsError until success within the timeout", async () => { let handlerCalls = 0; - const handler = vi.fn().mockImplementation(async () => { - handlerCalls++; - if (handlerCalls <= 2) { - throw tooManyRequestsError("rate limited"); - } + const handler = vi.fn().mockImplementation(() => { + return Effect.sync(() => { + handlerCalls++; + return handlerCalls; + }).pipe( + Effect.flatMap((attempt) => + attempt <= 2 + ? Effect.fail(tooManyRequestsError("rate limited")) + : Effect.void + ), + ); }); const msg = createMockMessage("rate-limited-payload"); @@ -243,22 +258,27 @@ describe("Consumer", () => { rateLimitTimeoutMs: 2_000, }); - backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null); + backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null)); - await consumer.start(flowCtx); + await Effect.runPromise(consumer.start(flowCtx)); await vi.advanceTimersByTimeAsync(1_100); await advanceUntil(() => backendConsumer.acknowledge.mock.calls.length > 0); - await consumer.stop(); + await Effect.runPromise(consumer.stop); - expect(handler).toHaveBeenCalledTimes(3); + expect(handlerCalls).toBe(3); expect(backendConsumer.acknowledge).toHaveBeenCalledWith(msg); expect(backendConsumer.negativeAcknowledge).not.toHaveBeenCalled(); }); it("negatively acknowledges when rate-limit retry timeout elapses", async () => { - const handler = vi.fn().mockImplementation(async () => { - throw tooManyRequestsError("rate limited"); - }); + let handlerCalls = 0; + const handler = vi.fn().mockReturnValue( + Effect.sync(() => { + handlerCalls++; + }).pipe( + Effect.flatMap(() => Effect.fail(tooManyRequestsError("rate limited"))), + ), + ); const msg = createMockMessage("rate-limited-payload"); const consumer = makeConsumer({ @@ -270,21 +290,21 @@ describe("Consumer", () => { rateLimitTimeoutMs: 1_000, }); - backendConsumer.receive.mockResolvedValueOnce(msg).mockResolvedValue(null); + backendConsumer.receive.mockReturnValueOnce(Effect.succeed(msg)).mockReturnValue(Effect.succeed(null)); - await consumer.start(flowCtx); + await Effect.runPromise(consumer.start(flowCtx)); await vi.advanceTimersByTimeAsync(1_100); await advanceUntil(() => backendConsumer.negativeAcknowledge.mock.calls.length > 0); - await consumer.stop(); + await Effect.runPromise(consumer.stop); - expect(handler).toHaveBeenCalledTimes(2); + expect(handlerCalls).toBeGreaterThanOrEqual(2); expect(backendConsumer.negativeAcknowledge).toHaveBeenCalledWith(msg); expect(backendConsumer.acknowledge).not.toHaveBeenCalled(); }); // ── stop() closes the backend ────────────────────────────────── it("stop() sets running=false and closes the backend", async () => { - backendConsumer.receive.mockResolvedValue(null); + backendConsumer.receive.mockReturnValue(Effect.succeed(null)); const consumer = makeConsumer({ pubsub, @@ -293,10 +313,10 @@ describe("Consumer", () => { handler: vi.fn(), }); - await consumer.start(flowCtx); - await consumer.stop(); + await Effect.runPromise(consumer.start(flowCtx)); + await Effect.runPromise(consumer.stop); - expect(backendConsumer.close).toHaveBeenCalled(); - await expect(consumer.stop()).resolves.toBeUndefined(); + expect(backendConsumer.closeMock).toHaveBeenCalled(); + await expect(Effect.runPromise(consumer.stop)).resolves.toBeUndefined(); }); }); diff --git a/ts/packages/base/src/__tests__/embeddings-service.test.ts b/ts/packages/base/src/__tests__/embeddings-service.test.ts index 0f6ba8e1..3699e77a 100644 --- a/ts/packages/base/src/__tests__/embeddings-service.test.ts +++ b/ts/packages/base/src/__tests__/embeddings-service.test.ts @@ -62,13 +62,15 @@ const waitFor = (condition: () => boolean, label: string) => 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 }); + send(message: T, properties?: Record): Effect.Effect { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + }); } - async flush(): Promise {} + readonly flush: Effect.Effect = Effect.void; - async close(): Promise {} + readonly close: Effect.Effect = Effect.void; } class PushConsumer implements BackendConsumer { @@ -87,32 +89,38 @@ class PushConsumer implements BackendConsumer { 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); + receive(): Effect.Effect | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return Promise.resolve(message ?? null); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message): Promise { - this.acknowledged.push(message); + acknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message): Promise { - this.nacked.push(message); + negativeAcknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise {} + readonly unsubscribe: Effect.Effect = Effect.void; - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } - } + }); } class EmbeddingsBackend implements PubSubBackend { @@ -121,24 +129,28 @@ class EmbeddingsBackend implements PubSubBackend { 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; + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + 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; + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + 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 { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); pushConfig(): void { this.configConsumer.push( diff --git a/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts b/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts index 68f6a839..25537326 100644 --- a/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts +++ b/ts/packages/base/src/__tests__/flow-processor-runtime.test.ts @@ -58,17 +58,19 @@ class RecordingProducer implements BackendProducer { closeCount = 0; flushCount = 0; - async send(message: T, properties?: Record): Promise { - this.sent.push(properties === undefined ? { message } : { message, properties }); + send(message: T, properties?: Record): Effect.Effect { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + }); } - async flush(): Promise { + readonly flush: Effect.Effect = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class PushConsumer implements BackendConsumer { @@ -88,33 +90,39 @@ class PushConsumer implements BackendConsumer { 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); + receive(): Effect.Effect | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return Promise.resolve(message ?? null); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message): Promise { - this.acknowledged.push(message); + acknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message): Promise { - this.nacked.push(message); + negativeAcknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise {} + readonly unsubscribe: Effect.Effect = Effect.void; - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } this.closeCount += 1; - } + }); } class FlowProcessorBackend implements PubSubBackend { @@ -124,24 +132,28 @@ class FlowProcessorBackend implements PubSubBackend { 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; + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + 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(); + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + this.consumerOptions.push(options); + if (options.topic === topics.configPush) { + return this.configConsumer as unknown as BackendConsumer; + } + return new PushConsumer(); + }); } - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); pushConfig(version: number, flows: Record): void { this.pushFlowConfig(version, flows); @@ -159,9 +171,9 @@ class TestFlowProcessor extends FlowProcessor { ) { super(config); this.registerSpecification(makeProducerSpec("output")); - this.registerConfigHandler(async (_config, version) => { + this.registerConfigHandler((_config, version) => Effect.sync(() => { this.events.push(`handler:${version}`); - }); + })); } } diff --git a/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts b/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts index 7a63fa0b..160ff6a5 100644 --- a/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts +++ b/ts/packages/base/src/__tests__/flow-spec-runtime.test.ts @@ -4,7 +4,6 @@ import * as S from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { makeConsumerSpec, - makeConsumerSpecFromPromise, Flow, MessagingRuntimeLive, makeParameterSpec, @@ -34,18 +33,20 @@ class RecordingProducer implements BackendProducer { 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); + send(message: T, properties?: Record): Effect.Effect { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + this.onSend?.(message, properties); + }); } - async flush(): Promise { + readonly flush: Effect.Effect = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class ScriptedConsumer implements BackendConsumer { @@ -72,33 +73,39 @@ class ScriptedConsumer implements BackendConsumer { 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); + receive(): Effect.Effect | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || !this.waitForMessages || this.closed) { + return Promise.resolve(message ?? null); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message): Promise { - this.acknowledged.push(message); + acknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message): Promise { - this.nacked.push(message); + negativeAcknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise {} + readonly unsubscribe: Effect.Effect = Effect.void; - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } this.closeCount += 1; - } + }); } class RuntimeBackend implements PubSubBackend { @@ -114,19 +121,23 @@ class RuntimeBackend implements PubSubBackend { this.producer = new RecordingProducer(onSend); } - async createProducer(options: CreateProducerOptions): Promise> { - this.producerOptions = options; - return this.producer as BackendProducer; + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + this.producerOptions = options; + return this.producer as BackendProducer; + }); } - async createConsumer(options: CreateConsumerOptions): Promise> { - this.consumerOptions = options; - return this.consumer as BackendConsumer; + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + this.consumerOptions = options; + return this.consumer as BackendConsumer; + }); } - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } const fastMessagingConfig = ConfigProvider.layer( @@ -187,7 +198,7 @@ describe("Effect-native flow specifications", () => { ); it.effect( - "runs Promise handlers through the explicit makeConsumerSpec compatibility helper", + "runs Effect handlers through makeConsumerSpec", Effect.fnUntraced(function* () { const message = createMessage("payload", { id: "request-1" }); const consumer = new ScriptedConsumer([message]); @@ -199,11 +210,11 @@ describe("Effect-native flow specifications", () => { backend, {}, [ - makeConsumerSpecFromPromise( + makeConsumerSpec( "input", - async (value, properties, flowContext: FlowContext) => { + (value, properties, flowContext: FlowContext) => Effect.sync(() => { handled.push(`${flowContext.name}:${properties.id}:${value}`); - }, + }), ), ], ); @@ -226,7 +237,7 @@ describe("Effect-native flow specifications", () => { ); it.effect( - "registers request-response specs through Effect queues and keeps the Promise facade working", + "registers request-response specs through Effect queues and exposes Effect requestors", Effect.fnUntraced(function* () { const responseConsumer = new ScriptedConsumer([], true); const backend = new RuntimeBackend( @@ -257,10 +268,8 @@ describe("Effect-native flow specifications", () => { yield* flow.startEffect; const duplicateSpecError = yield* flow.requestorEffect(duplicateRequestResponseSpec).pipe(Effect.flip); expect(duplicateSpecError._tag).toBe("FlowResourceNotFoundError"); - const requestor = flow.requestor(requestResponseSpec); - const fiber = yield* Effect.promise(() => - requestor.request("request", { timeoutMs: 250 }), - ).pipe(Effect.forkChild); + const requestor = yield* flow.requestor(requestResponseSpec); + const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild); yield* TestClock.adjust(Duration.millis(5)); return yield* Fiber.join(fiber); }), @@ -299,7 +308,8 @@ describe("Effect-native flow specifications", () => { const legacyParameter = yield* flow.parameterEffect("present"); const parameterError = yield* flow.parameterEffect("missing-parameter").pipe(Effect.flip); const invalidParameterError = yield* flow.parameterEffect(invalidParameter).pipe(Effect.flip); - return { producerError, parameter, legacyParameter, parameterError, invalidParameterError }; + const legacyProducerError = yield* flow.producer("missing-producer").pipe(Effect.flip); + return { producerError, legacyProducerError, parameter, legacyParameter, parameterError, invalidParameterError }; }), ), ); @@ -313,10 +323,10 @@ describe("Effect-native flow specifications", () => { expect(errors.parameterError.resourceType).toBe("parameter"); expect(errors.invalidParameterError._tag).toBe("FlowParameterDecodeError"); expect(errors.invalidParameterError.parameterName).toBe("present"); + expect(errors.legacyProducerError._tag).toBe("FlowResourceNotFoundError"); expect(flow.parameter(presentParameter)).toBe(42); expect(flow.parameter("present")).toBe(42); expect(() => flow.parameter(invalidParameter)).toThrow("failed schema decoding"); - 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 index 59d308ec..13830c0e 100644 --- a/ts/packages/base/src/__tests__/messaging-runtime.test.ts +++ b/ts/packages/base/src/__tests__/messaging-runtime.test.ts @@ -36,18 +36,20 @@ class RecordingProducer implements BackendProducer { 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); + send(message: T, properties?: Record): Effect.Effect { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + this.onSend?.(message, properties); + }); } - async flush(): Promise { + readonly flush: Effect.Effect = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class ScriptedConsumer implements BackendConsumer { @@ -64,27 +66,33 @@ class ScriptedConsumer implements BackendConsumer { this.messages.push(message); } - async receive(): Promise | null> { - const message = this.messages.shift(); - if (message !== undefined) { - return message; - } - return null; + receive(): Effect.Effect | null> { + return Effect.sync(() => { + const message = this.messages.shift(); + if (message !== undefined) { + return message; + } + return null; + }); } - async acknowledge(message: Message): Promise { - this.acknowledged.push(message); + acknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message): Promise { - this.nacked.push(message); + negativeAcknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise {} + readonly unsubscribe: Effect.Effect = Effect.void; - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class RuntimeBackend implements PubSubBackend { @@ -100,19 +108,23 @@ class RuntimeBackend implements PubSubBackend { this.producer = new RecordingProducer(onSend); } - async createProducer(options: CreateProducerOptions): Promise> { - this.producerOptions = options; - return this.producer as BackendProducer; + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + this.producerOptions = options; + return this.producer as BackendProducer; + }); } - async createConsumer(options: CreateConsumerOptions): Promise> { - this.consumerOptions = options; - return this.consumer as BackendConsumer; + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + this.consumerOptions = options; + return this.consumer as BackendConsumer; + }); } - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class ConsumerHandle { @@ -123,31 +135,33 @@ class ConcurrentConsumerBackend implements PubSubBackend { readonly consumerOptions: Array = []; readonly consumers: Array = []; - async createProducer(_options: CreateProducerOptions): Promise> { - return { - send: async () => {}, - flush: async () => {}, - close: async () => {}, - }; + createProducer(_options: CreateProducerOptions): Effect.Effect> { + return Effect.succeed({ + send: () => Effect.void, + flush: Effect.void, + close: Effect.void, + }); } - async createConsumer(options: CreateConsumerOptions): Promise> { - const handle = new ConsumerHandle(); - this.consumerOptions.push(options); - this.consumers.push(handle); + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + const handle = new ConsumerHandle(); + this.consumerOptions.push(options); + this.consumers.push(handle); - return { - receive: async () => null, - acknowledge: async () => {}, - negativeAcknowledge: async () => {}, - unsubscribe: async () => {}, - close: async () => { - handle.closeCount += 1; - }, - }; + return { + receive: () => Effect.succeed(null), + acknowledge: () => Effect.void, + negativeAcknowledge: () => Effect.void, + unsubscribe: Effect.void, + close: Effect.sync(() => { + handle.closeCount += 1; + }), + }; + }); } - async close(): Promise {} + readonly close: Effect.Effect = Effect.void; } const flowContext: FlowContext = { diff --git a/ts/packages/base/src/__tests__/nats-backend.test.ts b/ts/packages/base/src/__tests__/nats-backend.test.ts index e6bf942d..aedb6756 100644 --- a/ts/packages/base/src/__tests__/nats-backend.test.ts +++ b/ts/packages/base/src/__tests__/nats-backend.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Effect } from "effect"; import { makeNatsBackend } from "../backend/nats.js"; const natsMock = vi.hoisted(() => { @@ -34,6 +35,7 @@ const natsMock = vi.hoisted(() => { const publish = vi.fn(); const consumersGet = vi.fn(); + const consumersInfo = vi.fn(); const consumersAdd = vi.fn(); const streamsInfo = vi.fn(); const streamsAdd = vi.fn(); @@ -50,6 +52,7 @@ const natsMock = vi.hoisted(() => { connect, consumersAdd, consumersGet, + consumersInfo, decoder, drain, encoder, @@ -86,6 +89,7 @@ function resetNatsMock(): void { natsMock.publish.mockResolvedValue({ duplicate: false, seq: 1, stream: "tg_test" }); natsMock.consumersGet.mockResolvedValue({ next: natsMock.next }); + natsMock.consumersInfo.mockResolvedValue({ name: "worker" }); natsMock.consumersAdd.mockResolvedValue(undefined); natsMock.streamsInfo.mockResolvedValue({ config: { name: "tg_test" } }); natsMock.streamsAdd.mockResolvedValue(undefined); @@ -108,7 +112,7 @@ function resetNatsMock(): void { }), jetstreamManager: () => Promise.resolve({ - consumers: { add: natsMock.consumersAdd }, + consumers: { add: natsMock.consumersAdd, info: natsMock.consumersInfo }, streams: { add: natsMock.streamsAdd, info: natsMock.streamsInfo, @@ -126,7 +130,7 @@ describe("NATS backend", () => { natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("404", 404)); const backend = makeNatsBackend("nats://test"); - await backend.createProducer({ topic: "tg.test.topic" }); + await Effect.runPromise(backend.createProducer({ topic: "tg.test.topic" })); expect(natsMock.streamsAdd).toHaveBeenCalledWith({ name: "tg_test", @@ -137,11 +141,11 @@ describe("NATS backend", () => { it("caches initialized streams through the Effect mutable set", async () => { const backend = makeNatsBackend("nats://test"); - await backend.createProducer({ topic: "tg.test.topic" }); - await backend.createConsumer({ + await Effect.runPromise(backend.createProducer({ topic: "tg.test.topic" })); + await Effect.runPromise(backend.createConsumer({ topic: "tg.test.other", subscription: "worker", - }); + })); expect(natsMock.streamsInfo).toHaveBeenCalledTimes(1); expect(natsMock.streamsInfo).toHaveBeenCalledWith("tg_test"); @@ -151,7 +155,9 @@ describe("NATS backend", () => { natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION")); const backend = makeNatsBackend("nats://test"); - const error = await backend.createProducer({ topic: "tg.test.topic" }).catch((caught: unknown) => caught); + const error = await Effect.runPromise( + backend.createProducer({ topic: "tg.test.topic" }), + ).catch((caught: unknown) => caught); expect(error).toMatchObject({ _tag: "PubSubError", @@ -161,15 +167,13 @@ describe("NATS backend", () => { }); it("creates durable consumers only when consumer lookup returns a JetStream 404", async () => { - natsMock.consumersGet - .mockRejectedValueOnce(makeNatsError("404", 404)) - .mockResolvedValueOnce({ next: natsMock.next }); + natsMock.consumersInfo.mockRejectedValueOnce(makeNatsError("404", 404)); const backend = makeNatsBackend("nats://test"); - await backend.createConsumer({ + await Effect.runPromise(backend.createConsumer({ topic: "tg.test.topic", subscription: "worker", - }); + })); expect(natsMock.consumersAdd).toHaveBeenCalledWith("tg_test", { ack_policy: "explicit", @@ -180,13 +184,13 @@ describe("NATS backend", () => { }); it("does not create durable consumers for non-missing lookup failures", async () => { - natsMock.consumersGet.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION")); + natsMock.consumersInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION")); const backend = makeNatsBackend("nats://test"); - const error = await backend.createConsumer({ + const error = await Effect.runPromise(backend.createConsumer({ topic: "tg.test.topic", subscription: "worker", - }).catch((caught: unknown) => caught); + })).catch((caught: unknown) => caught); expect(error).toMatchObject({ _tag: "PubSubError", @@ -200,9 +204,11 @@ describe("NATS backend", () => { throw "invalid header"; }); const backend = makeNatsBackend("nats://test"); - const producer = await backend.createProducer({ topic: "tg.test.topic" }); + const producer = await Effect.runPromise(backend.createProducer({ topic: "tg.test.topic" })); - const error = await producer.send("hello", { bad: "value" }).catch((caught: unknown) => caught); + const error = await Effect.runPromise( + producer.send("hello", { bad: "value" }), + ).catch((caught: unknown) => caught); expect(error).toMatchObject({ _tag: "PubSubError", @@ -219,19 +225,23 @@ describe("NATS backend", () => { throw "nak failed"; }); const backend = makeNatsBackend("nats://test"); - const consumer = await backend.createConsumer({ + const consumer = await Effect.runPromise(backend.createConsumer({ topic: "tg.test.topic", subscription: "worker", - }); - const message = await consumer.receive(1); + })); + const message = await Effect.runPromise(consumer.receive(1)); expect(message).not.toBeNull(); if (message === null) { return; } - const ackError = await consumer.acknowledge(message).catch((caught: unknown) => caught); - const nakError = await consumer.negativeAcknowledge(message).catch((caught: unknown) => caught); + const ackError = await Effect.runPromise( + consumer.acknowledge(message), + ).catch((caught: unknown) => caught); + const nakError = await Effect.runPromise( + consumer.negativeAcknowledge(message), + ).catch((caught: unknown) => caught); expect(ackError).toMatchObject({ _tag: "PubSubError", diff --git a/ts/packages/base/src/__tests__/producer.test.ts b/ts/packages/base/src/__tests__/producer.test.ts index 59a1c277..fa812a80 100644 --- a/ts/packages/base/src/__tests__/producer.test.ts +++ b/ts/packages/base/src/__tests__/producer.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; import { makeProducer, + pubSubError, type BackendConsumer, type BackendProducer, type CreateConsumerOptions, @@ -15,30 +17,33 @@ class ProducerBackend implements PubSubBackend { flushCount = 0; failFlush = false; - async createProducer(options: CreateProducerOptions): Promise> { - this.producerTopics.push(options.topic); + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + this.producerTopics.push(options.topic); - return { - send: async (message, properties) => { + return { + send: (message, properties) => Effect.sync(() => { this.sent.push(properties === undefined ? { message } : { message, properties }); - }, - flush: async () => { - this.flushCount += 1; - if (this.failFlush) { - return Promise.reject("flush failed"); - } - }, - close: async () => { - this.closeCount += 1; - }, - }; + }), + flush: Effect.suspend(() => { + this.flushCount += 1; + if (this.failFlush) { + return Effect.fail(pubSubError("flush", "flush failed")); + } + return Effect.void; + }), + close: Effect.sync(() => { + this.closeCount += 1; + }), + }; + }); } - createConsumer(_options: CreateConsumerOptions): Promise> { - return Promise.reject("consumer not supported"); + createConsumer(_options: CreateConsumerOptions): Effect.Effect> { + return Effect.fail(pubSubError("create-consumer", "consumer not supported")); } - async close(): Promise {} + readonly close: Effect.Effect = Effect.void; } describe("Producer", () => { @@ -46,9 +51,9 @@ describe("Producer", () => { const backend = new ProducerBackend(); const producer = makeProducer(backend, "tg.test.producer"); - await producer.start(); - await producer.send("message-1", "hello"); - await producer.stop(); + await Effect.runPromise(producer.start); + await Effect.runPromise(producer.send("message-1", "hello")); + await Effect.runPromise(producer.stop); expect(backend.producerTopics).toEqual(["tg.test.producer"]); expect(backend.sent).toEqual([ @@ -56,9 +61,11 @@ describe("Producer", () => { ]); expect(backend.flushCount).toBe(1); expect(backend.closeCount).toBe(1); - await expect(producer.stop()).resolves.toBeUndefined(); + await expect(Effect.runPromise(producer.stop)).resolves.toBeUndefined(); - const error = await producer.send("message-2", "late").catch((caught: unknown) => caught); + const error = await Effect.runPromise( + producer.send("message-2", "late"), + ).catch((caught: unknown) => caught); expect(error).toMatchObject({ _tag: "MessagingLifecycleError", operation: "send", @@ -70,10 +77,10 @@ describe("Producer", () => { const backend = new ProducerBackend(); const producer = makeProducer(backend, "tg.test.producer"); - await producer.start(); + await Effect.runPromise(producer.start); backend.failFlush = true; - const error = await producer.stop().catch((caught: unknown) => caught); + const error = await Effect.runPromise(producer.stop).catch((caught: unknown) => caught); expect(error).toMatchObject({ _tag: "MessagingDeliveryError", diff --git a/ts/packages/base/src/__tests__/request-response.test.ts b/ts/packages/base/src/__tests__/request-response.test.ts index 71bdecff..3e4643d3 100644 --- a/ts/packages/base/src/__tests__/request-response.test.ts +++ b/ts/packages/base/src/__tests__/request-response.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; import { makeRequestResponse, type BackendConsumer, @@ -23,18 +24,20 @@ class RecordingProducer implements BackendProducer { 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); + send(message: T, properties?: Record): Effect.Effect { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + this.onSend?.(message, properties); + }); } - async flush(): Promise { + readonly flush: Effect.Effect = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class WaitingConsumer implements BackendConsumer { @@ -55,32 +58,38 @@ class WaitingConsumer implements BackendConsumer { this.messages.push(message); } - async receive(): Promise | null> { - const message = this.messages.shift(); - if (message !== undefined || this.closed) return message ?? null; + receive(): Effect.Effect | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || this.closed) return Promise.resolve(message ?? null); - return await new Promise((resolve) => { - this.waiters.push(resolve); + return new Promise((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message): Promise { - this.acknowledged.push(message); + acknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message): Promise { - this.nacked.push(message); + negativeAcknowledge(message: Message): Effect.Effect { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise {} + readonly unsubscribe: Effect.Effect = Effect.void; - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } this.closeCount += 1; - } + }); } class RuntimeBackend implements PubSubBackend { @@ -96,19 +105,23 @@ class RuntimeBackend implements PubSubBackend { this.producer = new RecordingProducer(onSend); } - async createProducer(options: CreateProducerOptions): Promise> { - this.producerOptions = options; - return this.producer as BackendProducer; + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + this.producerOptions = options; + return this.producer as BackendProducer; + }); } - async createConsumer(options: CreateConsumerOptions): Promise> { - this.consumerOptions = options; - return this.consumer as BackendConsumer; + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + this.consumerOptions = options; + return this.consumer as BackendConsumer; + }); } - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } describe("RequestResponse compatibility facade", () => { @@ -127,9 +140,9 @@ describe("RequestResponse compatibility facade", () => { subscription: "sub", }); - await requestor.start(); - const response = await requestor.request("request", { timeoutMs: 250 }); - await requestor.stop(); + await Effect.runPromise(requestor.start); + const response = await Effect.runPromise(requestor.request("request", { timeoutMs: 250 })); + await Effect.runPromise(requestor.stop); expect(response).toBe("response"); expect(backend.producerOptions).toEqual({ topic: "request-topic" }); @@ -150,9 +163,11 @@ describe("RequestResponse compatibility facade", () => { subscription: "sub", }); - await requestor.start(); - const error = await requestor.request("request", { timeoutMs: 5 }).catch((caught: unknown) => caught); - await requestor.stop(); + await Effect.runPromise(requestor.start); + const error = await Effect.runPromise( + requestor.request("request", { timeoutMs: 5 }), + ).catch((caught: unknown) => caught); + await Effect.runPromise(requestor.stop); expect(error).toMatchObject({ _tag: "MessagingTimeoutError", @@ -171,7 +186,9 @@ describe("RequestResponse compatibility facade", () => { subscription: "sub", }); - const error = await requestor.request("request").catch((caught: unknown) => caught); + const error = await Effect.runPromise( + requestor.request("request"), + ).catch((caught: unknown) => caught); expect(error).toMatchObject({ _tag: "MessagingLifecycleError", diff --git a/ts/packages/base/src/__tests__/runtime-services.test.ts b/ts/packages/base/src/__tests__/runtime-services.test.ts index 3983a88a..bfea144f 100644 --- a/ts/packages/base/src/__tests__/runtime-services.test.ts +++ b/ts/packages/base/src/__tests__/runtime-services.test.ts @@ -4,6 +4,7 @@ import * as S from "effect/Schema"; import { PubSub, makeAsyncProcessor, + pubSubError, runProcessorScoped, type BackendConsumer, type BackendProducer, @@ -24,39 +25,45 @@ class FakeProducer implements BackendProducer { closeCount = 0; flushCount = 0; - async send(message: T, properties?: Record): Promise { - this.sent.push( - properties === undefined - ? { message } - : { message, properties }, - ); + send(message: T, properties?: Record): Effect.Effect { + return Effect.sync(() => { + this.sent.push( + properties === undefined + ? { message } + : { message, properties }, + ); + }); } - async flush(): Promise { + readonly flush: Effect.Effect = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class FakeConsumer implements BackendConsumer { closeCount = 0; - async receive(): Promise | null> { - return null; + receive(): Effect.Effect | null> { + return Effect.succeed(null); } - async acknowledge(): Promise {} + acknowledge(): Effect.Effect { + return Effect.void; + } - async negativeAcknowledge(): Promise {} + negativeAcknowledge(): Effect.Effect { + return Effect.void; + } - async unsubscribe(): Promise {} + readonly unsubscribe: Effect.Effect = Effect.void; - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class FakePubSubBackend implements PubSubBackend { @@ -64,24 +71,30 @@ class FakePubSubBackend implements PubSubBackend { producerOptions: CreateProducerOptions | null = null; consumerOptions: CreateConsumerOptions | null = null; - async createProducer(options: CreateProducerOptions): Promise> { - this.producerOptions = options; - return new FakeProducer(); + createProducer(options: CreateProducerOptions): Effect.Effect> { + return Effect.sync(() => { + this.producerOptions = options; + return new FakeProducer(); + }); } - async createConsumer(options: CreateConsumerOptions): Promise> { - this.consumerOptions = options; - return new FakeConsumer(); + createConsumer(options: CreateConsumerOptions): Effect.Effect> { + return Effect.sync(() => { + this.consumerOptions = options; + return new FakeConsumer(); + }); } - async close(): Promise { + readonly close: Effect.Effect = Effect.sync(() => { this.closeCount += 1; - } + }); } class FailingProducerBackend extends FakePubSubBackend { - override async createProducer(): Promise> { - throw RuntimeServicesTestError.make({ message: "producer unavailable" }); + override createProducer(): Effect.Effect> { + return Effect.fail( + pubSubError("createProducer:tg.test.failure", RuntimeServicesTestError.make({ message: "producer unavailable" })), + ); } } @@ -90,23 +103,19 @@ const makeRecordingProcessor = ( events: Array, ) => { const processor = makeAsyncProcessor(config, { - run: async (runtime) => { + run: (runtime) => Effect.sync(() => { events.push(`run:${runtime.config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`); - }, + }), }); - const stop = processor.stop; - processor.stop = async () => { + processor.onShutdown(() => Effect.sync(() => { events.push("stop"); - await stop(); - }; + })); return processor; }; const makeFailingProcessor = (config: ProcessorConfig) => makeAsyncProcessor(config, { - run: async () => { - throw RuntimeServicesTestError.make({ message: "processor failed" }); - }, + run: () => Effect.fail(RuntimeServicesTestError.make({ message: "processor failed" })), }); const makeNativeRecordingProcessor = ( @@ -122,8 +131,9 @@ const makeNativeRecordingProcessor = ( }), }); processor.onShutdown(() => { - events.push("native-stop"); - return Promise.resolve(); + return Effect.sync(() => { + events.push("native-stop"); + }); }); return processor; }; @@ -138,7 +148,7 @@ describe("Effect runtime services", () => { Effect.gen(function* () { const pubsub = yield* PubSub; const producer = yield* pubsub.createProducer({ topic: "tg.test.topic" }); - yield* Effect.promise(() => producer.send("hello", { id: "1" })); + yield* producer.send("hello", { id: "1" }); expect(backend.producerOptions).toEqual({ topic: "tg.test.topic" }); expect(pubsub.backend).toBe(backend); diff --git a/ts/packages/base/src/backend/nats.ts b/ts/packages/base/src/backend/nats.ts index efebd177..29d4cae7 100644 --- a/ts/packages/base/src/backend/nats.ts +++ b/ts/packages/base/src/backend/nats.ts @@ -36,7 +36,7 @@ import type { CreateConsumerOptions, Message, } from "./types.js"; -import { pubSubError } from "../errors.js"; +import { pubSubError, type PubSubError } from "../errors.js"; const sc = StringCodec(); @@ -113,7 +113,7 @@ function makeNatsProducer( ): BackendProducer { const makePublishOptions = ( properties: Record | undefined, - ): Effect.Effect, ReturnType> => { + ): Effect.Effect, PubSubError> => { if (properties === undefined || Object.keys(properties).length === 0) { return Effect.succeed({}); } @@ -131,35 +131,32 @@ function makeNatsProducer( }; return { - send: (message, properties) => - Effect.runPromise( - Effect.gen(function* () { - const encoded = schema !== undefined - ? yield* S.encodeUnknownEffect(schema)(message).pipe( - Effect.mapError((error) => pubSubError(`encode:${subject}`, error)), - ) - : message; - const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(encoded).pipe( - Effect.mapError((error) => pubSubError(`encode-json:${subject}`, error)), - ); - const data = sc.encode(json); - const opts = yield* makePublishOptions(properties); + send: Effect.fn(`NatsProducer.send:${subject}`)(function*(message: T, properties?: Record) { + const encoded = schema !== undefined + ? yield* S.encodeUnknownEffect(schema)(message).pipe( + Effect.mapError((error) => pubSubError(`encode:${subject}`, error)), + ) + : message; + const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(encoded).pipe( + Effect.mapError((error) => pubSubError(`encode-json:${subject}`, error)), + ); + const data = sc.encode(json); + const opts = yield* makePublishOptions(properties); - yield* Effect.tryPromise({ - try: () => js.publish(subject, data, opts), - catch: (error) => pubSubError(`publish:${subject}`, error), - }); - }), - ), + yield* Effect.tryPromise({ + try: () => js.publish(subject, data, opts), + catch: (error) => pubSubError(`publish:${subject}`, error), + }); + }), // NATS publishes are flushed on the connection level. - flush: () => Promise.resolve(), + flush: Effect.void, // No per-producer cleanup needed for NATS. - close: () => Promise.resolve(), + close: Effect.void, }; } interface InitializableBackendConsumer extends BackendConsumer { - readonly init: () => Promise; + readonly init: Effect.Effect; } function makeNatsConsumer( @@ -173,115 +170,111 @@ function makeNatsConsumer( ): InitializableBackendConsumer { let consumer: NatsJsConsumer | null = null; + const isReceiveTimeoutError = (error: unknown): boolean => { + const code = P.isObject(error) ? (error as { readonly code?: unknown }).code : undefined; + return code === 408 || code === "408" || code === ErrorCode.Timeout; + }; + return { - init: () => - Effect.runPromise( - Effect.gen(function* () { - const existing = yield* Effect.tryPromise({ - try: () => js.consumers.get(streamName, subscription), - catch: (error) => natsLookupError(`get-consumer:${streamName}:${subscription}`, error), - }).pipe( - Effect.catchIf( - isMissingLookupError, - () => - Effect.gen(function* () { - const deliverPolicy = - initialPosition === "earliest" - ? DeliverPolicy.All - : DeliverPolicy.New; + init: Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => jsm.consumers.info(streamName, subscription), + catch: (error) => natsLookupError(`consumer-info:${streamName}:${subscription}`, error), + }).pipe( + Effect.catchIf( + isMissingLookupError, + () => + Effect.gen(function* () { + const deliverPolicy = + initialPosition === "earliest" + ? DeliverPolicy.All + : DeliverPolicy.New; - yield* Effect.tryPromise({ - try: () => - jsm.consumers.add(streamName, { - durable_name: subscription, - ack_policy: AckPolicy.Explicit, - deliver_policy: deliverPolicy, - filter_subject: subject, - }), - catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error), - }); + yield* Effect.tryPromise({ + try: () => + jsm.consumers.add(streamName, { + durable_name: subscription, + ack_policy: AckPolicy.Explicit, + deliver_policy: deliverPolicy, + filter_subject: subject, + }), + catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error), + }); + }), + (error) => Effect.fail(pubSubError(error.operation, error.cause)), + ), + ); + consumer = yield* Effect.tryPromise({ + try: () => js.consumers.get(streamName, subscription), + catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error), + }); + }), + receive: Effect.fn(`NatsConsumer.receive:${subject}`)(function*(timeoutMs = 2000) { + const current = consumer; + if (current === null) { + return yield* pubSubError("receive", "Consumer not initialized"); + } - return yield* Effect.tryPromise({ - try: () => js.consumers.get(streamName, subscription), - catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error), - }); - }), - (error) => Effect.fail(pubSubError(error.operation, error.cause)), - ), + const msg = yield* Effect.tryPromise({ + try: () => current.next({ expires: timeoutMs }), + catch: (error) => + isReceiveTimeoutError(error) + ? pubSubError(`receive-timeout:${subject}`, error) + : pubSubError(`receive:${subject}`, error), + }).pipe( + Effect.catchIf( + (error) => error.operation === `receive-timeout:${subject}`, + () => Effect.succeed(null), + ), + ); + if (msg === null) return null; + + const parsed = yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(sc.decode(msg.data)).pipe( + Effect.mapError((error) => pubSubError(`decode-json:${subject}`, error)), + ); + const decoded = schema !== undefined + ? yield* S.decodeUnknownEffect(schema)(parsed).pipe( + Effect.mapError((error) => pubSubError(`decode-schema:${subject}`, error)), + ) + : yield* S.decodeUnknownEffect(S.Any)(parsed).pipe( + Effect.mapError((error) => pubSubError(`decode-any:${subject}`, error)), ); - consumer = existing; - }), - ), - receive: (timeoutMs = 2000) => - Effect.runPromise( - Effect.gen(function* () { - const current = consumer; - if (current === null) { - return yield* pubSubError("receive", "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 = yield* Effect.tryPromise({ - try: () => current.next({ expires: timeoutMs }), - catch: (error) => pubSubError(`receive:${subject}`, error), - }); - if (msg === null) return null; - - const parsed = yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(sc.decode(msg.data)).pipe( - Effect.mapError((error) => pubSubError(`decode-json:${subject}`, error)), - ); - const decoded = schema !== undefined - ? yield* S.decodeUnknownEffect(schema)(parsed).pipe( - Effect.mapError((error) => pubSubError(`decode-schema:${subject}`, error)), - ) - : yield* S.decodeUnknownEffect(S.Any)(parsed).pipe( - Effect.mapError((error) => pubSubError(`decode-any:${subject}`, error)), - ); - return makeNatsMessage(msg, decoded); - }), - ), - acknowledge: (message) => - Effect.runPromise( - Effect.gen(function* () { - if (!isNatsMessage(message)) { - return yield* pubSubError(`acknowledge:${subject}`, "Message was not produced by NATS backend"); - } - yield* Effect.try({ - try: () => { - message._jsMsg.ack(); - }, - catch: (error) => pubSubError(`acknowledge:${subject}`, error), - }); - }), - ), - negativeAcknowledge: (message) => - Effect.runPromise( - Effect.gen(function* () { - if (!isNatsMessage(message)) { - return yield* pubSubError( - `negative-acknowledge:${subject}`, - "Message was not produced by NATS backend", - ); - } - yield* Effect.try({ - try: () => { - message._jsMsg.nak(); - }, - catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error), - }); - }), - ), - unsubscribe: () => { - // The pull-based consumer does not have a persistent subscription to drain. - // Clearing the reference is sufficient; the durable consumer persists server-side. + return makeNatsMessage(msg, decoded); + }), + acknowledge: Effect.fn(`NatsConsumer.acknowledge:${subject}`)(function*(message: Message) { + if (!isNatsMessage(message)) { + return yield* pubSubError( + `acknowledge:${subject}`, + "Message was not produced by NATS backend", + ); + } + yield* Effect.try({ + try: () => { + message._jsMsg.ack(); + }, + catch: (error) => pubSubError(`acknowledge:${subject}`, error), + }); + }), + negativeAcknowledge: Effect.fn(`NatsConsumer.negativeAcknowledge:${subject}`)(function*(message: Message) { + if (!isNatsMessage(message)) { + return yield* pubSubError( + `negative-acknowledge:${subject}`, + "Message was not produced by NATS backend", + ); + } + yield* Effect.try({ + try: () => { + message._jsMsg.nak(); + }, + catch: (error) => pubSubError(`negative-acknowledge:${subject}`, error), + }); + }), + unsubscribe: Effect.sync(() => { consumer = null; - return Promise.resolve(); - }, - close: () => { + }), + close: Effect.sync(() => { consumer = null; - return Promise.resolve(); - }, + }), }; } @@ -319,7 +312,9 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend { const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`; const manager = jsm; - if (manager === null) return yield* pubSubError("ensure-stream", "NATS backend not connected"); + if (manager === null) { + return yield* pubSubError("ensure-stream", "NATS backend not connected"); + } yield* Effect.tryPromise({ try: () => manager.streams.info(streamName), @@ -344,56 +339,48 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend { }); return { - createProducer: (options: CreateProducerOptions) => - Effect.runPromise( - Effect.gen(function* () { - yield* ensureConnected(); - yield* ensureStream(options.topic); - const client = js; - if (client === null) return yield* pubSubError("create-producer", "NATS backend not connected"); - return makeNatsProducer(client, options.topic, options.schema); - }), - ), - createConsumer: (options: CreateConsumerOptions) => - Effect.runPromise( - Effect.gen(function* () { - yield* ensureConnected(); - const streamName = yield* ensureStream(options.topic); - const client = js; - const manager = jsm; - if (client === null || manager === null) { - return yield* pubSubError("create-consumer", "NATS backend not connected"); - } - const consumer = makeNatsConsumer( - client, - manager, - options.topic, - options.subscription, - options.initialPosition ?? "latest", - streamName, - options.schema, - ); - yield* Effect.tryPromise({ - try: () => consumer.init(), - catch: (error) => pubSubError(`init-consumer:${options.topic}`, error), - }); - return consumer; - }), - ), - close: () => - Effect.runPromise( - Effect.gen(function* () { - const conn = connection; - if (conn !== null) { - yield* Effect.tryPromise({ - try: () => conn.drain(), - catch: (error) => pubSubError("close", error), - }); - connection = null; - js = null; - jsm = null; - } - }), - ), + createProducer: Effect.fn("NatsBackend.createProducer")(function*(options: CreateProducerOptions) { + yield* ensureConnected(); + yield* ensureStream(options.topic); + const client = js; + if (client === null) { + return yield* pubSubError("create-producer", "NATS backend not connected"); + } + return makeNatsProducer(client, options.topic, options.schema); + }), + createConsumer: Effect.fn("NatsBackend.createConsumer")(function*(options: CreateConsumerOptions) { + yield* ensureConnected(); + const streamName = yield* ensureStream(options.topic); + const client = js; + const manager = jsm; + if (client === null || manager === null) { + return yield* pubSubError("create-consumer", "NATS backend not connected"); + } + const consumer = makeNatsConsumer( + client, + manager, + options.topic, + options.subscription, + options.initialPosition ?? "latest", + streamName, + options.schema, + ); + yield* consumer.init.pipe( + Effect.mapError((error) => pubSubError(`init-consumer:${options.topic}`, error)), + ); + return consumer; + }), + close: Effect.gen(function* () { + const conn = connection; + if (conn !== null) { + yield* Effect.tryPromise({ + try: () => conn.drain(), + catch: (error) => pubSubError("close", error), + }); + connection = null; + js = null; + jsm = null; + } + }), }; } diff --git a/ts/packages/base/src/backend/pubsub.ts b/ts/packages/base/src/backend/pubsub.ts index 5ec4855a..40f59171 100644 --- a/ts/packages/base/src/backend/pubsub.ts +++ b/ts/packages/base/src/backend/pubsub.ts @@ -1,8 +1,8 @@ /** * 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. + * The backend protocol is Effect-native; this service provides the + * Context.Service/Layer boundary used by runtime composition. */ import { Config, Context, Effect, Layer } from "effect"; @@ -15,17 +15,17 @@ import type { PubSubBackend, } from "./types.js"; import { makeNatsBackend } from "./nats.js"; -import { pubSubError } from "../errors.js"; +import type { PubSubError } from "../errors.js"; export interface PubSubService { readonly backend: PubSubBackend; readonly createProducer: ( options: CreateProducerOptions, - ) => Effect.Effect, ReturnType>; + ) => Effect.Effect, PubSubError>; readonly createConsumer: ( options: CreateConsumerOptions, - ) => Effect.Effect, ReturnType>; - readonly close: Effect.Effect>; + ) => Effect.Effect, PubSubError>; + readonly close: Effect.Effect; } export class PubSub extends Context.Service()("@trustgraph/base/backend/pubsub") { @@ -41,20 +41,9 @@ export class PubSub extends Context.Service()("@trustgrap 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), - }), + createProducer: (options: CreateProducerOptions) => backend.createProducer(options), + createConsumer: (options: CreateConsumerOptions) => backend.createConsumer(options), + close: backend.close, }; } diff --git a/ts/packages/base/src/backend/types.ts b/ts/packages/base/src/backend/types.ts index 8f541b8a..7e1d9a38 100644 --- a/ts/packages/base/src/backend/types.ts +++ b/ts/packages/base/src/backend/types.ts @@ -5,7 +5,9 @@ * (NATS, Pulsar, Redis Streams) implements these interfaces. */ +import type { Effect } from "effect"; import type * as S from "effect/Schema"; +import type { PubSubError } from "../errors.js"; export interface Message { value(): T; @@ -13,17 +15,17 @@ export interface Message { } export interface BackendProducer { - send(message: T, properties?: Record): Promise; - flush(): Promise; - close(): Promise; + send(message: T, properties?: Record): Effect.Effect; + flush: Effect.Effect; + close: Effect.Effect; } export interface BackendConsumer { - receive(timeoutMs?: number): Promise | null>; - acknowledge(message: Message): Promise; - negativeAcknowledge(message: Message): Promise; - unsubscribe(): Promise; - close(): Promise; + receive(timeoutMs?: number): Effect.Effect | null, PubSubError>; + acknowledge(message: Message): Effect.Effect; + negativeAcknowledge(message: Message): Effect.Effect; + unsubscribe: Effect.Effect; + close: Effect.Effect; } export type ConsumerType = "shared" | "exclusive" | "failover"; @@ -43,7 +45,7 @@ export interface CreateConsumerOptions { } export interface PubSubBackend { - createProducer(options: CreateProducerOptions): Promise>; - createConsumer(options: CreateConsumerOptions): Promise>; - close(): Promise; + createProducer(options: CreateProducerOptions): Effect.Effect, PubSubError>; + createConsumer(options: CreateConsumerOptions): Effect.Effect, PubSubError>; + close: Effect.Effect; } diff --git a/ts/packages/base/src/errors.ts b/ts/packages/base/src/errors.ts index 63399088..42207767 100644 --- a/ts/packages/base/src/errors.ts +++ b/ts/packages/base/src/errors.ts @@ -104,7 +104,7 @@ export class MessagingTimeoutError extends S.TaggedErrorClass = ( message: T, properties: Record, flow: FlowContext, -) => Promise; +) => Effect.Effect; export interface FlowContext { id: string; @@ -47,8 +51,10 @@ declare const ConsumerMessageType: unique symbol; export interface Consumer { readonly [ConsumerMessageType]?: (_: T) => T; - readonly start: (flow: FlowContext) => Promise; - readonly stop: () => Promise; + readonly start: ( + flow: FlowContext, + ) => Effect.Effect; + readonly stop: Effect.Effect; } interface ConsumerRuntime { @@ -56,8 +62,6 @@ interface ConsumerRuntime { readonly consumer: EffectConsumer; } -const consumerRuntime = ManagedRuntime.make(Layer.empty); - export function makeConsumer(options: ConsumerOptions): Consumer { let runtime: ConsumerRuntime | null = null; const isTooManyRequestsError = S.is(TooManyRequestsError); @@ -67,62 +71,58 @@ export function makeConsumer(options: ConsumerOptions): Consumer { properties: Record, flow: FlowContext, ): Effect.Effect => - Effect.tryPromise({ - try: () => options.handler(message, properties, flow), - catch: (error) => + options.handler(message, properties, flow).pipe( + Effect.mapError((error) => isTooManyRequestsError(error) ? error - : messagingHandlerError(options.topic, options.subscription, error), - }); + : messagingHandlerError(options.topic, options.subscription, error) + ), + ); return { start: (flow) => P.isNotNull(runtime) - ? Promise.resolve() - : consumerRuntime.runPromise( - Effect.gen(function* () { - const scope = yield* Scope.make(); - const startConsumer = Effect.gen(function* () { - const config = yield* loadMessagingRuntimeConfig(); - const consumer = yield* makeEffectConsumerFromPubSub( - PubSub.fromBackend(options.pubsub), - config, - { - topic: options.topic, - subscription: options.subscription, - handler: runHandler, - ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - initialPosition: options.initialPosition ?? "latest", - ...(options.rateLimitRetryMs === undefined ? {} : { rateLimitRetryMs: options.rateLimitRetryMs }), - ...(options.rateLimitTimeoutMs === undefined - ? {} - : { rateLimitTimeoutMs: options.rateLimitTimeoutMs }), - }, - flow, - ).pipe( - Scope.provide(scope), - Effect.mapError((error) => - messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error) - ), - ); - runtime = { scope, consumer }; - }); - - yield* startConsumer.pipe( - Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ? Effect.void + : Effect.gen(function* () { + const scope = yield* Scope.make(); + const startConsumer = Effect.gen(function* () { + const config = yield* loadMessagingRuntimeConfig(); + const consumer = yield* makeEffectConsumerFromPubSub( + PubSub.fromBackend(options.pubsub), + config, + { + topic: options.topic, + subscription: options.subscription, + handler: runHandler, + ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), + initialPosition: options.initialPosition ?? "latest", + ...(options.rateLimitRetryMs === undefined ? {} : { rateLimitRetryMs: options.rateLimitRetryMs }), + ...(options.rateLimitTimeoutMs === undefined + ? {} + : { rateLimitTimeoutMs: options.rateLimitTimeoutMs }), + }, + flow, + ).pipe( + Scope.provide(scope), + Effect.mapError((error) => + messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error) + ), ); - }), - ), - stop: () => { + runtime = { scope, consumer }; + }); + + yield* startConsumer.pipe( + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }), + stop: Effect.suspend(() => { const current = runtime; runtime = null; return current === null - ? Promise.resolve() - : consumerRuntime.runPromise( - current.consumer.stop.pipe( - Effect.ensuring(Scope.close(current.scope, Exit.void)), - ), + ? Effect.void + : current.consumer.stop.pipe( + Effect.ensuring(Scope.close(current.scope, Exit.void)), ); - }, + }), }; } diff --git a/ts/packages/base/src/messaging/producer.ts b/ts/packages/base/src/messaging/producer.ts index 3824d71a..e8b6a72f 100644 --- a/ts/packages/base/src/messaging/producer.ts +++ b/ts/packages/base/src/messaging/producer.ts @@ -9,12 +9,16 @@ import type { ProducerMetrics } from "../metrics/index.ts"; import { Effect, Exit, Scope } from "effect"; import { PubSub } from "../backend/pubsub.js"; import { makeEffectProducerFromPubSub, type EffectProducer } from "./runtime.js"; -import { messagingLifecycleError } from "../errors.js"; +import { + messagingLifecycleError, + type MessagingDeliveryError, + type MessagingLifecycleError, +} from "../errors.js"; export interface Producer { - readonly start: () => Promise; - readonly send: (id: string, message: T) => Promise; - readonly stop: () => Promise; + readonly start: Effect.Effect; + readonly send: (id: string, message: T) => Effect.Effect; + readonly stop: Effect.Effect; } interface ProducerRuntime { @@ -29,49 +33,46 @@ export function makeProducer( ): Producer { let runtime: ProducerRuntime | null = null; + const start = Effect.fn(`Producer.start:${topic}`)(function* () { + if (runtime !== null) return; + + const scope = yield* Scope.make(); + const startProducer = Effect.gen(function* () { + const producer = yield* makeEffectProducerFromPubSub( + PubSub.fromBackend(pubsub), + { + topic, + ...(metrics === undefined ? {} : { metrics }), + }, + ).pipe( + Scope.provide(scope), + Effect.mapError((error) => messagingLifecycleError(topic, "create-producer", error)), + ); + + runtime = { scope, producer }; + }); + + yield* startProducer.pipe( + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }); + return { - start: () => - runtime !== null - ? Promise.resolve() - : Effect.runPromise( - Effect.gen(function* () { - const scope = yield* Scope.make(); - const startProducer = Effect.gen(function* () { - const producer = yield* makeEffectProducerFromPubSub( - PubSub.fromBackend(pubsub), - { - topic, - ...(metrics === undefined ? {} : { metrics }), - }, - ).pipe( - Scope.provide(scope), - Effect.mapError((error) => messagingLifecycleError(topic, "create-producer", error)), - ); - - runtime = { scope, producer }; - }); - - yield* startProducer.pipe( - Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), - ); - }), - ), + start: start(), send: (id, message) => { const current = runtime; return current === null - ? Effect.runPromise(Effect.fail(messagingLifecycleError(topic, "send", "Producer not started"))) - : Effect.runPromise(current.producer.send(id, message)); + ? Effect.fail(messagingLifecycleError(topic, "send", "Producer not started")) + : current.producer.send(id, message); }, - stop: () => { + stop: Effect.suspend(() => { const current = runtime; runtime = null; return current === null - ? Promise.resolve() - : Effect.runPromise( - current.producer.flush.pipe( - Effect.ensuring(Scope.close(current.scope, Exit.void)), - ), + ? Effect.void + : current.producer.flush.pipe( + Effect.ensuring(Scope.close(current.scope, Exit.void)), ); - }, + }), }; } diff --git a/ts/packages/base/src/messaging/request-response.ts b/ts/packages/base/src/messaging/request-response.ts index fdc38720..639c82dd 100644 --- a/ts/packages/base/src/messaging/request-response.ts +++ b/ts/packages/base/src/messaging/request-response.ts @@ -7,12 +7,22 @@ * Python reference: trustgraph-base/trustgraph/base/request_response_spec.py */ -import { Effect, Exit, Scope } from "effect"; +import { Config as EffectConfig, Effect, Exit, Scope } from "effect"; import type { PubSubBackend } from "../backend/types.js"; import { PubSub } from "../backend/pubsub.js"; -import { messagingDeliveryError, messagingLifecycleError } from "../errors.js"; +import { + messagingLifecycleError, + type MessagingDeliveryError, + type MessagingLifecycleError, + type MessagingTimeoutError, + type PubSubError, +} from "../errors.js"; import { loadMessagingRuntimeConfig } from "../runtime/index.ts"; -import { makeEffectRequestResponseFromPubSub, type EffectRequestResponse } from "./runtime.js"; +import { + makeEffectRequestResponseFromPubSub, + type EffectRequestOptions, + type EffectRequestResponse, +} from "./runtime.js"; export interface RequestResponseOptions { pubsub: PubSubBackend; @@ -22,15 +32,12 @@ export interface RequestResponseOptions { } export interface RequestResponse { - readonly start: () => Promise; - readonly stop: () => Promise; - readonly request: ( + readonly start: Effect.Effect; + readonly stop: Effect.Effect; + readonly request: ( request: TReq, - options?: { - timeoutMs?: number; - recipient?: (response: TRes) => Promise; - }, - ) => Promise; + options?: EffectRequestOptions, + ) => Effect.Effect; } interface RequestResponseRuntime { @@ -43,44 +50,43 @@ export function makeRequestResponse( ): RequestResponse { let runtime: RequestResponseRuntime | null = null; + const start = Effect.fn(`RequestResponse.start:${options.requestTopic}:${options.responseTopic}`)(function* () { + if (runtime !== null) return; + + const scope = yield* Scope.make(); + const startRuntime = Effect.gen(function* () { + const config = yield* loadMessagingRuntimeConfig(); + const requestor = yield* makeEffectRequestResponseFromPubSub( + PubSub.fromBackend(options.pubsub), + config, + { + requestTopic: options.requestTopic, + responseTopic: options.responseTopic, + subscription: options.subscription, + }, + ).pipe(Scope.provide(scope)); + + runtime = { scope, requestor }; + }); + + yield* startRuntime.pipe( + Effect.catch((error) => + Scope.close(scope, Exit.fail(error)).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + }); + return { - start: () => - runtime !== null - ? Promise.resolve() - : Effect.runPromise( - Effect.gen(function* () { - const scope = yield* Scope.make(); - const startRuntime = Effect.gen(function* () { - const config = yield* loadMessagingRuntimeConfig(); - const requestor = yield* makeEffectRequestResponseFromPubSub( - PubSub.fromBackend(options.pubsub), - config, - { - requestTopic: options.requestTopic, - responseTopic: options.responseTopic, - subscription: options.subscription, - }, - ).pipe(Scope.provide(scope)); - - runtime = { scope, requestor }; - }); - - yield* startRuntime.pipe( - Effect.catch((error) => - Scope.close(scope, Exit.fail(error)).pipe( - Effect.flatMap(() => Effect.fail(error)), - ), - ), - ); - }), - ), - stop: () => { + start: start(), + stop: Effect.suspend(() => { const current = runtime; runtime = null; return current === null - ? Promise.resolve() - : Effect.runPromise(Scope.close(current.scope, Exit.void)); - }, + ? Effect.void + : Scope.close(current.scope, Exit.void); + }), /** * Send a request and wait for responses. * @@ -93,34 +99,21 @@ export function makeRequestResponse( request: (request, requestOptions) => { const current = runtime; if (current === null) { - return Effect.runPromise( - Effect.fail( - messagingLifecycleError( - `${options.requestTopic}:${options.responseTopic}`, - "request", - "RequestResponse not started", - ), + return Effect.fail( + messagingLifecycleError( + `${options.requestTopic}:${options.responseTopic}`, + "request", + "RequestResponse not started", ), ); } const timeoutMs = requestOptions?.timeoutMs ?? 300_000; - const recipient = requestOptions?.recipient; - return Effect.runPromise( - current.requestor.request(request, { - timeoutMs, - ...(recipient === undefined - ? {} - : { - recipient: (response) => - Effect.tryPromise({ - try: () => recipient(response), - catch: (error) => messagingDeliveryError(options.responseTopic, "recipient", error), - }), - }), - }), - ); + return current.requestor.request(request, { + ...requestOptions, + timeoutMs, + }); }, }; } diff --git a/ts/packages/base/src/messaging/runtime.ts b/ts/packages/base/src/messaging/runtime.ts index 1085c38b..4f792a7d 100644 --- a/ts/packages/base/src/messaging/runtime.ts +++ b/ts/packages/base/src/messaging/runtime.ts @@ -168,10 +168,8 @@ export function makeEffectProducerHandle( ): 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( + backend.send(message, { id }).pipe( + Effect.mapError((error) => messagingDeliveryError(options.topic, "send", error)), Effect.tap(() => options.metrics === undefined ? Effect.void @@ -179,14 +177,12 @@ export function makeEffectProducerHandle( ), ), ), - 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), - }), + flush: backend.flush.pipe( + Effect.mapError((error) => messagingDeliveryError(options.topic, "flush", error)), + ), + close: backend.close.pipe( + Effect.mapError((error) => messagingDeliveryError(options.topic, "close", error)), + ), }; } @@ -219,40 +215,36 @@ const closeConsumerBackend = ( topic: string, subscription: string, ) => - Effect.tryPromise({ - try: () => backend.close(), - catch: (error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error), - }); + backend.close.pipe( + Effect.mapError((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), - }); + backend.acknowledge(message).pipe( + Effect.mapError((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), - }); + backend.negativeAcknowledge(message).pipe( + Effect.mapError((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), - }); + backend.receive(timeoutMs).pipe( + Effect.mapError((error) => messagingDeliveryError(topic, "receive", error)), + ); const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* ( options: EffectConsumerOptions, diff --git a/ts/packages/base/src/processor/async-processor.ts b/ts/packages/base/src/processor/async-processor.ts index ee934de4..ed725a1b 100644 --- a/ts/packages/base/src/processor/async-processor.ts +++ b/ts/packages/base/src/processor/async-processor.ts @@ -8,7 +8,7 @@ import type { PubSubBackend } from "../backend/types.js"; import { makeNatsBackend } from "../backend/nats.js"; -import { Context, Effect, Layer, ManagedRuntime } from "effect"; +import { Cause, Config as EffectConfig, Context, Effect } from "effect"; import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js"; import { loadProcessorRuntimeConfig } from "../runtime/config.js"; @@ -23,7 +23,7 @@ export interface ProcessorConfig { export type ConfigHandler = ( config: Record, version: number, -) => Promise; +) => Effect.Effect; export type EffectConfigHandler = ( config: Record, @@ -36,8 +36,10 @@ declare const processorRunRequirementsType: unique symbol; export interface ProcessorRuntime { readonly [processorRunErrorType]?: RunError; readonly [processorRunRequirementsType]?: RunRequirements; - readonly start: (context: Context.Context) => Promise; - readonly stop: () => Promise; + readonly start: ( + context: Context.Context, + ) => Effect.Effect; + readonly stop: Effect.Effect; startEffect: Effect.Effect; stopEffect: Effect.Effect; } @@ -48,12 +50,16 @@ export interface AsyncProcessorRuntime< > extends ProcessorRuntime { readonly config: ProcessorConfig; readonly pubsub: PubSubBackend; - readonly configHandlers: ConfigHandler[]; + readonly configHandlers: Array>; readonly running: boolean; readonly isRunning: () => boolean; - readonly registerConfigHandler: (handler: ConfigHandler) => void; - readonly onShutdown: (callback: () => Promise) => void; - readonly run: (context: Context.Context) => Promise; + readonly registerConfigHandler: ( + handler: EffectConfigHandler, + ) => void; + readonly onShutdown: (callback: () => Effect.Effect) => void; + readonly run: ( + context: Context.Context, + ) => Effect.Effect; runEffect: Effect.Effect; } @@ -63,7 +69,7 @@ export interface AsyncProcessorRuntimeOptions< > { readonly run?: ( processor: AsyncProcessorRuntime, - ) => Promise; + ) => Effect.Effect; readonly runEffect?: ( processor: AsyncProcessorRuntime, ) => Effect.Effect; @@ -74,8 +80,6 @@ interface RegisteredSignalHandler { readonly handler: () => void; } -const asyncProcessorRuntime = ManagedRuntime.make(Layer.empty); - export function makeAsyncProcessor< RunError = ProcessorLifecycleError, RunRequirements = never, @@ -85,8 +89,8 @@ export function makeAsyncProcessor< ): AsyncProcessorRuntime { const pubsub = config.pubsub ?? makeNatsBackend(config.pubsubUrl ?? "nats://localhost:4222"); const ownsPubSub = config.pubsub === undefined; - const configHandlers: ConfigHandler[] = []; - const shutdownCallbacks: Array<() => Promise> = []; + const configHandlers: Array> = []; + const shutdownCallbacks: Array<() => Effect.Effect> = []; let running = false; let signalHandlers: RegisteredSignalHandler[] = []; @@ -96,12 +100,16 @@ export function makeAsyncProcessor< } const shutdown = () => { - void asyncProcessorRuntime.runPromise( + Effect.runFork( Effect.log(`[${config.id}] Shutting down...`).pipe( - Effect.flatMap(() => processor.stopEffect), + Effect.flatMap(() => processor.stop), Effect.mapError((error) => processorLifecycleError(config.id, "signal-shutdown", error)), + Effect.match({ + onFailure: () => process.exit(1), + onSuccess: () => process.exit(0), + }), ), - ).then(() => process.exit(0), () => process.exit(1)); + ); }; const handlers: RegisteredSignalHandler[] = [ { signal: "SIGINT", handler: shutdown }, @@ -131,8 +139,10 @@ export function makeAsyncProcessor< registerConfigHandler: (handler) => { configHandlers.push(handler); }, - start: (context) => asyncProcessorRuntime.runPromise(Effect.provide(processor.startEffect, context)), - stop: () => asyncProcessorRuntime.runPromise(processor.stopEffect), + start: (context) => Effect.provide(processor.startEffect, context), + get stop() { + return processor.stopEffect; + }, onShutdown: (callback) => { shutdownCallbacks.push(callback); }, @@ -161,30 +171,27 @@ export function makeAsyncProcessor< }); for (const cb of shutdownCallbacks) { - yield* Effect.tryPromise({ - try: () => cb(), - catch: (error) => processorLifecycleError(config.id, "shutdown-callback", error), - }); + yield* cb().pipe( + Effect.mapError((error) => processorLifecycleError(config.id, "shutdown-callback", error)), + ); } if (ownsPubSub) { - yield* Effect.tryPromise({ - try: () => pubsub.close(), - catch: (error) => processorLifecycleError(config.id, "close-pubsub", error), - }); + yield* pubsub.close.pipe( + Effect.mapError((error) => processorLifecycleError(config.id, "close-pubsub", error)), + ); } }); return stopProcessor(); }, - run: (context) => asyncProcessorRuntime.runPromise(Effect.provide(processor.runEffect, context)), + run: (context) => Effect.provide(processor.runEffect, context), get runEffect() { if (options.runEffect !== undefined) { return options.runEffect(processor); } - return Effect.tryPromise({ - try: () => options.run?.(processor) ?? Promise.resolve(), - catch: (error) => processorLifecycleError(config.id, "start", error), - }); + return options.run?.(processor).pipe( + Effect.mapError((error) => processorLifecycleError(config.id, "start", error)), + ) ?? Effect.void; }, }; @@ -201,21 +208,18 @@ export const AsyncProcessor = Object.assign( return makeAsyncProcessor(config); }, { - launch>( + launch>( this: new (config: ProcessorConfig) => T, id: string, - ): Promise { + ): Effect.Effect { const ProcessorCtor = this; - return asyncProcessorRuntime.runPromise( - Effect.gen(function* () { - const config = yield* loadProcessorRuntimeConfig(id); - const processor = new ProcessorCtor(config); - yield* Effect.tryPromise({ - try: () => processor.start(Context.empty()), - catch: (error) => processorLifecycleError(id, "launch", error), - }); - }), - ); + return Effect.gen(function* () { + const config = yield* loadProcessorRuntimeConfig(id); + const processor = new ProcessorCtor(config); + yield* processor.start(Context.empty()).pipe( + Effect.mapError((error) => processorLifecycleError(id, "launch", error)), + ); + }); }, }, ) as unknown as { @@ -225,8 +229,8 @@ export const AsyncProcessor = Object.assign( ( config: ProcessorConfig, ): AsyncProcessor; - launch>( + launch>( this: new (config: ProcessorConfig) => T, id: string, - ): Promise; + ): Effect.Effect; }; diff --git a/ts/packages/base/src/processor/flow-processor.ts b/ts/packages/base/src/processor/flow-processor.ts index 6935e6f6..84c48b1b 100644 --- a/ts/packages/base/src/processor/flow-processor.ts +++ b/ts/packages/base/src/processor/flow-processor.ts @@ -10,7 +10,6 @@ import { makeAsyncProcessor, type AsyncProcessorRuntime, - type ConfigHandler, type EffectConfigHandler, type ProcessorRuntime, type ProcessorConfig, @@ -38,7 +37,7 @@ import { } from "../messaging/runtime.js"; import { makePubSubService, PubSub } from "../backend/pubsub.js"; import { loadMessagingRuntimeConfig } from "../runtime/index.ts"; -import { Context, Duration, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; +import { Config as EffectConfig, Context, Duration, Effect, Exit, Scope } from "effect"; import * as MutableHashMap from "effect/MutableHashMap"; import * as O from "effect/Option"; import * as S from "effect/Schema"; @@ -75,22 +74,38 @@ type FlowProcessorRuntimeRequirements = | Scope.Scope | FlowRequirements; +type FlowProcessorRunError = + | PubSubError + | FlowRuntimeError + | ProcessorLifecycleError + | EffectConfig.ConfigError; + export type FlowProcessorStartEffect = Effect.Effect< void, - PubSubError | FlowRuntimeError | ProcessorLifecycleError, + FlowProcessorRunError, FlowProcessorRuntimeRequirements >; export interface FlowProcessorRuntime extends ProcessorRuntime< - PubSubError | FlowRuntimeError | ProcessorLifecycleError, + FlowProcessorRunError, FlowProcessorRuntimeRequirements - > { +> { readonly config: ProcessorConfig; readonly pubsub: PubSubBackend; - readonly configHandlers: ConfigHandler[]; + readonly configHandlers: ReadonlyArray< + EffectConfigHandler< + FlowProcessorRunError, + FlowProcessorRuntimeRequirements + > + >; readonly isRunning: () => boolean; - readonly registerConfigHandler: (handler: ConfigHandler) => void; + readonly registerConfigHandler: ( + handler: EffectConfigHandler< + FlowProcessorRunError, + FlowProcessorRuntimeRequirements + >, + ) => void; readonly registerSpecification: (spec: Spec) => void; readonly specifications: ReadonlyArray>; } @@ -103,7 +118,7 @@ export interface MakeFlowProcessorOptions { } const ConfigPushSchema = S.Struct({ - version: S.Number, + version: S.Finite, config: S.Record(S.String, S.Unknown), }); @@ -162,10 +177,8 @@ export function runFlowProcessorDefinitionScoped< if (consumer === null) { return Effect.void; } - return Effect.tryPromise({ - try: () => consumer.close(), - catch: (error) => pubSubError("close:config-push", error), - }).pipe( + return consumer.close.pipe( + Effect.mapError((error) => pubSubError("close:config-push", error)), Effect.catch((error) => Effect.logError(`[${options.id}] Failed to close config consumer`, { error: error.message, @@ -253,10 +266,9 @@ export function runFlowProcessorDefinitionScoped< return; } - const msg = yield* Effect.tryPromise({ - try: () => consumer.receive(2000), - catch: (error) => pubSubError("receive:config-push", error), - }); + const msg = yield* consumer.receive(2000).pipe( + Effect.mapError((error) => pubSubError("receive:config-push", error)), + ); if (msg === null) { return; } @@ -270,10 +282,9 @@ export function runFlowProcessorDefinitionScoped< yield* handler(push.config, push.version); } - yield* Effect.tryPromise({ - try: () => consumer.acknowledge(msg), - catch: (error) => pubSubError("acknowledge:config-push", error), - }); + yield* consumer.acknowledge(msg).pipe( + Effect.mapError((error) => pubSubError("acknowledge:config-push", error)), + ); }); const processNextConfigPushSafelyEffect = Effect.fn("FlowProcessor.processNextConfigPushSafely")(function* () { @@ -324,29 +335,19 @@ export function makeFlowProcessor( const specifications: Array> = [ ...(options.specifications ?? []), ]; - const compatibilityRuntime = ManagedRuntime.make(Layer.empty); let processor: FlowProcessorRuntime; const base: AsyncProcessorRuntime< - PubSubError | FlowRuntimeError | ProcessorLifecycleError, + FlowProcessorRunError, FlowProcessorRuntimeRequirements > = makeAsyncProcessor(config, { - runEffect: (runtime) => { - const configHandlers = runtime.configHandlers.map( - (handler): EffectConfigHandler => - (pushedConfig, version) => - Effect.tryPromise({ - try: () => handler(pushedConfig, version), - catch: (error) => pubSubError("config-handler", error), - }), - ); - return runFlowProcessorDefinitionScoped({ + runEffect: (runtime) => + runFlowProcessorDefinitionScoped({ id: runtime.config.id, pubsub: runtime.pubsub, specifications, - configHandlers, + configHandlers: runtime.configHandlers, isRunning: runtime.isRunning, - }); - }, + }), }); const makeStartEffect = (): FlowProcessorStartEffect => { @@ -381,7 +382,7 @@ export function makeFlowProcessor( get startEffect() { return makeStartEffect(); }, - start: (context) => compatibilityRuntime.runPromise(startProcessorEffect(context)), + start: (context) => startProcessorEffect(context), }; return processor; diff --git a/ts/packages/base/src/processor/flow.ts b/ts/packages/base/src/processor/flow.ts index 4bcbc4aa..7e03512a 100644 --- a/ts/packages/base/src/processor/flow.ts +++ b/ts/packages/base/src/processor/flow.ts @@ -4,7 +4,7 @@ * Python reference: trustgraph-base/trustgraph/base/flow.py */ -import { Config as EffectConfig, Context, Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; +import { Config as EffectConfig, Context, Effect, Exit, Scope } from "effect"; import * as MutableHashMap from "effect/MutableHashMap"; import * as O from "effect/Option"; import * as S from "effect/Schema"; @@ -15,6 +15,9 @@ import { flowResourceNotFoundError, type FlowParameterDecodeError, type FlowResourceNotFoundError, + type MessagingDeliveryError, + type MessagingLifecycleError, + type MessagingTimeoutError, type PubSubError, } from "../errors.js"; import { @@ -43,26 +46,26 @@ export interface FlowDefinition { } export interface FlowProducer { - readonly send: (id: string, message: T) => Promise; - readonly flush: () => Promise; - readonly stop: () => Promise; + readonly send: (id: string, message: T) => Effect.Effect; + readonly flush: Effect.Effect; + readonly stop: Effect.Effect; } export interface FlowConsumer { - readonly stop: () => Promise; + readonly stop: Effect.Effect; } -export interface FlowRequestOptions { +export interface FlowRequestOptions { readonly timeoutMs?: number; - readonly recipient?: (response: TRes) => Promise; + readonly recipient?: (response: TRes) => Effect.Effect; } export interface FlowRequestor { - readonly request: ( + readonly request: ( request: TReq, - options?: FlowRequestOptions, - ) => Promise; - readonly stop: () => Promise; + options?: FlowRequestOptions, + ) => Effect.Effect; + readonly stop: Effect.Effect; } type FlowParameterError = FlowResourceNotFoundError | FlowParameterDecodeError; @@ -71,19 +74,14 @@ export interface Flow { readonly name: string; readonly processorId: string; startEffect: Effect.Effect; - start: (context: Context.Context) => Promise; - stop: () => Promise; + start: (context: Context.Context) => Effect.Effect; + stop: Effect.Effect; stopEffect: Effect.Effect; - runInCompatibilityScopeEffect: ( + runInRuntimeScopeEffect: ( effect: Effect.Effect, runtimePubsub: PubSubBackend, context: Context.Context, ) => Effect.Effect; - runInCompatibilityScope: ( - effect: Effect.Effect, - runtimePubsub: PubSubBackend, - context: Context.Context, - ) => Promise; clearResources: () => void; registerProducer: (registerName: string, producer: EffectProducer) => void; registerConsumer: (registerName: string, consumer: EffectConsumer) => void; @@ -108,13 +106,15 @@ export interface Flow { (parameterName: string): Effect.Effect; }; producer: { - (producerSpec: ProducerSpec): FlowProducer; - (producerName: string): FlowProducer; + (producerSpec: ProducerSpec): Effect.Effect, FlowResourceNotFoundError>; + (producerName: string): Effect.Effect, FlowResourceNotFoundError>; }; - consumer: (consumerName: string) => FlowConsumer; + consumer: (consumerName: string) => Effect.Effect; requestor: { - (requestorSpec: RequestResponseSpec): FlowRequestor; - (requestorName: string): FlowRequestor; + ( + requestorSpec: RequestResponseSpec, + ): Effect.Effect, FlowResourceNotFoundError>; + (requestorName: string): Effect.Effect, FlowResourceNotFoundError>; }; parameter: { (parameterSpec: ParameterSpec): T; @@ -133,21 +133,20 @@ export function makeFlow( const consumers = MutableHashMap.empty(); const requestors = MutableHashMap.empty>(); const parameters = MutableHashMap.empty(); - let compatibilityScope: Scope.Closeable | null = null; - const compatibilityRuntime = ManagedRuntime.make(Layer.empty); + let runtimeScope: Scope.Closeable | null = null; - const ensureCompatibilityScopeEffect = Effect.fn("Flow.ensureCompatibilityScope")(function* () { - if (compatibilityScope !== null) { - return compatibilityScope; + const ensureRuntimeScopeEffect = Effect.fn("Flow.ensureRuntimeScope")(function* () { + if (runtimeScope !== null) { + return runtimeScope; } const scope = yield* Scope.make(); - compatibilityScope = scope; + runtimeScope = scope; return scope; }); - const toEffectRequestOptions = ( - options: FlowRequestOptions | undefined, - ): EffectRequestOptions | undefined => { + const toEffectRequestOptions = ( + options: FlowRequestOptions | undefined, + ): EffectRequestOptions | undefined => { if (options === undefined) { return undefined; } @@ -157,7 +156,7 @@ export function makeFlow( ...(recipient === undefined ? {} : { - recipient: (response: TRes) => Effect.promise(() => recipient(response)), + recipient, }), }; }; @@ -198,12 +197,6 @@ export function makeFlow( : Effect.succeed(producer); }; - const getProducer = (producerName: string): EffectProducer => { - const producer = O.getOrUndefined(MutableHashMap.get(producers, producerName)); - if (producer === undefined) throw flowResourceNotFoundError(name, "producer", producerName); - return producer; - }; - const getRequestorEffect = ( requestorName: string, ): Effect.Effect, FlowResourceNotFoundError> => { @@ -213,31 +206,21 @@ export function makeFlow( : Effect.succeed(requestor); }; - const getRequestor = ( - requestorName: string, - ): EffectRequestResponse => { - const requestor = O.getOrUndefined(MutableHashMap.get(requestors, requestorName)); - if (requestor === undefined) throw flowResourceNotFoundError(name, "requestor", requestorName); - return requestor; - }; - const toFlowProducer = (producer: EffectProducer): FlowProducer => ({ - send: (id, message) => compatibilityRuntime.runPromise(producer.send(id, message)), - flush: () => compatibilityRuntime.runPromise(producer.flush), - stop: () => compatibilityRuntime.runPromise(producer.flush.pipe(Effect.flatMap(() => producer.close))), + send: producer.send, + flush: producer.flush, + stop: producer.flush.pipe(Effect.flatMap(() => producer.close)), }); const toFlowRequestor = ( requestor: EffectRequestResponse, ): FlowRequestor => ({ request: (request, options) => - compatibilityRuntime.runPromise( - requestor.request( - request, - toEffectRequestOptions(options), - ), + requestor.request( + request, + toEffectRequestOptions(options), ), - stop: () => compatibilityRuntime.runPromise(requestor.stop), + stop: requestor.stop, }); function producerEffect( @@ -303,32 +286,26 @@ export function makeFlow( return decodeParameter(parameter, value); } - function producer(producerSpec: ProducerSpec): FlowProducer; - function producer(producerName: string): FlowProducer; + function producer(producerSpec: ProducerSpec): Effect.Effect, FlowResourceNotFoundError>; + function producer(producerName: string): Effect.Effect, FlowResourceNotFoundError>; function producer(producer: string | ProducerSpec) { if (typeof producer === "string") { - return toFlowProducer(getProducer(producer)); + return getProducerEffect(producer).pipe(Effect.map(toFlowProducer)); } - if (!MutableHashMap.has(producers, producer.name)) { - throw flowResourceNotFoundError(name, "producer", producer.name); - } - return toFlowProducer(compatibilityRuntime.runSync(producer.producerEffect(flow))); + return producer.producerEffect(flow).pipe(Effect.map(toFlowProducer)); } function requestor( requestorSpec: RequestResponseSpec, - ): FlowRequestor; - function requestor(requestorName: string): FlowRequestor; + ): Effect.Effect, FlowResourceNotFoundError>; + function requestor(requestorName: string): Effect.Effect, FlowResourceNotFoundError>; function requestor( requestor: string | RequestResponseSpec, ) { if (typeof requestor === "string") { - return toFlowRequestor(getRequestor(requestor)); + return getRequestorEffect(requestor).pipe(Effect.map(toFlowRequestor)); } - if (!MutableHashMap.has(requestors, requestor.name)) { - throw flowResourceNotFoundError(name, "requestor", requestor.name); - } - return toFlowRequestor(compatibilityRuntime.runSync(requestor.requestorEffect(flow))); + return requestor.requestorEffect(flow).pipe(Effect.map(toFlowRequestor)); } const flow: Flow = { @@ -339,33 +316,31 @@ export function makeFlow( yield* spec.addEffect(flow, definition); } }).pipe(Effect.withSpan("Flow.startEffect")), - start(context: Context.Context): Promise { - return compatibilityRuntime.runPromise( - Effect.gen(function* () { - if (compatibilityScope !== null) { - yield* flow.stopEffect; - } - yield* flow.runInCompatibilityScopeEffect(flow.startEffect, pubsub, context); - }), - ); + start(context: Context.Context): Effect.Effect { + return Effect.gen(function* () { + if (runtimeScope !== null) { + yield* flow.stop; + } + yield* flow.runInRuntimeScopeEffect(flow.startEffect, pubsub, context); + }); }, - stop(): Promise { - return compatibilityRuntime.runPromise(flow.stopEffect); + get stop() { + return flow.stopEffect; }, stopEffect: Effect.gen(function* () { - const scope = compatibilityScope; - compatibilityScope = null; + const scope = runtimeScope; + runtimeScope = null; if (scope !== null) { yield* Scope.close(scope, Exit.void); } flow.clearResources(); }).pipe(Effect.withSpan("Flow.stopEffect")), - runInCompatibilityScopeEffect: Effect.fn("Flow.runInCompatibilityScopeEffect")(function* ( + runInRuntimeScopeEffect: Effect.fn("Flow.runInRuntimeScopeEffect")(function* ( effect: Effect.Effect, runtimePubsub: PubSubBackend, context: Context.Context, ) { - const scope = yield* ensureCompatibilityScopeEffect(); + const scope = yield* ensureRuntimeScopeEffect(); const pubsubService = makePubSubService(runtimePubsub); const messagingConfig = yield* loadMessagingRuntimeConfig(); return yield* Effect.provide( @@ -381,13 +356,6 @@ export function makeFlow( context, ); }), - runInCompatibilityScope( - effect: Effect.Effect, - runtimePubsub: PubSubBackend, - context: Context.Context, - ): Promise { - return compatibilityRuntime.runPromise(flow.runInCompatibilityScopeEffect(effect, runtimePubsub, context)); - }, clearResources(): void { MutableHashMap.clear(producers); MutableHashMap.clear(consumers); @@ -416,12 +384,12 @@ export function makeFlow( requestorEffect, parameterEffect, producer, - consumer(consumerName: string): FlowConsumer { - const c = O.getOrUndefined(MutableHashMap.get(consumers, consumerName)); - if (c === undefined) throw flowResourceNotFoundError(name, "consumer", consumerName); - return { - stop: () => compatibilityRuntime.runPromise(c.stop), - }; + consumer(consumerName: string): Effect.Effect { + return flow.consumerEffect(consumerName).pipe( + Effect.map((c) => ({ + stop: c.stop, + })), + ); }, requestor, parameter, diff --git a/ts/packages/base/src/processor/program.ts b/ts/packages/base/src/processor/program.ts index 83bbe351..b8668ed7 100644 --- a/ts/packages/base/src/processor/program.ts +++ b/ts/packages/base/src/processor/program.ts @@ -7,7 +7,6 @@ import { Config as EffectConfig, Effect, Layer } from "effect"; import { - processorLifecycleError, type FlowRuntimeError, type ProcessorLifecycleError, type PubSubError, @@ -83,10 +82,7 @@ export const runProcessorScoped = Effect.fn("runProcessorScoped")(function* < const processor = make(runtimeConfig); yield* Effect.addFinalizer(() => - Effect.tryPromise({ - try: () => processor.stop(), - catch: (error) => processorLifecycleError(config.id, "stop", error), - }).pipe( + processor.stop.pipe( Effect.catch((error) => Effect.logError("[Processor] Failed to stop processor", { error: error.message, diff --git a/ts/packages/base/src/schema/messages.ts b/ts/packages/base/src/schema/messages.ts index 1618ca8c..5f5d0fc7 100644 --- a/ts/packages/base/src/schema/messages.ts +++ b/ts/packages/base/src/schema/messages.ts @@ -11,7 +11,7 @@ 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 NumberArray = MutableArray(S.Finite); const NumberArrays = MutableArray(NumberArray); // Text completion @@ -19,7 +19,7 @@ export const TextCompletionRequest = S.Struct({ system: S.String, prompt: S.String, model: S.optionalKey(S.String), - temperature: S.optionalKey(S.Number), + temperature: S.optionalKey(S.Finite), streaming: S.optionalKey(S.Boolean), }); export type TextCompletionRequest = typeof TextCompletionRequest.Type; @@ -27,8 +27,8 @@ export type TextCompletionRequest = typeof TextCompletionRequest.Type; export const TextCompletionResponse = S.Struct({ response: S.String, model: S.optionalKey(S.String), - inToken: S.optionalKey(S.Number), - outToken: S.optionalKey(S.Number), + inToken: S.optionalKey(S.Finite), + outToken: S.optionalKey(S.Finite), error: S.optionalKey(TgError), endOfStream: S.optionalKey(S.Boolean), }); @@ -51,10 +51,10 @@ export type EmbeddingsResponse = typeof EmbeddingsResponse.Type; 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), + entityLimit: S.optionalKey(S.Finite), + tripleLimit: S.optionalKey(S.Finite), + maxSubgraphSize: S.optionalKey(S.Finite), + maxPathLength: S.optionalKey(S.Finite), streaming: S.optionalKey(S.Boolean), }); export type GraphRagRequest = typeof GraphRagRequest.Type; @@ -126,7 +126,7 @@ export const TriplesQueryRequest = S.Struct({ p: S.optionalKey(Term), o: S.optionalKey(Term), collection: S.optionalKey(S.String), - limit: S.optionalKey(S.Number), + limit: S.optionalKey(S.Finite), }); export type TriplesQueryRequest = typeof TriplesQueryRequest.Type; @@ -140,7 +140,7 @@ export type TriplesQueryResponse = typeof TriplesQueryResponse.Type; export const GraphEmbeddingsRequest = S.Struct({ vectors: NumberArrays, user: S.optionalKey(S.String), - limit: S.optionalKey(S.Number), + limit: S.optionalKey(S.Finite), collection: S.optionalKey(S.String), }); export type GraphEmbeddingsRequest = typeof GraphEmbeddingsRequest.Type; @@ -154,7 +154,7 @@ export type GraphEmbeddingsResponse = typeof GraphEmbeddingsResponse.Type; // Document embeddings query export const DocumentEmbeddingsRequest = S.Struct({ vectors: NumberArrays, - limit: S.optionalKey(S.Number), + limit: S.optionalKey(S.Finite), user: S.optionalKey(S.String), collection: S.optionalKey(S.String), }); @@ -162,7 +162,7 @@ export type DocumentEmbeddingsRequest = typeof DocumentEmbeddingsRequest.Type; const DocumentEmbeddingChunk = S.Struct({ chunkId: S.String, - score: S.Number, + score: S.Finite, content: S.optionalKey(S.String), }); @@ -193,7 +193,7 @@ export const ConfigRequest = S.StructWithRest( export type ConfigRequest = typeof ConfigRequest.Type; export const ConfigResponse = S.Struct({ - version: S.optionalKey(S.Number), + version: S.optionalKey(S.Finite), values: S.optionalKey(S.Unknown), directory: S.optionalKey(StringArray), config: S.optionalKey(UnknownRecord), @@ -266,7 +266,7 @@ export type Triples = typeof Triples.Type; // Document metadata export const DocumentMetadata = S.Struct({ id: S.String, - time: S.Number, + time: S.Finite, kind: S.String, title: S.String, comments: S.String, @@ -284,7 +284,7 @@ export const ProcessingMetadata = S.Struct({ id: S.String, documentId: S.String, "document-id": S.optionalKey(S.String), - time: S.Number, + time: S.Finite, flow: S.String, user: S.String, collection: S.String, @@ -329,10 +329,10 @@ export const LibrarianRequest = S.StructWithRest( content: S.optionalKey(S.String), user: S.optionalKey(S.String), collection: S.optionalKey(S.String), - "total-size": S.optionalKey(S.Number), - "chunk-size": S.optionalKey(S.Number), + "total-size": S.optionalKey(S.Finite), + "chunk-size": S.optionalKey(S.Finite), "upload-id": S.optionalKey(S.String), - "chunk-index": S.optionalKey(S.Number), + "chunk-index": S.optionalKey(S.Finite), }), [UnknownRecord], ); @@ -342,10 +342,10 @@ const UploadSessionInfo = S.Struct({ "upload-id": S.String, "document-id": S.String, "document-metadata-json": S.String, - "total-size": S.Number, - "chunk-size": S.Number, - "total-chunks": S.Number, - "chunks-received": S.Number, + "total-size": S.Finite, + "chunk-size": S.Finite, + "total-chunks": S.Finite, + "chunks-received": S.Finite, "created-at": S.String, }); @@ -363,12 +363,12 @@ export const LibrarianResponse = S.StructWithRest( "document-id": S.optionalKey(S.String), "object-id": S.optionalKey(S.String), "upload-id": S.optionalKey(S.String), - "chunk-size": S.optionalKey(S.Number), - "chunk-index": S.optionalKey(S.Number), - "total-chunks": S.optionalKey(S.Number), - "chunks-received": S.optionalKey(S.Number), - "bytes-received": S.optionalKey(S.Number), - "total-bytes": S.optionalKey(S.Number), + "chunk-size": S.optionalKey(S.Finite), + "chunk-index": S.optionalKey(S.Finite), + "total-chunks": S.optionalKey(S.Finite), + "chunks-received": S.optionalKey(S.Finite), + "bytes-received": S.optionalKey(S.Finite), + "total-bytes": S.optionalKey(S.Finite), "upload-state": S.optionalKey(S.String), "received-chunks": S.optionalKey(NumberArray), "missing-chunks": S.optionalKey(NumberArray), diff --git a/ts/packages/base/src/schema/primitives.ts b/ts/packages/base/src/schema/primitives.ts index 21d74000..a0c6bb6a 100644 --- a/ts/packages/base/src/schema/primitives.ts +++ b/ts/packages/base/src/schema/primitives.ts @@ -84,16 +84,16 @@ export type RowSchema = typeof RowSchema.Type; export const LlmResult = S.Struct({ text: S.String, - inToken: S.Number, - outToken: S.Number, + inToken: S.Finite, + outToken: S.Finite, 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), + inToken: S.NullOr(S.Finite), + outToken: S.NullOr(S.Finite), model: S.String, isFinal: S.Boolean, }); diff --git a/ts/packages/base/src/services/llm-service.ts b/ts/packages/base/src/services/llm-service.ts index 5d2127b0..e30ee396 100644 --- a/ts/packages/base/src/services/llm-service.ts +++ b/ts/packages/base/src/services/llm-service.ts @@ -32,19 +32,19 @@ export class LlmServiceError extends S.TaggedErrorClass()( }, ) {} -export interface LlmProvider { +export interface LlmProvider { readonly generateContent: ( system: string, prompt: string, model?: string, temperature?: number, - ) => Promise; + ) => Effect.Effect; readonly generateContentStream: ( system: string, prompt: string, model?: string, temperature?: number, - ) => AsyncGenerator; + ) => Stream.Stream; readonly supportsStreaming: () => boolean; } @@ -60,7 +60,7 @@ export interface LlmServiceShape { prompt: string, model?: string, temperature?: number, - ) => AsyncGenerator; + ) => Stream.Stream; readonly supportsStreaming: () => boolean; } @@ -74,24 +74,28 @@ const llmServiceError = (operation: string, cause: unknown) => message: errorMessage(cause), }); -export const makeLlmServiceShape = (provider: LlmProvider): LlmServiceShape => ({ +export const makeLlmServiceShape = ( + provider: LlmProvider, +): LlmServiceShape => ({ generateContent: Effect.fn("Llm.generateContent")(( system, prompt, model, temperature, ) => - Effect.tryPromise({ - try: () => provider.generateContent(system, prompt, model, temperature), - catch: (cause) => llmServiceError("generate-content", cause), - }), + provider.generateContent(system, prompt, model, temperature).pipe( + Effect.mapError((cause) => llmServiceError("generate-content", cause)), + ), ), generateContentStream: ( system, prompt, model, temperature, - ) => provider.generateContentStream(system, prompt, model, temperature), + ) => + provider.generateContentStream(system, prompt, model, temperature).pipe( + Stream.mapError((cause) => llmServiceError("generate-content-stream", cause)), + ), supportsStreaming: () => provider.supportsStreaming(), }); @@ -137,14 +141,11 @@ const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(func ) => Effect.Effect; }, ) { - yield* Stream.fromAsyncIterable( - llm.generateContentStream( - msg.system, - msg.prompt, - msg.model, - msg.temperature, - ), - (cause) => llmServiceError("generate-content-stream", cause), + yield* llm.generateContentStream( + msg.system, + msg.prompt, + msg.model, + msg.temperature, ).pipe( Stream.runForEach((chunk) => responseProducer.send(requestId, chunkToResponse(chunk)), @@ -215,12 +216,14 @@ export const makeLlmSpecs = (): ReadonlyArray> => [ makeParameterSpec("temperature"), ]; -export type LlmService = FlowProcessorRuntime & LlmProvider; +export type LlmService = + & FlowProcessorRuntime + & LlmProvider; -export function makeLlmService( +export function makeLlmService( config: ProcessorConfig, - provider: LlmProvider, -): LlmService { + provider: LlmProvider, +): LlmService { const service = makeFlowProcessor(config, { specifications: makeLlmSpecs(), provide: (effect) => diff --git a/ts/packages/base/src/spec/consumer-spec.ts b/ts/packages/base/src/spec/consumer-spec.ts index 27bab751..89807ecf 100644 --- a/ts/packages/base/src/spec/consumer-spec.ts +++ b/ts/packages/base/src/spec/consumer-spec.ts @@ -5,24 +5,17 @@ */ import { Effect } from "effect"; -import * as S from "effect/Schema"; import type { Spec } from "./types.js"; import type { SpecRuntimeRequirements } from "./types.js"; import type { Flow, FlowDefinition } from "../processor/flow.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); - declare const ConsumerSpecType: unique symbol; export interface ConsumerSpec extends Spec { @@ -62,26 +55,5 @@ export function makeConsumerSpec( return { name, addEffect, - add: (flow, pubsub, definition, context) => - flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context), }; } - -export function makeConsumerSpecFromPromise( - name: string, - handler: MessageHandler, - concurrency = 1, -): ConsumerSpec { - return makeConsumerSpec( - 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, - ); -} diff --git a/ts/packages/base/src/spec/index.ts b/ts/packages/base/src/spec/index.ts index 2ceeae1c..9e3a9e16 100644 --- a/ts/packages/base/src/spec/index.ts +++ b/ts/packages/base/src/spec/index.ts @@ -1,5 +1,5 @@ export type { Spec, SpecRuntimeError, SpecRuntimeRequirements } from "./types.js"; -export { makeConsumerSpec, makeConsumerSpecFromPromise, type ConsumerSpec } from "./consumer-spec.js"; +export { makeConsumerSpec, type ConsumerSpec } from "./consumer-spec.js"; export { makeProducerSpec, type ProducerSpec } from "./producer-spec.js"; export { makeParameterSpec, type ParameterSpec } from "./parameter-spec.js"; export { makeRequestResponseSpec, type RequestResponseSpec } from "./request-response-spec.js"; diff --git a/ts/packages/base/src/spec/parameter-spec.ts b/ts/packages/base/src/spec/parameter-spec.ts index 9d2106f0..84468d61 100644 --- a/ts/packages/base/src/spec/parameter-spec.ts +++ b/ts/packages/base/src/spec/parameter-spec.ts @@ -4,9 +4,8 @@ * Python reference: trustgraph-base/trustgraph/base/parameter_spec.py */ -import { Effect, type Context } from "effect"; +import { Effect } from "effect"; import * as S from "effect/Schema"; -import type { PubSubBackend } from "../backend/types.js"; import type { SpecRuntimeRequirements } from "./types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; import type { PubSubError } from "../errors.js"; @@ -23,12 +22,6 @@ export interface ParameterSpec { flow: Flow, definition: FlowDefinition, ) => Effect.Effect; - readonly add: ( - flow: Flow, - pubsub: PubSubBackend, - definition: FlowDefinition, - context: Context.Context, - ) => Promise; } export function makeParameterSpec(name: string): ParameterSpec; @@ -51,12 +44,5 @@ export function makeParameterSpec( name, schema: parameterSchema, addEffect, - add: ( - flow: Flow, - pubsub: PubSubBackend, - definition: FlowDefinition, - context: Context.Context, - ) => - flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context), }; } diff --git a/ts/packages/base/src/spec/producer-spec.ts b/ts/packages/base/src/spec/producer-spec.ts index 218060f6..27c6fcaf 100644 --- a/ts/packages/base/src/spec/producer-spec.ts +++ b/ts/packages/base/src/spec/producer-spec.ts @@ -4,10 +4,9 @@ * Python reference: trustgraph-base/trustgraph/base/producer_spec.py */ -import { Effect, type Context } from "effect"; +import { Effect } from "effect"; import type { SpecRuntimeRequirements } from "./types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; -import type { PubSubBackend } from "../backend/types.js"; import { flowResourceNotFoundError, type FlowResourceNotFoundError, @@ -27,12 +26,6 @@ export interface ProducerSpec { flow: Flow, definition: FlowDefinition, ) => Effect.Effect; - readonly add: ( - flow: Flow, - pubsub: PubSubBackend, - definition: FlowDefinition, - context: Context.Context, - ) => Promise; readonly producerEffect: ( flow: Flow, ) => Effect.Effect, FlowResourceNotFoundError>; @@ -84,7 +77,5 @@ export function makeProducerSpec(name: string): ProducerSpec { name, producerEffect, addEffect, - add: (flow, pubsub, definition, context) => - flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context), }; } diff --git a/ts/packages/base/src/spec/request-response-spec.ts b/ts/packages/base/src/spec/request-response-spec.ts index 7ac34914..fc9af214 100644 --- a/ts/packages/base/src/spec/request-response-spec.ts +++ b/ts/packages/base/src/spec/request-response-spec.ts @@ -7,10 +7,9 @@ * Python reference: trustgraph-base/trustgraph/base/prompt_client_spec.py */ -import { Effect, type Context } from "effect"; +import { Effect } from "effect"; import type { SpecRuntimeRequirements } from "./types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; -import type { PubSubBackend } from "../backend/types.js"; import { flowResourceNotFoundError, type FlowResourceNotFoundError, @@ -33,12 +32,6 @@ export interface RequestResponseSpec { flow: Flow, definition: FlowDefinition, ) => Effect.Effect; - readonly add: ( - flow: Flow, - pubsub: PubSubBackend, - definition: FlowDefinition, - context: Context.Context, - ) => Promise; readonly requestorEffect: ( flow: Flow, ) => Effect.Effect, FlowResourceNotFoundError>; @@ -99,7 +92,5 @@ export function makeRequestResponseSpec( name, requestorEffect, addEffect, - add: (flow, pubsub, definition, context) => - flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context), }; } diff --git a/ts/packages/base/src/spec/types.ts b/ts/packages/base/src/spec/types.ts index 65b5e8bc..2c5ea608 100644 --- a/ts/packages/base/src/spec/types.ts +++ b/ts/packages/base/src/spec/types.ts @@ -4,8 +4,7 @@ * Python reference: trustgraph-base/trustgraph/base/spec.py and siblings */ -import type { Context, Effect, Scope } from "effect"; -import type { PubSubBackend } from "../backend/types.js"; +import type { Effect, Scope } from "effect"; import type { ConsumerFactory, ProducerFactory, @@ -28,10 +27,4 @@ export interface Spec { flow: Flow, definition: FlowDefinition, ): Effect.Effect; - add( - flow: Flow, - pubsub: PubSubBackend, - definition: FlowDefinition, - context: Context.Context, - ): Promise; } diff --git a/ts/packages/cli/package.json b/ts/packages/cli/package.json index e422cbd1..20589468 100644 --- a/ts/packages/cli/package.json +++ b/ts/packages/cli/package.json @@ -12,21 +12,20 @@ "test": "bunx --bun vitest run --passWithNoTests --exclude=dist/**" }, "dependencies": { + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", - "commander": "^13.1.0", - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", "ws": "^8.18.0" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/ws": "^8.5.0", "typescript": "^5.8.0", "vitest": "^4.1.6" diff --git a/ts/packages/cli/src/commands/agent.ts b/ts/packages/cli/src/commands/agent.ts index 9b51e473..96acaf4e 100644 --- a/ts/packages/cli/src/commands/agent.ts +++ b/ts/packages/cli/src/commands/agent.ts @@ -4,21 +4,19 @@ * Python reference: trustgraph-cli/trustgraph/cli/invoke_agent.py */ -import type { Command } from "commander"; import { Effect } from "effect"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; import { cliCommandError, withSocket } from "./util.js"; -export function registerAgentCommands(program: Command): void { - program - .command("agent") - .description("Ask the TrustGraph agent a question") - .argument("", "Question to ask") - .action((question: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket, opts) => - Effect.gen(function* () { +export const agentCommand = Command.make("agent", { + question: Argument.string("question").pipe(Argument.withDescription("Question to ask")), +}, ({ question }) => + withSocket((socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - yield* Effect.callback>((resume) => { + yield* Effect.callback>((resume) => { flow.agent( question, (chunk) => { @@ -40,7 +38,6 @@ export function registerAgentCommands(program: Command): void { (err) => resume(Effect.fail(cliCommandError("agent", err))), ); }); - }), - )), - ); -} + }), + ), +).pipe(Command.withDescription("Ask the TrustGraph agent a question")); diff --git a/ts/packages/cli/src/commands/config.ts b/ts/packages/cli/src/commands/config.ts index 2a462bb7..e910ffd1 100644 --- a/ts/packages/cli/src/commands/config.ts +++ b/ts/packages/cli/src/commands/config.ts @@ -4,38 +4,29 @@ * Python reference: trustgraph-cli/trustgraph/cli/show_config.py etc. */ -import type { Command } from "commander"; import { Effect } from "effect"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; import { cliCommandError, withSocket, writeJson } from "./util.js"; -export function registerConfigCommands(program: Command): void { - const config = program - .command("config") - .description("Configuration management"); - - config - .command("show") - .description("Show current configuration") - .action((_opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const show = Command.make("show", {}, () => + withSocket((socket) => + Effect.gen(function* () { const cfg = socket.config(); - const resp = yield* Effect.tryPromise({ - try: () => cfg.getConfigAll(), - catch: (error) => cliCommandError("config.show", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => cfg.getConfigAll(), + catch: (error) => cliCommandError("config.show", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Show current configuration")); - config - .command("get") - .description("Get a configuration value") - .argument("", "Config key (format: type/key)") - .action((key: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const get = Command.make("get", { + key: Argument.string("key").pipe(Argument.withDescription("Config key (format: type/key)")), +}, ({ key }) => + withSocket((socket) => + Effect.gen(function* () { const cfg = socket.config(); // Support "type/key" format; fall back to using the whole string as key const parts = key.split("/"); @@ -43,74 +34,75 @@ export function registerConfigCommands(program: Command): void { parts.length >= 2 ? { type: parts[0], key: parts.slice(1).join("/") } : { type: "config", key }; - const resp = yield* Effect.tryPromise({ - try: () => cfg.getConfig([configKey]), - catch: (error) => cliCommandError("config.get", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => cfg.getConfig([configKey]), + catch: (error) => cliCommandError("config.get", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Get a configuration value")); - config - .command("set") - .description("Set a configuration value") - .argument("", "Config key (format: type/key)") - .argument("", "Config value (JSON)") - .action((key: string, value: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const set = Command.make("set", { + key: Argument.string("key").pipe(Argument.withDescription("Config key (format: type/key)")), + value: Argument.string("value").pipe(Argument.withDescription("Config value (JSON)")), +}, ({ key, value }) => + withSocket((socket) => + Effect.gen(function* () { const cfg = socket.config(); const parts = key.split("/"); const configEntry = parts.length >= 2 ? { type: parts[0], key: parts.slice(1).join("/"), value } : { type: "config", key, value }; - const resp = yield* Effect.tryPromise({ - try: () => cfg.putConfig([configEntry]), - catch: (error) => cliCommandError("config.set", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => cfg.putConfig([configEntry]), + catch: (error) => cliCommandError("config.set", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Set a configuration value")); - config - .command("list") - .description("List configuration keys for a type") - .argument("[type]", "Config type to list", "config") - .action((type: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const list = Command.make("list", { + type: Argument.string("type").pipe( + Argument.withDescription("Config type to list"), + Argument.withDefault("config"), + ), +}, ({ type }) => + withSocket((socket) => + Effect.gen(function* () { const cfg = socket.config(); - const resp = yield* Effect.tryPromise({ - try: () => cfg.list(type), - catch: (error) => cliCommandError("config.list", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => cfg.list(type), + catch: (error) => cliCommandError("config.list", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("List configuration keys for a type")); - config - .command("delete") - .description("Delete a configuration entry") - .argument("", "Config key (format: type/key)") - .action((key: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const deleteCommand = Command.make("delete", { + key: Argument.string("key").pipe(Argument.withDescription("Config key (format: type/key)")), +}, ({ key }) => + withSocket((socket) => + Effect.gen(function* () { const cfg = socket.config(); const parts = key.split("/"); const configKey = parts.length >= 2 ? { type: parts[0], key: parts.slice(1).join("/") } : { type: "config", key }; - const resp = yield* Effect.tryPromise({ - try: () => cfg.deleteConfig(configKey), - catch: (error) => cliCommandError("config.delete", error), - }); - yield* writeJson(resp); - }), - )), - ); -} + const resp = yield* Effect.tryPromise({ + try: () => cfg.deleteConfig(configKey), + catch: (error) => cliCommandError("config.delete", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Delete a configuration entry")); + +export const configCommand = Command.make("config").pipe( + Command.withDescription("Configuration management"), + Command.withSubcommands([show, get, set, list, deleteCommand]), +); diff --git a/ts/packages/cli/src/commands/embeddings.ts b/ts/packages/cli/src/commands/embeddings.ts index 78a3be86..e469e9ca 100644 --- a/ts/packages/cli/src/commands/embeddings.ts +++ b/ts/packages/cli/src/commands/embeddings.ts @@ -4,25 +4,25 @@ * Generate text embeddings using the configured embedding model. */ -import type { Command } from "commander"; import { Effect } from "effect"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; import { cliCommandError, withSocket, writeJson } from "./util.js"; -export function registerEmbeddingsCommands(program: Command): void { - program - .command("embeddings") - .description("Generate text embeddings") - .argument("", "Text(s) to embed") - .action((texts: string[], _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket, opts) => - Effect.gen(function* () { +export const embeddingsCommand = Command.make("embeddings", { + texts: Argument.string("text").pipe( + Argument.withDescription("Text(s) to embed"), + Argument.variadic({ min: 1 }), + ), +}, ({ texts }) => + withSocket((socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - const vectors = yield* Effect.tryPromise({ - try: () => flow.embeddings(texts), - catch: (error) => cliCommandError("embeddings", error), - }); - yield* writeJson(vectors); - }), - )), - ); -} + const vectors = yield* Effect.tryPromise({ + try: () => flow.embeddings(Array.from(texts)), + catch: (error) => cliCommandError("embeddings", error), + }); + yield* writeJson(vectors); + }), + ), +).pipe(Command.withDescription("Generate text embeddings")); diff --git a/ts/packages/cli/src/commands/flow.ts b/ts/packages/cli/src/commands/flow.ts index a2631213..920d3fab 100644 --- a/ts/packages/cli/src/commands/flow.ts +++ b/ts/packages/cli/src/commands/flow.ts @@ -4,96 +4,99 @@ * Python reference: trustgraph-cli/trustgraph/cli/start_flow.py, stop_flow.py, etc. */ -import type { Command } from "commander"; import { Effect } from "effect"; import * as S from "effect/Schema"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; import { cliCommandError, withSocket, writeJson } from "./util.js"; -export function registerFlowCommands(program: Command): void { - const flow = program - .command("flow") - .description("Flow management"); - - flow - .command("list") - .description("List active flows") - .action((_opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const list = Command.make("list", {}, () => + withSocket((socket) => + Effect.gen(function* () { const flows = socket.flows(); - const ids = yield* Effect.tryPromise({ - try: () => flows.getFlows(), - catch: (error) => cliCommandError("flow.list", error), - }); - yield* writeJson(ids); - }), - )), - ); + const ids = yield* Effect.tryPromise({ + try: () => flows.getFlows(), + catch: (error) => cliCommandError("flow.list", error), + }); + yield* writeJson(ids); + }), + ), +).pipe(Command.withDescription("List active flows")); - flow - .command("get") - .description("Get a flow definition") - .argument("", "Flow ID") - .action((id: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const get = Command.make("get", { + id: Argument.string("id").pipe(Argument.withDescription("Flow ID")), +}, ({ id }) => + withSocket((socket) => + Effect.gen(function* () { const flows = socket.flows(); - const def = yield* Effect.tryPromise({ - try: () => flows.getFlow(id), - catch: (error) => cliCommandError("flow.get", error), - }); - yield* writeJson(def); - }), - )), - ); + const def = yield* Effect.tryPromise({ + try: () => flows.getFlow(id), + catch: (error) => cliCommandError("flow.get", error), + }); + yield* writeJson(def); + }), + ), +).pipe(Command.withDescription("Get a flow definition")); - flow - .command("start") - .description("Start a flow") - .argument("", "Flow ID") - .requiredOption("-b, --blueprint ", "Blueprint name") - .option("-d, --description ", "Flow description", "") - .option("-p, --parameters ", "Parameters as JSON") - .action((id: string, cmdOpts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const start = Command.make("start", { + id: Argument.string("id").pipe(Argument.withDescription("Flow ID")), + blueprint: Flag.string("blueprint").pipe( + Flag.withAlias("b"), + Flag.withDescription("Blueprint name"), + ), + description: Flag.string("description").pipe( + Flag.withAlias("d"), + Flag.withDescription("Flow description"), + Flag.withDefault(""), + ), + parameters: Flag.string("parameters").pipe( + Flag.withAlias("p"), + Flag.withDescription("Parameters as JSON"), + Flag.optional, + ), +}, ({ id, blueprint, description, parameters }) => + withSocket((socket) => + Effect.gen(function* () { const flows = socket.flows(); - const rawParameters = cmdOpts.parameters as string | undefined; - const params = rawParameters !== undefined && rawParameters.length > 0 + const rawParameters = parameters._tag === "Some" ? parameters.value : undefined; + const params = rawParameters !== undefined && rawParameters.length > 0 ? yield* S.decodeUnknownEffect(S.UnknownFromJsonString)(rawParameters).pipe( Effect.flatMap(S.decodeUnknownEffect(S.Record(S.String, S.Unknown))), Effect.mapError((error) => cliCommandError("flow.start.parameters", error)), ) : undefined; - const resp = yield* Effect.tryPromise({ - try: () => - flows.startFlow( - id, - cmdOpts.blueprint as string, - cmdOpts.description as string, - params, - ), - catch: (error) => cliCommandError("flow.start", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => + flows.startFlow( + id, + blueprint, + description, + params, + ), + catch: (error) => cliCommandError("flow.start", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Start a flow")); - flow - .command("stop") - .description("Stop a flow") - .argument("", "Flow ID") - .action((id: string, _opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const stop = Command.make("stop", { + id: Argument.string("id").pipe(Argument.withDescription("Flow ID")), +}, ({ id }) => + withSocket((socket) => + Effect.gen(function* () { const flows = socket.flows(); - const resp = yield* Effect.tryPromise({ - try: () => flows.stopFlow(id), - catch: (error) => cliCommandError("flow.stop", error), - }); - yield* writeJson(resp); - }), - )), - ); -} + const resp = yield* Effect.tryPromise({ + try: () => flows.stopFlow(id), + catch: (error) => cliCommandError("flow.stop", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Stop a flow")); + +export const flowCommand = Command.make("flow").pipe( + Command.withDescription("Flow management"), + Command.withSubcommands([list, get, start, stop]), +); diff --git a/ts/packages/cli/src/commands/graph-rag.ts b/ts/packages/cli/src/commands/graph-rag.ts index 32ac5ff8..cb4e2df6 100644 --- a/ts/packages/cli/src/commands/graph-rag.ts +++ b/ts/packages/cli/src/commands/graph-rag.ts @@ -4,65 +4,72 @@ * Python reference: trustgraph-cli/trustgraph/cli/invoke_graph_rag.py */ -import type { Command } from "commander"; import { Effect } from "effect"; +import * as O from "effect/Option"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; import { cliCommandError, withSocket, writeLine } from "./util.js"; -export function registerGraphRagCommands(program: Command): void { - program - .command("graph-rag") - .description("Query the knowledge graph using RAG") - .argument("", "Natural language query") - .option("--entity-limit ", "Max entities", "50") - .option("--triple-limit ", "Max triples per entity", "30") - .option("--collection ", "Collection name") - .action((query: string, cmdOpts, cmd) => - Effect.runPromise(withSocket(cmd, (socket, opts) => - Effect.gen(function* () { +export const graphRagCommand = Command.make("graph-rag", { + query: Argument.string("query").pipe(Argument.withDescription("Natural language query")), + entityLimit: Flag.integer("entity-limit").pipe( + Flag.withDescription("Max entities"), + Flag.withDefault(50), + ), + tripleLimit: Flag.integer("triple-limit").pipe( + Flag.withDescription("Max triples per entity"), + Flag.withDefault(30), + ), + collection: Flag.string("collection").pipe( + Flag.withDescription("Collection name"), + Flag.optional, + ), +}, ({ query, entityLimit, tripleLimit, collection }) => + withSocket((socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - const collection = cmdOpts.collection as string | undefined; - const response = yield* Effect.tryPromise({ - try: () => - flow.graphRag( - query, - { - entityLimit: parseInt(cmdOpts.entityLimit, 10), - tripleLimit: parseInt(cmdOpts.tripleLimit, 10), - }, - collection, - ), - catch: (error) => cliCommandError("graph-rag", error), - }); - yield* writeLine(response); - }), - )), - ); + const response = yield* Effect.tryPromise({ + try: () => + flow.graphRag( + query, + { + entityLimit, + tripleLimit, + }, + O.getOrUndefined(collection), + ), + catch: (error) => cliCommandError("graph-rag", error), + }); + yield* writeLine(response); + }), + ), +).pipe(Command.withDescription("Query the knowledge graph using RAG")); - program - .command("document-rag") - .description("Query documents using RAG") - .argument("", "Natural language query") - .option("--doc-limit ", "Max documents", "20") - .option("--collection ", "Collection name") - .action((query: string, cmdOpts, cmd) => - Effect.runPromise(withSocket(cmd, (socket, opts) => - Effect.gen(function* () { +export const documentRagCommand = Command.make("document-rag", { + query: Argument.string("query").pipe(Argument.withDescription("Natural language query")), + docLimit: Flag.integer("doc-limit").pipe( + Flag.withDescription("Max documents"), + Flag.withDefault(20), + ), + collection: Flag.string("collection").pipe( + Flag.withDescription("Collection name"), + Flag.optional, + ), +}, ({ query, docLimit, collection }) => + withSocket((socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - const docLimit = cmdOpts.docLimit as string | undefined; - const collection = cmdOpts.collection as string | undefined; - const response = yield* Effect.tryPromise({ - try: () => - flow.documentRag( - query, - docLimit !== undefined && docLimit.length > 0 - ? parseInt(docLimit, 10) - : undefined, - collection, - ), - catch: (error) => cliCommandError("document-rag", error), - }); - yield* writeLine(response); - }), - )), - ); -} + const response = yield* Effect.tryPromise({ + try: () => + flow.documentRag( + query, + docLimit, + O.getOrUndefined(collection), + ), + catch: (error) => cliCommandError("document-rag", error), + }); + yield* writeLine(response); + }), + ), +).pipe(Command.withDescription("Query documents using RAG")); diff --git a/ts/packages/cli/src/commands/library.ts b/ts/packages/cli/src/commands/library.ts index 0369fb90..59b50b97 100644 --- a/ts/packages/cli/src/commands/library.ts +++ b/ts/packages/cli/src/commands/library.ts @@ -4,8 +4,11 @@ * Manages documents stored in the TrustGraph library. */ -import type { Command } from "commander"; import { Effect, Match } from "effect"; +import * as O from "effect/Option"; +import * as Argument from "effect/unstable/cli/Argument"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; import { cliCommandError, withSocket, writeJson } from "./util.js"; function basenamePath(filepath: string): string { @@ -30,98 +33,106 @@ export function guessMimeType(filepath: string): string { ); } -export function registerLibraryCommands(program: Command): void { - const library = program - .command("library") - .description("Document library management"); - - library - .command("list") - .description("List documents in the library") - .action((_opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const list = Command.make("list", {}, () => + withSocket((socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const docs = yield* Effect.tryPromise({ - try: () => lib.getDocuments(), - catch: (error) => cliCommandError("library.list", error), - }); - yield* writeJson(docs); - }), - )), - ); + const docs = yield* Effect.tryPromise({ + try: () => lib.getDocuments(), + catch: (error) => cliCommandError("library.list", error), + }); + yield* writeJson(docs); + }), + ), +).pipe(Command.withDescription("List documents in the library")); - library - .command("load") - .description("Load a document into the library") - .argument("", "Path to the file to load") - .option("-t, --title ", "Document title") - .option("-m, --mime-type <type>", "MIME type (auto-detected if omitted)") - .option("-c, --comments <text>", "Comments", "") - .option("--tags <tags...>", "Document tags") - .option("--id <id>", "Optional document ID") - .action((file: string, cmdOpts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const load = Command.make("load", { + file: Argument.string("file").pipe(Argument.withDescription("Path to the file to load")), + title: Flag.string("title").pipe( + Flag.withAlias("t"), + Flag.withDescription("Document title"), + Flag.optional, + ), + mimeType: Flag.string("mime-type").pipe( + Flag.withAlias("m"), + Flag.withDescription("MIME type (auto-detected if omitted)"), + Flag.optional, + ), + comments: Flag.string("comments").pipe( + Flag.withAlias("c"), + Flag.withDescription("Comments"), + Flag.withDefault(""), + ), + tags: Flag.string("tags").pipe( + Flag.withDescription("Document tags"), + Flag.atMost(Number.MAX_SAFE_INTEGER), + ), + id: Flag.string("id").pipe( + Flag.withDescription("Optional document ID"), + Flag.optional, + ), +}, ({ file, title, mimeType, comments, tags, id }) => + withSocket((socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const data = new Uint8Array(yield* Effect.tryPromise({ - try: () => Bun.file(file).arrayBuffer(), - catch: (error) => cliCommandError("library.load.read-file", error), - })); - const b64 = Buffer.from(data).toString("base64"); - const mimeType = (cmdOpts.mimeType as string | undefined) ?? guessMimeType(file); - const title = (cmdOpts.title as string | undefined) ?? basenamePath(file); - const comments = cmdOpts.comments as string; - const tags: string[] = (cmdOpts.tags as string[] | undefined) ?? []; + const data = new Uint8Array(yield* Effect.tryPromise({ + try: () => Bun.file(file).arrayBuffer(), + catch: (error) => cliCommandError("library.load.read-file", error), + })); + const b64 = Buffer.from(data).toString("base64"); + const resolvedMimeType = O.getOrUndefined(mimeType) ?? guessMimeType(file); + const resolvedTitle = O.getOrUndefined(title) ?? basenamePath(file); - const resp = yield* Effect.tryPromise({ - try: () => - lib.loadDocument( - b64, - mimeType, - title, - comments, - tags, - cmdOpts.id as string | undefined, - ), - catch: (error) => cliCommandError("library.load", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => + lib.loadDocument( + b64, + resolvedMimeType, + resolvedTitle, + comments, + Array.from(tags), + O.getOrUndefined(id), + ), + catch: (error) => cliCommandError("library.load", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Load a document into the library")); - library - .command("remove") - .description("Remove a document from the library") - .argument("<id>", "Document ID to remove") - .option("--collection <name>", "Collection name") - .action((id: string, cmdOpts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const remove = Command.make("remove", { + id: Argument.string("id").pipe(Argument.withDescription("Document ID to remove")), + collection: Flag.string("collection").pipe( + Flag.withDescription("Collection name"), + Flag.optional, + ), +}, ({ id, collection }) => + withSocket((socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const resp = yield* Effect.tryPromise({ - try: () => lib.removeDocument(id, cmdOpts.collection as string | undefined), - catch: (error) => cliCommandError("library.remove", error), - }); - yield* writeJson(resp); - }), - )), - ); + const resp = yield* Effect.tryPromise({ + try: () => lib.removeDocument(id, O.getOrUndefined(collection)), + catch: (error) => cliCommandError("library.remove", error), + }); + yield* writeJson(resp); + }), + ), +).pipe(Command.withDescription("Remove a document from the library")); - library - .command("processing") - .description("List documents currently being processed") - .action((_opts, cmd) => - Effect.runPromise(withSocket(cmd, (socket) => - Effect.gen(function* () { +const processing = Command.make("processing", {}, () => + withSocket((socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const items = yield* Effect.tryPromise({ - try: () => lib.getProcessing(), - catch: (error) => cliCommandError("library.processing", error), - }); - yield* writeJson(items); - }), - )), - ); -} + const items = yield* Effect.tryPromise({ + try: () => lib.getProcessing(), + catch: (error) => cliCommandError("library.processing", error), + }); + yield* writeJson(items); + }), + ), +).pipe(Command.withDescription("List documents currently being processed")); + +export const libraryCommand = Command.make("library").pipe( + Command.withDescription("Document library management"), + Command.withSubcommands([list, load, remove, processing]), +); diff --git a/ts/packages/cli/src/commands/triples.ts b/ts/packages/cli/src/commands/triples.ts index ab28b966..27ab2dd7 100644 --- a/ts/packages/cli/src/commands/triples.ts +++ b/ts/packages/cli/src/commands/triples.ts @@ -4,50 +4,67 @@ * Query the knowledge graph for subject-predicate-object triples. */ -import type { Command } from "commander"; import type { Term } from "@trustgraph/client"; import { Effect } from "effect"; +import * as O from "effect/Option"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; import { cliCommandError, withSocket, writeJson } from "./util.js"; -export function registerTriplesCommands(program: Command): void { - program - .command("triples") - .description("Query knowledge graph triples") - .option("-s, --subject <iri>", "Subject IRI") - .option("-p, --predicate <iri>", "Predicate IRI") - .option("-o, --object <iri>", "Object IRI or literal") - .option("-l, --limit <n>", "Max results", "20") - .option("--collection <name>", "Collection name") - .action((cmdOpts, cmd) => - Effect.runPromise(withSocket(cmd, (socket, opts) => - Effect.gen(function* () { +export const triplesCommand = Command.make("triples", { + subject: Flag.string("subject").pipe( + Flag.withAlias("s"), + Flag.withDescription("Subject IRI"), + Flag.optional, + ), + predicate: Flag.string("predicate").pipe( + Flag.withAlias("p"), + Flag.withDescription("Predicate IRI"), + Flag.optional, + ), + object: Flag.string("object").pipe( + Flag.withAlias("o"), + Flag.withDescription("Object IRI or literal"), + Flag.optional, + ), + limit: Flag.integer("limit").pipe( + Flag.withAlias("l"), + Flag.withDescription("Max results"), + Flag.withDefault(20), + ), + collection: Flag.string("collection").pipe( + Flag.withDescription("Collection name"), + Flag.optional, + ), +}, ({ subject, predicate, object, limit, collection }) => + withSocket((socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - 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 } + const subjectValue = O.getOrUndefined(subject); + const predicateValue = O.getOrUndefined(predicate); + const objectValue = O.getOrUndefined(object); + const s: Term | undefined = subjectValue !== undefined && subjectValue.length > 0 + ? { t: "i", i: subjectValue } : undefined; - const p: Term | undefined = predicate !== undefined && predicate.length > 0 - ? { t: "i", i: predicate } + const p: Term | undefined = predicateValue !== undefined && predicateValue.length > 0 + ? { t: "i", i: predicateValue } : undefined; - const o: Term | undefined = object !== undefined && object.length > 0 - ? { t: "i", i: object } + const o: Term | undefined = objectValue !== undefined && objectValue.length > 0 + ? { t: "i", i: objectValue } : undefined; - const triples = yield* Effect.tryPromise({ - try: () => - flow.triplesQuery( - s, - p, - o, - parseInt(cmdOpts.limit as string, 10), - cmdOpts.collection as string | undefined, - ), - catch: (error) => cliCommandError("triples", error), - }); - yield* writeJson(triples); - }), - )), - ); -} + const triples = yield* Effect.tryPromise({ + try: () => + flow.triplesQuery( + s, + p, + o, + limit, + O.getOrUndefined(collection), + ), + catch: (error) => cliCommandError("triples", error), + }); + yield* writeJson(triples); + }), + ), +).pipe(Command.withDescription("Query knowledge graph triples")); diff --git a/ts/packages/cli/src/commands/util.ts b/ts/packages/cli/src/commands/util.ts index fd459ef5..18d28904 100644 --- a/ts/packages/cli/src/commands/util.ts +++ b/ts/packages/cli/src/commands/util.ts @@ -2,10 +2,12 @@ * Shared CLI utilities. */ -import type { Command } from "commander"; import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client"; import { Duration, Effect } from "effect"; +import * as O from "effect/Option"; import * as S from "effect/Schema"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; export interface CliOpts { gateway: string; @@ -14,12 +16,42 @@ export interface CliOpts { flow: string; } -export function getOpts(cmd: Command): CliOpts { - // Walk up to root command to get global options - let root = cmd; - while (root.parent !== null) root = root.parent; - return root.opts() as CliOpts; -} +export const rootCommand = Command.make("tg").pipe( + Command.withDescription("TrustGraph CLI - interact with TrustGraph services"), + Command.withSharedFlags({ + gateway: Flag.string("gateway").pipe( + Flag.withAlias("g"), + Flag.withDescription("Gateway WebSocket URL"), + Flag.withDefault("ws://localhost:8088/api/v1/rpc"), + ), + user: Flag.string("user").pipe( + Flag.withAlias("u"), + Flag.withDescription("User identifier"), + Flag.withDefault("cli"), + ), + token: Flag.string("token").pipe( + Flag.withAlias("t"), + Flag.withDescription("Authentication token"), + Flag.optional, + ), + flow: Flag.string("flow").pipe( + Flag.withAlias("f"), + Flag.withDescription("Flow ID"), + Flag.withDefault("default"), + ), + }), +); + +export const getOpts = Effect.gen(function* () { + const opts = yield* rootCommand; + const base = { + gateway: opts.gateway, + user: opts.user, + flow: opts.flow, + }; + const token = O.getOrUndefined(opts.token); + return token === undefined ? base : { ...base, token } satisfies CliOpts; +}); export class CliCommandError extends S.TaggedErrorClass<CliCommandError>()( "CliCommandError", @@ -78,19 +110,16 @@ export function createSocketEffect(opts: CliOpts): Effect.Effect<BaseApi, CliCom ); } -export function createSocket(opts: CliOpts): Promise<BaseApi> { - return Effect.runPromise(createSocketEffect(opts)); -} - -export const withSocket = <A, E, R>( - cmd: Command, +export const withSocket = Effect.fn("withSocket")(function* <A, E, R>( use: (socket: BaseApi, opts: CliOpts) => Effect.Effect<A, E, R>, -) => - Effect.acquireUseRelease( - createSocketEffect(getOpts(cmd)), - (socket) => use(socket, getOpts(cmd)), +) { + const opts = yield* getOpts; + return yield* Effect.acquireUseRelease( + createSocketEffect(opts), + (socket) => use(socket, opts), (socket) => Effect.sync(() => { socket.close(); }), ); +}); diff --git a/ts/packages/cli/src/index.ts b/ts/packages/cli/src/index.ts index 1ff29ab6..a4587425 100644 --- a/ts/packages/cli/src/index.ts +++ b/ts/packages/cli/src/index.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +/** @effect-diagnostics strictEffectProvide:skip-file */ + /** * Unified TrustGraph CLI. * @@ -9,32 +11,33 @@ * Python reference: trustgraph-cli/trustgraph/cli/ */ -import { Command } from "commander"; -import { registerAgentCommands } from "./commands/agent.js"; -import { registerGraphRagCommands } from "./commands/graph-rag.js"; -import { registerConfigCommands } from "./commands/config.js"; -import { registerFlowCommands } from "./commands/flow.js"; -import { registerLibraryCommands } from "./commands/library.js"; -import { registerTriplesCommands } from "./commands/triples.js"; -import { registerEmbeddingsCommands } from "./commands/embeddings.js"; +import { BunRuntime, BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; +import * as Command from "effect/unstable/cli/Command"; +import { agentCommand } from "./commands/agent.js"; +import { configCommand } from "./commands/config.js"; +import { embeddingsCommand } from "./commands/embeddings.js"; +import { flowCommand } from "./commands/flow.js"; +import { graphRagCommand, documentRagCommand } from "./commands/graph-rag.js"; +import { libraryCommand } from "./commands/library.js"; +import { triplesCommand } from "./commands/triples.js"; +import { rootCommand } from "./commands/util.js"; -const program = new Command(); +export const cli = rootCommand.pipe( + Command.withSubcommands([ + agentCommand, + graphRagCommand, + documentRagCommand, + configCommand, + flowCommand, + libraryCommand, + triplesCommand, + embeddingsCommand, + ]), +); -program - .name("tg") - .description("TrustGraph CLI — interact with TrustGraph services") - .version("0.1.0") - .option("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/rpc") - .option("-u, --user <id>", "User identifier", "cli") - .option("-t, --token <token>", "Authentication token") - .option("-f, --flow <id>", "Flow ID", "default"); +export const program = Command.run(cli, { version: "0.1.0" }).pipe( + Effect.provide(BunServices.layer), +); -registerAgentCommands(program); -registerGraphRagCommands(program); -registerConfigCommands(program); -registerFlowCommands(program); -registerLibraryCommands(program); -registerTriplesCommands(program); -registerEmbeddingsCommands(program); - -program.parse(); +BunRuntime.runMain(program); diff --git a/ts/packages/client/package.json b/ts/packages/client/package.json index 6ce33446..78631199 100644 --- a/ts/packages/client/package.json +++ b/ts/packages/client/package.json @@ -12,7 +12,7 @@ "test": "bunx --bun vitest run" }, "dependencies": { - "effect": "4.0.0-beta.75" + "effect": "4.0.0-beta.78" }, "peerDependencies": { "ws": "^8.0.0" @@ -23,7 +23,7 @@ } }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "@types/ws": "^8.5.0", diff --git a/ts/packages/flow/package.json b/ts/packages/flow/package.json index 0c3f1446..0e7c6501 100644 --- a/ts/packages/flow/package.json +++ b/ts/packages/flow/package.json @@ -11,32 +11,30 @@ "test": "bunx --bun vitest run" }, "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", - "@fastify/websocket": "^11.0.0", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/platform-node": "4.0.0-beta.78", + "@effect/platform-node-shared": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@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.75", + "effect": "4.0.0-beta.78", "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.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "typescript": "^5.8.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 index f83a1552..c76c4c8d 100644 --- a/ts/packages/flow/src/__tests__/chunking-service.test.ts +++ b/ts/packages/flow/src/__tests__/chunking-service.test.ts @@ -59,17 +59,19 @@ class RecordingProducer<T> implements BackendProducer<T> { closeCount = 0; flushCount = 0; - async send(message: T, properties?: Record<string, string>): Promise<void> { - this.sent.push(properties === undefined ? { message } : { message, properties }); + send(message: T, properties?: Record<string, string>): Effect.Effect<void> { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + }); } - async flush(): Promise<void> { + readonly flush: Effect.Effect<void> = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closeCount += 1; - } + }); } class PushConsumer<T> implements BackendConsumer<T> { @@ -89,33 +91,39 @@ class PushConsumer<T> implements BackendConsumer<T> { this.messages.push(message); } - async receive(): Promise<Message<T> | null> { - const message = this.messages.shift(); - if (message !== undefined || this.closed) { - return message ?? null; - } - return await new Promise((resolve) => { - this.waiters.push(resolve); + receive(): Effect.Effect<Message<T> | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return Promise.resolve(message ?? null); + } + return new Promise<Message<T> | null>((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message<T>): Promise<void> { - this.acknowledged.push(message); + acknowledge(message: Message<T>): Effect.Effect<void> { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message<T>): Promise<void> { - this.nacked.push(message); + negativeAcknowledge(message: Message<T>): Effect.Effect<void> { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise<void> {} + readonly unsubscribe: Effect.Effect<void> = Effect.void; - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } this.closeCount += 1; - } + }); } class ChunkingBackend implements PubSubBackend { @@ -126,26 +134,30 @@ class ChunkingBackend implements PubSubBackend { readonly consumerOptions: Array<CreateConsumerOptions> = []; closeCount = 0; - async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> { - this.producerOptions.push(options); - const producer = new RecordingProducer<unknown>(); - this.producersByTopic.set(options.topic, producer); - return producer as BackendProducer<T>; + createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> { + return Effect.sync(() => { + this.producerOptions.push(options); + const producer = new RecordingProducer<unknown>(); + this.producersByTopic.set(options.topic, producer); + return producer as BackendProducer<T>; + }); } - async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - this.consumerOptions.push(options); - if (options.topic === topics.configPush) { - return this.configConsumer as unknown as BackendConsumer<T>; - } - const consumer = new PushConsumer<unknown>(); - this.consumersByTopic.set(options.topic, consumer); - return consumer as BackendConsumer<T>; + createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.sync(() => { + this.consumerOptions.push(options); + if (options.topic === topics.configPush) { + return this.configConsumer as unknown as BackendConsumer<T>; + } + const consumer = new PushConsumer<unknown>(); + this.consumersByTopic.set(options.topic, consumer); + return consumer as BackendConsumer<T>; + }); } - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closeCount += 1; - } + }); pushConfig(): void { this.configConsumer.push( diff --git a/ts/packages/flow/src/__tests__/config-service.test.ts b/ts/packages/flow/src/__tests__/config-service.test.ts index d089a113..6d8943e8 100644 --- a/ts/packages/flow/src/__tests__/config-service.test.ts +++ b/ts/packages/flow/src/__tests__/config-service.test.ts @@ -20,29 +20,29 @@ import type { class NoopPubSub implements PubSubBackend { readonly sentByTopic = new Map<string, Array<unknown>>(); - async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> { - return { - send: async (message) => { + createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> { + return Effect.succeed({ + send: (message) => Effect.sync(() => { const sent = this.sentByTopic.get(options.topic) ?? []; sent.push(message); this.sentByTopic.set(options.topic, sent); - }, - flush: async () => undefined, - close: async () => undefined, - }; + }), + flush: Effect.void, + close: Effect.void, + }); } - async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - return { - receive: async () => null, - acknowledge: async () => undefined, - negativeAcknowledge: async () => undefined, - unsubscribe: async () => undefined, - close: async () => undefined, - }; + createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.succeed({ + receive: () => Effect.succeed(null), + acknowledge: () => Effect.void, + negativeAcknowledge: () => Effect.void, + unsubscribe: Effect.void, + close: Effect.void, + }); } - async close(): Promise<void> {} + readonly close: Effect.Effect<void> = Effect.void; } const makeService = (persistPath?: string) => @@ -59,9 +59,9 @@ describe("ConfigService operations", () => { const putRequest: ConfigRequest = { operation: "put" }; const deleteRequest: ConfigRequest = { operation: "delete" }; - const putError = await service.handlePut(putRequest) + const putError = await Effect.runPromise(service.handlePutEffect(putRequest)) .catch((caught: unknown) => caught); - const deleteError = await service.handleDelete(deleteRequest) + const deleteError = await Effect.runPromise(service.handleDeleteEffect(deleteRequest)) .catch((caught: unknown) => caught); expect(putError).toBeInstanceOf(ConfigServiceError); @@ -81,7 +81,7 @@ describe("ConfigService operations", () => { ], }; - await service.handlePut(putRequest); + await Effect.runPromise(service.handlePutEffect(putRequest)); const persisted = await Bun.file(persistPath).json(); await rm(dir, { recursive: true, force: true }); @@ -107,7 +107,7 @@ describe("ConfigService operations", () => { ); const service = makeService(persistPath); - await service.loadFromDisk(); + await Effect.runPromise(service.loadFromDiskEffect); const getRequest: ConfigRequest = { operation: "get", keys: ["prompt", "system"], @@ -131,7 +131,10 @@ describe("ConfigService operations", () => { { operation: "put", values: [{ workspace: "beta", type: "prompt", key: "c", value: "three" }] }, ]; - await Promise.all(requests.map((request) => service.handlePut(request))); + await Effect.runPromise(Effect.all(requests.map((request) => service.handlePutEffect(request)), { + concurrency: "unbounded", + discard: true, + })); expect(service.handleGet({ operation: "get", keys: ["prompt"] })).toEqual({ version: 3, @@ -150,53 +153,53 @@ describe("ConfigService operations", () => { it("dispatches all config operations through the Match-backed handler", async () => { const service = makeService(); - await expect(service.handleOperation({ operation: "put" })).rejects.toMatchObject({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "put" }))).rejects.toMatchObject({ _tag: "ConfigServiceError", operation: "put", }); - await expect(service.handleOperation({ operation: "delete" })).rejects.toMatchObject({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "delete" }))).rejects.toMatchObject({ _tag: "ConfigServiceError", operation: "delete", }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "put", values: [{ type: "prompt", key: "system", value: "hello" }], - })).resolves.toEqual({ version: 1 }); + }))).resolves.toEqual({ version: 1 }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "get", keys: ["prompt", "system"], - })).resolves.toEqual({ + }))).resolves.toEqual({ version: 1, values: { system: "hello" }, }); - await expect(service.handleOperation({ operation: "list" })).resolves.toEqual({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "list" }))).resolves.toEqual({ version: 1, directory: ["prompt"], }); - await expect(service.handleOperation({ operation: "config" })).resolves.toEqual({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "config" }))).resolves.toEqual({ version: 1, config: { prompt: { system: "hello" } }, }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "getvalues", type: "prompt", - })).resolves.toEqual({ + }))).resolves.toEqual({ version: 1, values: [{ type: "prompt", key: "system", value: "hello" }], }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "getvalues-all-ws", type: "prompt", - })).resolves.toEqual({ + }))).resolves.toEqual({ version: 1, values: [{ workspace: "default", type: "prompt", key: "system", value: "hello" }], }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "delete", keys: ["prompt", "system"], - })).resolves.toEqual({ version: 2 }); + }))).resolves.toEqual({ version: 2 }); }); it("pushes config from the stored producer handle", async () => { @@ -206,10 +209,10 @@ describe("ConfigService operations", () => { manageProcessSignals: false, pubsub: backend, }); - const pushProducer = await backend.createProducer<{ + const pushProducer = await Effect.runPromise(backend.createProducer<{ readonly version: number; readonly config: Record<string, unknown>; - }>({ topic: topics.configPush }); + }>({ topic: topics.configPush })); await Effect.runPromise( SynchronizedRef.update(service.state, (state) => ({ @@ -217,11 +220,11 @@ describe("ConfigService operations", () => { pushProducer, })), ); - await service.pushConfig(); - await service.handlePut({ + await Effect.runPromise(service.pushConfigEffect); + await Effect.runPromise(service.handlePutEffect({ operation: "put", values: [{ type: "prompt", key: "system", value: "hello" }], - }); + })); expect(backend.sentByTopic.get(topics.configPush)).toEqual([ { version: 0, config: {} }, diff --git a/ts/packages/flow/src/__tests__/falkordb-lifecycle.test.ts b/ts/packages/flow/src/__tests__/falkordb-lifecycle.test.ts index 43bd400f..85e8cb6b 100644 --- a/ts/packages/flow/src/__tests__/falkordb-lifecycle.test.ts +++ b/ts/packages/flow/src/__tests__/falkordb-lifecycle.test.ts @@ -17,30 +17,34 @@ class FakeFalkorDBClient implements FalkorDBStoreClient, FalkorDBQueryClient { connectCount = 0; disconnectCount = 0; - async connect(): Promise<void> { + readonly connect: Effect.Effect<void> = Effect.sync(() => { this.connectCount += 1; - } + }); - async disconnect(): Promise<void> { + readonly disconnect: Effect.Effect<void> = Effect.sync(() => { this.disconnectCount += 1; - } + }); } class FakeStoreGraph implements FalkorDBStoreGraph { readonly queries: string[] = []; - async query<T = unknown>(query: string): Promise<{ readonly data?: Array<T> }> { - this.queries.push(query); - return {}; + query<T = unknown>(query: string): Effect.Effect<{ readonly data?: Array<T> }> { + return Effect.sync(() => { + this.queries.push(query); + return {}; + }); } } class FakeQueryGraph implements FalkorDBQueryGraph { readonly queries: string[] = []; - async query<T = unknown>(query: string): Promise<{ readonly data?: Array<T> }> { - this.queries.push(query); - return {}; + query<T = unknown>(query: string): Effect.Effect<{ readonly data?: Array<T> }> { + return Effect.sync(() => { + this.queries.push(query); + return {}; + }); } } diff --git a/ts/packages/flow/src/__tests__/flow-manager-service.test.ts b/ts/packages/flow/src/__tests__/flow-manager-service.test.ts index dff32c9a..68b21804 100644 --- a/ts/packages/flow/src/__tests__/flow-manager-service.test.ts +++ b/ts/packages/flow/src/__tests__/flow-manager-service.test.ts @@ -19,29 +19,29 @@ import {FlowManagerError, makeFlowManagerService} from "../flow-manager/service. class NoopPubSub implements PubSubBackend { readonly sentByTopic = new Map<string, Array<unknown>>(); - async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> { - return { - send: async (message) => { + createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> { + return Effect.succeed({ + send: (message) => Effect.sync(() => { const sent = this.sentByTopic.get(options.topic) ?? []; sent.push(message); this.sentByTopic.set(options.topic, sent); - }, - flush: async () => undefined, - close: async () => undefined, - }; + }), + flush: Effect.void, + close: Effect.void, + }); } - async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - return { - receive: async () => null, - acknowledge: async () => undefined, - negativeAcknowledge: async () => undefined, - unsubscribe: async () => undefined, - close: async () => undefined, - }; + createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.succeed({ + receive: () => Effect.succeed(null), + acknowledge: () => Effect.void, + negativeAcknowledge: () => Effect.void, + unsubscribe: Effect.void, + close: Effect.void, + }); } - async close(): Promise<void> {} + readonly close: Effect.Effect<void> = Effect.void; } class RecordingConfigClient implements RequestResponse<ConfigRequest, ConfigResponse> { @@ -53,25 +53,27 @@ class RecordingConfigClient implements RequestResponse<ConfigRequest, ConfigResp private readonly legacyFlows: Array<{readonly key: string; readonly value: unknown}> = [], ) {} - async start(): Promise<void> {} + readonly start: Effect.Effect<void> = Effect.void; - async stop(): Promise<void> {} + readonly stop: Effect.Effect<void> = Effect.void; - async request(request: ConfigRequest): Promise<ConfigResponse> { - this.requests.push(request); - if (request.operation !== "getvalues") return {}; + request(request: ConfigRequest): Effect.Effect<ConfigResponse> { + return Effect.sync(() => { + this.requests.push(request); + if (request.operation !== "getvalues") return {}; - if (request.type === "flow-blueprint") { - return {values: this.blueprints}; - } - if (request.type === "flow") { - return {values: this.flows}; - } - if (request.type === "flows") { - return {values: this.legacyFlows}; - } + if (request.type === "flow-blueprint") { + return {values: this.blueprints}; + } + if (request.type === "flow") { + return {values: this.flows}; + } + if (request.type === "flows") { + return {values: this.legacyFlows}; + } - return {values: []}; + return {values: []}; + }); } } @@ -97,9 +99,9 @@ const seedResponseProducer = async ( backend: NoopPubSub, service: ReturnType<typeof makeFlowManagerService>, ) => { - const responseProducer = await backend.createProducer<FlowResponse>({ + const responseProducer = await Effect.runPromise(backend.createProducer<FlowResponse>({ topic: topics.flowResponse, - }); + })); await Effect.runPromise( SynchronizedRef.update(service.state, (state) => ({ ...state, @@ -127,43 +129,43 @@ describe("FlowManagerService operations", () => { const service = makeService(); await seedConfigClient(service, configClient); - await expect(service.handleOperation({operation: "list-blueprints"})).resolves.toEqual({ + await expect(Effect.runPromise(service.handleOperationEffect({operation: "list-blueprints"}))).resolves.toEqual({ "blueprint-names": ["custom", "default"], }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "get-blueprint", "blueprint-name": "custom", - })).resolves.toMatchObject({ + }))).resolves.toMatchObject({ "blueprint-definition": "{\"description\":\"Custom\",\"topics\":{\"input\":\"topic.in\"}}", }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "put-blueprint", "blueprint-name": "added", "blueprint-definition": {description: "Added", topics: {input: "topic.added"}}, - })).resolves.toEqual({}); - await expect(service.handleOperation({ + }))).resolves.toEqual({}); + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "delete-blueprint", "blueprint-name": "custom", - })).resolves.toEqual({}); - await expect(service.handleOperation({operation: "list-flows"})).resolves.toEqual({ + }))).resolves.toEqual({}); + await expect(Effect.runPromise(service.handleOperationEffect({operation: "list-flows"}))).resolves.toEqual({ "flow-ids": ["flow-a"], }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "get-flow", "flow-id": "flow-a", - })).resolves.toEqual({ + }))).resolves.toEqual({ flow: "{\"blueprint-name\":\"custom\",\"description\":\"Alpha\",\"parameters\":{\"limit\":3}}", }); - await expect(service.handleOperation({ + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "start-flow", "flow-id": "flow-b", "blueprint-name": "custom", - })).resolves.toEqual({}); - await expect(service.handleOperation({ + }))).resolves.toEqual({}); + await expect(Effect.runPromise(service.handleOperationEffect({ operation: "stop-flow", "flow-id": "flow-a", - })).resolves.toEqual({}); - await expect(service.handleOperation({operation: "unknown-flow"})).rejects.toMatchObject({ + }))).resolves.toEqual({}); + await expect(Effect.runPromise(service.handleOperationEffect({operation: "unknown-flow"}))).rejects.toMatchObject({ _tag: "FlowManagerError", operation: "operation", message: "Unknown flow operation: unknown-flow", @@ -180,9 +182,9 @@ describe("FlowManagerService operations", () => { it("uses tagged errors for invalid flow mutations", async () => { const service = makeService(); - const startError = await service.handleStartFlow({operation: "start-flow"}) + const startError = await Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow"})) .catch((caught: unknown) => caught); - const stopError = await service.handleStopFlow({operation: "stop-flow"}) + const stopError = await Effect.runPromise(service.handleStopFlowEffect({operation: "stop-flow"})) .catch((caught: unknown) => caught); expect(startError).toBeInstanceOf(FlowManagerError); @@ -196,12 +198,12 @@ describe("FlowManagerService operations", () => { const service = makeService(); await seedConfigClient(service, configClient); - await service.handleStartFlow({ + await Effect.runPromise(service.handleStartFlowEffect({ operation: "start-flow", "flow-id": "flow-a", description: "alpha", parameters: {limit: 3}, - }); + })); let state = await Effect.runPromise(SynchronizedRef.get(service.state)); expect(Option.getOrUndefined(HashMap.get(state.flows, "flow-a"))).toMatchObject({ id: "flow-a", @@ -211,10 +213,10 @@ describe("FlowManagerService operations", () => { status: "running", }); - await service.handleStopFlow({ + await Effect.runPromise(service.handleStopFlowEffect({ operation: "stop-flow", "flow-id": "flow-a", - }); + })); state = await Effect.runPromise(SynchronizedRef.get(service.state)); expect(HashMap.has(state.flows, "flow-a")).toBe(false); @@ -245,7 +247,7 @@ describe("FlowManagerService operations", () => { const service = makeService(); await seedConfigClient(service, configClient); - await service.refreshBlueprintsFromConfig(); + await Effect.runPromise(service.refreshBlueprintsFromConfigEffect); const state = await Effect.runPromise(SynchronizedRef.get(service.state)); expect(Option.getOrUndefined(HashMap.get(state.blueprints, "custom"))).toMatchObject({ @@ -263,8 +265,8 @@ describe("FlowManagerService operations", () => { await seedConfigClient(service, configClient); const results = await Promise.allSettled([ - service.handleStartFlow({operation: "start-flow", "flow-id": "flow-a"}), - service.handleStartFlow({operation: "start-flow", "flow-id": "flow-a"}), + Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow", "flow-id": "flow-a"})), + Effect.runPromise(service.handleStartFlowEffect({operation: "start-flow", "flow-id": "flow-a"})), ]); const state = await Effect.runPromise(SynchronizedRef.get(service.state)); diff --git a/ts/packages/flow/src/__tests__/gateway-dispatcher.test.ts b/ts/packages/flow/src/__tests__/gateway-dispatcher.test.ts index 7e326ed7..0c8b37fd 100644 --- a/ts/packages/flow/src/__tests__/gateway-dispatcher.test.ts +++ b/ts/packages/flow/src/__tests__/gateway-dispatcher.test.ts @@ -18,6 +18,7 @@ import type { Message, PubSubBackend, } from "@trustgraph/base"; +import { pubSubError } from "@trustgraph/base"; function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> { return { @@ -44,32 +45,38 @@ class TopicConsumer<T> implements BackendConsumer<T> { this.messages.push(message); } - async receive(): Promise<Message<T> | null> { - const message = this.messages.shift(); - if (message !== undefined || this.closed) return message ?? null; + receive(): Effect.Effect<Message<T> | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || this.closed) return Promise.resolve(message ?? null); - return await new Promise((resolve) => { - this.waiters.push(resolve); + return new Promise((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message<T>): Promise<void> { - this.acknowledged.push(message); + acknowledge(message: Message<T>): Effect.Effect<void> { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(message: Message<T>): Promise<void> { - this.nacked.push(message); + negativeAcknowledge(message: Message<T>): Effect.Effect<void> { + return Effect.sync(() => { + this.nacked.push(message); + }); } - async unsubscribe(): Promise<void> {} + readonly unsubscribe: Effect.Effect<void> = Effect.void; - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } this.closeCount += 1; - } + }); } class RecordingProducer<T> implements BackendProducer<T> { @@ -82,18 +89,23 @@ class RecordingProducer<T> implements BackendProducer<T> { private readonly onSend: (topic: string, message: T, properties?: Record<string, string>) => void, ) {} - async send(message: T, properties?: Record<string, string>): Promise<void> { - this.sent.push(properties === undefined ? { message } : { message, properties }); - this.onSend(this.topic, message, properties); + send(message: T, properties?: Record<string, string>): Effect.Effect<void> { + return Effect.try({ + try: () => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + this.onSend(this.topic, message, properties); + }, + catch: (error) => pubSubError(`send:${this.topic}`, error), + }); } - async flush(): Promise<void> { + readonly flush: Effect.Effect<void> = Effect.sync(() => { this.flushCount += 1; - } + }); - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closeCount += 1; - } + }); } class DispatchBackend implements PubSubBackend { @@ -104,31 +116,35 @@ class DispatchBackend implements PubSubBackend { readonly consumersByTopic = new Map<string, TopicConsumer<unknown>>(); readonly failSendTopics = new Set<string>(); - async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> { - this.producerOptions.push(options); - let producer = this.producersByTopic.get(options.topic); - if (producer === undefined) { - producer = new RecordingProducer<unknown>(options.topic, (topic, message, properties) => { - this.handleSend(topic, message, properties); - }); - this.producersByTopic.set(options.topic, producer); - } - return producer as BackendProducer<T>; + createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> { + return Effect.sync(() => { + this.producerOptions.push(options); + let producer = this.producersByTopic.get(options.topic); + if (producer === undefined) { + producer = new RecordingProducer<unknown>(options.topic, (topic, message, properties) => { + this.handleSend(topic, message, properties); + }); + this.producersByTopic.set(options.topic, producer); + } + return producer as BackendProducer<T>; + }); } - async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - this.consumerOptions.push(options); - let consumer = this.consumersByTopic.get(options.topic); - if (consumer === undefined) { - consumer = new TopicConsumer<unknown>(); - this.consumersByTopic.set(options.topic, consumer); - } - return consumer as BackendConsumer<T>; + createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.sync(() => { + this.consumerOptions.push(options); + let consumer = this.consumersByTopic.get(options.topic); + if (consumer === undefined) { + consumer = new TopicConsumer<unknown>(); + this.consumersByTopic.set(options.topic, consumer); + } + return consumer as BackendConsumer<T>; + }); } - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closeCount += 1; - } + }); private handleSend(topic: string, message: unknown, properties?: Record<string, string>): void { if (this.failSendTopics.has(topic)) { @@ -230,10 +246,10 @@ describe("gateway dispatcher manager", () => { pubsub: backend, }); - await manager.start(); - const first = await manager.dispatchGlobalService("config", { operation: "get" }); - const second = await manager.dispatchGlobalService("config", { operation: "list" }); - await manager.stop(); + await Effect.runPromise(manager.start); + const first = await Effect.runPromise(manager.dispatchGlobalService("config", { operation: "get" })); + const second = await Effect.runPromise(manager.dispatchGlobalService("config", { operation: "list" })); + await Effect.runPromise(manager.stop); expect(first).toEqual({ ok: true, echo: { operation: "get" } }); expect(second).toEqual({ ok: true, echo: { operation: "list" } }); @@ -252,12 +268,12 @@ describe("gateway dispatcher manager", () => { pubsub: backend, }); - await manager.start(); - const [first, second] = await Promise.all([ + await Effect.runPromise(manager.start); + const [first, second] = await Effect.runPromise(Effect.all([ manager.dispatchGlobalService("config", { operation: "get" }), manager.dispatchGlobalService("config", { operation: "list" }), - ]); - await manager.stop(); + ], { concurrency: "unbounded" })); + await Effect.runPromise(manager.stop); expect(first).toEqual({ ok: true, echo: { operation: "get" } }); expect(second).toEqual({ ok: true, echo: { operation: "list" } }); @@ -274,12 +290,12 @@ describe("gateway dispatcher manager", () => { }); await expect( - manager.dispatchGlobalService("knowledge", { term: { t: "t" } }), + Effect.runPromise(manager.dispatchGlobalService("knowledge", { term: { t: "t" } })), ).rejects.toMatchObject({ _tag: "DispatchSerializationError", operation: "client-term-to-internal", }); - await manager.stop(); + await Effect.runPromise(manager.stop); expect(backend.producerOptions).toHaveLength(0); expect(backend.consumerOptions).toHaveLength(0); @@ -296,12 +312,12 @@ describe("gateway dispatcher manager", () => { }); await expect( - manager.publishToTopic("tg.flow.ingest", { text: "hello" }, "msg-1"), + Effect.runPromise(manager.publishToTopic("tg.flow.ingest", { text: "hello" }, "msg-1")), ).rejects.toMatchObject({ _tag: "MessagingDeliveryError", operation: "send", }); - await manager.stop(); + await Effect.runPromise(manager.stop); expect(backend.producersByTopic.get("tg.flow.ingest")?.closeCount).toBe(1); expect(backend.closeCount).toBe(0); @@ -316,10 +332,14 @@ describe("gateway dispatcher manager", () => { }); const chunks: Array<{ readonly response: unknown; readonly complete: boolean }> = []; - await manager.dispatchGlobalServiceStreaming("knowledge", { query: "hello" }, async (response, complete) => { - chunks.push({ response, complete }); - }); - await manager.stop(); + await Effect.runPromise( + manager.dispatchGlobalServiceStreaming("knowledge", { query: "hello" }, (response, complete) => + Effect.sync(() => { + chunks.push({ response, complete }); + }) + ), + ); + await Effect.runPromise(manager.stop); expect(chunks).toEqual([ { response: { chunk: 1 }, complete: false }, @@ -337,13 +357,13 @@ describe("gateway dispatcher manager", () => { const chunks: Array<{ readonly response: unknown; readonly complete: boolean }> = []; await Effect.runPromise( - manager.dispatchGlobalServiceStreamingEffect("knowledge", { query: "hello" }, (response, complete) => + manager.dispatchGlobalServiceStreaming("knowledge", { query: "hello" }, (response, complete) => Effect.sync(() => { chunks.push({ response, complete }); }) ), ); - await manager.stop(); + await Effect.runPromise(manager.stop); expect(chunks).toEqual([ { response: { chunk: 1 }, complete: false }, diff --git a/ts/packages/flow/src/__tests__/knowledge-core-service.test.ts b/ts/packages/flow/src/__tests__/knowledge-core-service.test.ts index 7f83f5e6..ef6a13d6 100644 --- a/ts/packages/flow/src/__tests__/knowledge-core-service.test.ts +++ b/ts/packages/flow/src/__tests__/knowledge-core-service.test.ts @@ -20,29 +20,29 @@ import {makeKnowledgeCoreService} from "../cores/service.js"; class NoopPubSub implements PubSubBackend { readonly sentByTopic = new Map<string, Array<unknown>>(); - async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> { - return { - send: async (message) => { + createProducer<T>(options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> { + return Effect.succeed({ + send: (message) => Effect.sync(() => { const sent = this.sentByTopic.get(options.topic) ?? []; sent.push(message); this.sentByTopic.set(options.topic, sent); - }, - flush: async () => undefined, - close: async () => undefined, - }; + }), + flush: Effect.void, + close: Effect.void, + }); } - async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - return { - receive: async () => null, - acknowledge: async (_message: Message<T>) => undefined, - negativeAcknowledge: async (_message: Message<T>) => undefined, - unsubscribe: async () => undefined, - close: async () => undefined, - }; + createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.succeed({ + receive: () => Effect.succeed(null), + acknowledge: (_message: Message<T>) => Effect.void, + negativeAcknowledge: (_message: Message<T>) => Effect.void, + unsubscribe: Effect.void, + close: Effect.void, + }); } - async close(): Promise<void> {} + readonly close: Effect.Effect<void> = Effect.void; } const sampleTriple: Triple = { @@ -63,9 +63,9 @@ const seedResponseProducer = async ( backend: NoopPubSub, service: ReturnType<typeof makeKnowledgeCoreService>, ) => { - const responseProducer = await backend.createProducer<KnowledgeResponse>({ + const responseProducer = await Effect.runPromise(backend.createProducer<KnowledgeResponse>({ topic: topics.knowledgeResponse, - }); + })); await Effect.runPromise( SynchronizedRef.update(service.state, (state) => ({ ...state, @@ -94,15 +94,15 @@ describe("KnowledgeCoreService operations", () => { ], }; - await service.putKgCore(request, "put-1"); + await Effect.runPromise(service.putKgCoreEffect(request, "put-1")); const state = await Effect.runPromise(SynchronizedRef.get(service.state)); const core = Option.getOrUndefined(HashMap.get(state.kgCores, "alice:core-a")); - await service.getKgCore({ + await Effect.runPromise(service.getKgCoreEffect({ operation: "get-kg-core", user: "alice", id: "core-a", - }, "get-1"); + }, "get-1")); await rm(dir, {recursive: true, force: true}); expect(core?.triples).toEqual([sampleTriple]); @@ -142,14 +142,14 @@ describe("KnowledgeCoreService operations", () => { const service = makeService(dir, backend); await seedResponseProducer(backend, service); - await Promise.all([ - service.putKgCore({ + await Effect.runPromise(Effect.all([ + service.putKgCoreEffect({ operation: "put-kg-core", user: "alice", id: "core-b", triples: [sampleTriple], }, "put-a"), - service.putKgCore({ + service.putKgCoreEffect({ operation: "put-kg-core", user: "alice", id: "core-b", @@ -161,7 +161,10 @@ describe("KnowledgeCoreService operations", () => { }, ], }, "put-b"), - ]); + ], { + concurrency: "unbounded", + discard: true, + })); const state = await Effect.runPromise(SynchronizedRef.get(service.state)); await rm(dir, {recursive: true, force: true}); @@ -183,7 +186,7 @@ describe("KnowledgeCoreService operations", () => { ); const service = makeService(dir); - await service.loadFromDisk(); + await Effect.runPromise(service.loadFromDiskEffect); const state = await Effect.runPromise(SynchronizedRef.get(service.state)); await rm(dir, {recursive: true, force: true}); diff --git a/ts/packages/flow/src/__tests__/librarian-service.test.ts b/ts/packages/flow/src/__tests__/librarian-service.test.ts index 05d0c9b3..c5725a71 100644 --- a/ts/packages/flow/src/__tests__/librarian-service.test.ts +++ b/ts/packages/flow/src/__tests__/librarian-service.test.ts @@ -1,6 +1,7 @@ import {mkdtemp, rm} from "node:fs/promises"; import {tmpdir} from "node:os"; import {join} from "node:path"; +import {Effect} from "effect"; import {describe, expect, it} from "vitest"; import { type BackendConsumer, @@ -15,25 +16,25 @@ import { import {makeLibrarianService} from "../librarian/service.js"; class NoopPubSub implements PubSubBackend { - async createProducer<T>(_options: CreateProducerOptions<T>): Promise<BackendProducer<T>> { - return { - send: async () => undefined, - flush: async () => undefined, - close: async () => undefined, - }; + createProducer<T>(_options: CreateProducerOptions<T>): Effect.Effect<BackendProducer<T>> { + return Effect.succeed({ + send: () => Effect.void, + flush: Effect.void, + close: Effect.void, + }); } - async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - return { - receive: async () => null, - acknowledge: async (_message: Message<T>) => undefined, - negativeAcknowledge: async (_message: Message<T>) => undefined, - unsubscribe: async () => undefined, - close: async () => undefined, - }; + createConsumer<T>(_options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.succeed({ + receive: () => Effect.succeed(null), + acknowledge: (_message: Message<T>) => Effect.void, + negativeAcknowledge: (_message: Message<T>) => Effect.void, + unsubscribe: Effect.void, + close: Effect.void, + }); } - async close(): Promise<void> {} + readonly close: Effect.Effect<void> = Effect.void; } const sampleTriple: Triple = { @@ -66,40 +67,40 @@ describe("LibrarianService schema-backed boundaries", () => { const service = makeService(dir); try { - await expect(service.handleLibrarianOperation({ + await expect(Effect.runPromise(service.handleLibrarianOperation({ operation: "list-documents", user: "alice", - })).resolves.toEqual({ + }))).resolves.toEqual({ documents: [], "document-metadatas": [], }); - const upload = await service.handleLibrarianOperation({ + const upload = await Effect.runPromise(service.handleLibrarianOperation({ operation: "begin-upload", documentMetadata: sampleDocument, "document-metadata": sampleDocument, "total-size": 12, "chunk-size": 4, - }); - await expect(service.handleLibrarianOperation({ + })); + await expect(Effect.runPromise(service.handleLibrarianOperation({ operation: "get-upload-status", "upload-id": upload["upload-id"], - })).resolves.toMatchObject({ + }))).resolves.toMatchObject({ "upload-id": upload["upload-id"], "upload-state": "in-progress", "missing-chunks": [0, 1, 2], }); - await expect(service.handleLibrarianOperation({ + await expect(Effect.runPromise(service.handleLibrarianOperation({ operation: "stream-document", "document-id": "doc-a", - })).rejects.toMatchObject({ + }))).rejects.toMatchObject({ _tag: "LibrarianServiceError", operation: "stream-document", message: "stream-document must be handled as a streaming operation", }); - await expect(service.handleLibrarianOperation(JSON.parse(`{"operation":"unknown-librarian"}`))).rejects.toMatchObject({ + await expect(Effect.runPromise(service.handleLibrarianOperation(JSON.parse(`{"operation":"unknown-librarian"}`)))).rejects.toMatchObject({ _tag: "LibrarianServiceError", operation: "operation", message: "Unknown librarian operation: unknown-librarian", @@ -114,14 +115,14 @@ describe("LibrarianService schema-backed boundaries", () => { const service = makeService(dir); try { - await expect(service.handleCollectionOperation({ + await expect(Effect.runPromise(service.handleCollectionOperation({ operation: "update-collection", user: "alice", collection: "docs", name: "Docs", description: "Documentation", tags: ["reference"], - })).resolves.toEqual({ + }))).resolves.toEqual({ collections: [{ user: "alice", collection: "docs", @@ -131,10 +132,10 @@ describe("LibrarianService schema-backed boundaries", () => { }], }); - await expect(service.handleCollectionOperation({ + await expect(Effect.runPromise(service.handleCollectionOperation({ operation: "list-collections", user: "alice", - })).resolves.toEqual({ + }))).resolves.toEqual({ collections: [{ user: "alice", collection: "docs", @@ -144,17 +145,17 @@ describe("LibrarianService schema-backed boundaries", () => { }], }); - await expect(service.handleCollectionOperation({ + await expect(Effect.runPromise(service.handleCollectionOperation({ operation: "delete-collection", user: "alice", collection: "docs", - })).resolves.toEqual({}); - await expect(service.handleCollectionOperation({ + }))).resolves.toEqual({}); + await expect(Effect.runPromise(service.handleCollectionOperation({ operation: "list-collections", user: "alice", - })).resolves.toEqual({collections: []}); + }))).resolves.toEqual({collections: []}); - await expect(service.handleCollectionOperation(JSON.parse(`{"operation":"unknown-collection"}`))).rejects.toMatchObject({ + await expect(Effect.runPromise(service.handleCollectionOperation(JSON.parse(`{"operation":"unknown-collection"}`)))).rejects.toMatchObject({ _tag: "LibrarianServiceError", operation: "collection-operation", message: "Unknown collection operation: unknown-collection", @@ -168,18 +169,18 @@ describe("LibrarianService schema-backed boundaries", () => { const dir = await mkdtemp(join(tmpdir(), "trustgraph-librarian-service-")); const service = makeService(dir); - const response = await service.beginUpload({ + const response = await Effect.runPromise(service.beginUpload({ operation: "begin-upload", documentMetadata: sampleDocument, "document-metadata": sampleDocument, "total-size": 12, "chunk-size": 4, - }); + })); const uploadId = response["upload-id"]; - const status = await service.getUploadStatus({ + const status = await Effect.runPromise(service.getUploadStatus({ operation: "get-upload-status", "upload-id": uploadId, - }); + })); await rm(dir, {recursive: true, force: true}); expect(uploadId).toEqual(expect.any(String)); @@ -202,8 +203,8 @@ describe("LibrarianService schema-backed boundaries", () => { ); const service = makeService(dir); - await service.loadFromDisk(); - const documents = service.listDocuments({operation: "list-documents", user: "alice"}).documents; + await Effect.runPromise(service.loadFromDisk); + const documents = (await Effect.runPromise(service.listDocuments({operation: "list-documents", user: "alice"}))).documents; await rm(dir, {recursive: true, force: true}); expect(documents).toEqual([{ @@ -217,15 +218,15 @@ describe("LibrarianService schema-backed boundaries", () => { const dir = await mkdtemp(join(tmpdir(), "trustgraph-librarian-service-")); const service = makeService(dir); - const valid = await service.normaliseDocumentMetadata({ + const valid = await Effect.runPromise(service.normaliseDocumentMetadata({ ...sampleDocument, metadata: [sampleTriple], - }); - const invalid = await service.normaliseDocumentMetadata({ + })); + const invalid = await Effect.runPromise(service.normaliseDocumentMetadata({ ...sampleDocument, id: "doc-b", metadata: [{not: "a triple"}], - }); + })); await rm(dir, {recursive: true, force: true}); expect(valid.metadata).toEqual([sampleTriple]); diff --git a/ts/packages/flow/src/__tests__/prompt-template.test.ts b/ts/packages/flow/src/__tests__/prompt-template.test.ts index 53f68dc9..d875ebb0 100644 --- a/ts/packages/flow/src/__tests__/prompt-template.test.ts +++ b/ts/packages/flow/src/__tests__/prompt-template.test.ts @@ -55,13 +55,15 @@ const waitFor = (condition: () => boolean, label: string) => class RecordingProducer<T> implements BackendProducer<T> { readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = []; - async send(message: T, properties?: Record<string, string>): Promise<void> { - this.sent.push(properties === undefined ? { message } : { message, properties }); + send(message: T, properties?: Record<string, string>): Effect.Effect<void> { + return Effect.sync(() => { + this.sent.push(properties === undefined ? { message } : { message, properties }); + }); } - async flush(): Promise<void> {} + readonly flush: Effect.Effect<void> = Effect.void; - async close(): Promise<void> {} + readonly close: Effect.Effect<void> = Effect.void; } class PushConsumer<T> implements BackendConsumer<T> { @@ -79,30 +81,36 @@ class PushConsumer<T> implements BackendConsumer<T> { this.messages.push(message); } - async receive(): Promise<Message<T> | null> { - const message = this.messages.shift(); - if (message !== undefined || this.closed) { - return message ?? null; - } - return await new Promise((resolve) => { - this.waiters.push(resolve); + receive(): Effect.Effect<Message<T> | null> { + return Effect.promise(() => { + const message = this.messages.shift(); + if (message !== undefined || this.closed) { + return Promise.resolve(message ?? null); + } + return new Promise<Message<T> | null>((resolve) => { + this.waiters.push(resolve); + }); }); } - async acknowledge(message: Message<T>): Promise<void> { - this.acknowledged.push(message); + acknowledge(message: Message<T>): Effect.Effect<void> { + return Effect.sync(() => { + this.acknowledged.push(message); + }); } - async negativeAcknowledge(): Promise<void> {} + negativeAcknowledge(): Effect.Effect<void> { + return Effect.void; + } - async unsubscribe(): Promise<void> {} + readonly unsubscribe: Effect.Effect<void> = Effect.void; - async close(): Promise<void> { + readonly close: Effect.Effect<void> = Effect.sync(() => { this.closed = true; for (const waiter of this.waiters.splice(0)) { waiter(null); } - } + }); } class PromptBackend implements PubSubBackend { @@ -110,22 +118,26 @@ class PromptBackend implements PubSubBackend { readonly consumersByTopic = new Map<string, PushConsumer<unknown>>(); readonly producersByTopic = new Map<string, RecordingProducer<unknown>>(); - async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> { - const producer = new RecordingProducer<unknown>(); - this.producersByTopic.set(options.topic, producer); - return producer as BackendProducer<T>; + createProducer<T>(options: CreateProducerOptions): Effect.Effect<BackendProducer<T>> { + return Effect.sync(() => { + const producer = new RecordingProducer<unknown>(); + this.producersByTopic.set(options.topic, producer); + return producer as BackendProducer<T>; + }); } - async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> { - if (options.topic === topics.configPush) { - return this.configConsumer as unknown as BackendConsumer<T>; - } - const consumer = new PushConsumer<unknown>(); - this.consumersByTopic.set(options.topic, consumer); - return consumer as BackendConsumer<T>; + createConsumer<T>(options: CreateConsumerOptions): Effect.Effect<BackendConsumer<T>> { + return Effect.sync(() => { + if (options.topic === topics.configPush) { + return this.configConsumer as unknown as BackendConsumer<T>; + } + const consumer = new PushConsumer<unknown>(); + this.consumersByTopic.set(options.topic, consumer); + return consumer as BackendConsumer<T>; + }); } - async close(): Promise<void> {} + readonly close: Effect.Effect<void> = Effect.void; pushPromptConfig(): void { this.configConsumer.push(createMessage({ diff --git a/ts/packages/flow/src/__tests__/qdrant-embeddings.test.ts b/ts/packages/flow/src/__tests__/qdrant-embeddings.test.ts index d7ac9f37..98cba475 100644 --- a/ts/packages/flow/src/__tests__/qdrant-embeddings.test.ts +++ b/ts/packages/flow/src/__tests__/qdrant-embeddings.test.ts @@ -33,44 +33,51 @@ class FakeQdrantClient implements QdrantClientLike { readonly deletedCollections: string[] = []; searchResults: ReadonlyArray<QdrantScoredPoint> = []; - async collectionExists(collectionName: string): Promise<{ readonly exists: boolean }> { - this.collectionExistsCalls.push(collectionName); - return { exists: this.collections.has(collectionName) }; + collectionExists(collectionName: string): Effect.Effect<{ readonly exists: boolean }> { + return Effect.sync(() => { + this.collectionExistsCalls.push(collectionName); + return { exists: this.collections.has(collectionName) }; + }); } - async createCollection( + createCollection( collectionName: string, options: { readonly vectors: { readonly size: number; readonly distance: "Cosine" } }, - ): Promise<void> { - this.collections.add(collectionName); - this.createdCollections.push({ name: collectionName, size: options.vectors.size }); + ): Effect.Effect<void> { + return Effect.sync(() => { + this.collections.add(collectionName); + this.createdCollections.push({ name: collectionName, size: options.vectors.size }); + }); } - async upsert( + upsert( collectionName: string, options: { readonly points: ReadonlyArray<FakePoint> }, - ): Promise<void> { - this.upserts.push({ collectionName, points: options.points }); + ): Effect.Effect<void> { + return Effect.sync(() => { + this.upserts.push({ collectionName, points: options.points }); + }); } - async getCollections(): Promise<{ readonly collections: ReadonlyArray<{ readonly name: string }> }> { - return { collections: Array.from(this.collections, (name) => ({ name })) }; + readonly getCollections: Effect.Effect<{ readonly collections: ReadonlyArray<{ readonly name: string }> }> = + Effect.sync(() => ({ collections: Array.from(this.collections, (name) => ({ name })) })); + + deleteCollection(collectionName: string): Effect.Effect<void> { + return Effect.sync(() => { + this.collections.delete(collectionName); + this.deletedCollections.push(collectionName); + }); } - async deleteCollection(collectionName: string): Promise<void> { - this.collections.delete(collectionName); - this.deletedCollections.push(collectionName); - } - - async search( + search( _collectionName: string, _options: { readonly vector: ReadonlyArray<number>; readonly limit: number; readonly with_payload: boolean; }, - ): Promise<ReadonlyArray<QdrantScoredPoint>> { - return this.searchResults; + ): Effect.Effect<ReadonlyArray<QdrantScoredPoint>> { + return Effect.sync(() => this.searchResults); } } @@ -206,22 +213,22 @@ describe("Qdrant embeddings", () => { }); await Effect.runPromise( - store.storeEffect({ + store.store({ user: "alice", collection: "docs", chunks: [{ chunkId: "chunk-a", vector: [1, 2], content: "alpha" }], }), ); await Effect.runPromise( - store.storeEffect({ + store.store({ user: "alice", collection: "docs", chunks: [{ chunkId: "chunk-b", vector: [2, 1], content: "beta" }], }), ); - await Effect.runPromise(store.deleteCollectionEffect("alice", "docs")); + await Effect.runPromise(store.deleteCollection("alice", "docs")); await Effect.runPromise( - store.storeEffect({ + store.store({ user: "alice", collection: "docs", chunks: [{ chunkId: "chunk-c", vector: [1, 1], content: "gamma" }], diff --git a/ts/packages/flow/src/__tests__/text-completion-common.test.ts b/ts/packages/flow/src/__tests__/text-completion-common.test.ts index 1c4a94af..822a1b23 100644 --- a/ts/packages/flow/src/__tests__/text-completion-common.test.ts +++ b/ts/packages/flow/src/__tests__/text-completion-common.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import type { LlmChunk } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Context, Effect, Stream } from "effect"; import { AiError, LanguageModel, Response } from "effect/unstable/ai"; import { llmStreamPart, @@ -9,10 +8,9 @@ import { providerStatusError, streamTextCompletionChunks, textFromContent, - toAsyncGenerator, } from "../model/text-completion/common.js"; -const languageModelRuntime = ManagedRuntime.make(Layer.empty); +const languageModelContext = Context.empty(); const usage = (inputTokens: number, outputTokens: number) => ({ inputTokens: { @@ -42,12 +40,6 @@ const aiError = (reason: AiError.AiErrorReason) => reason, }); -const emptyChunkIterator = (): AsyncIterable<LlmChunk> => ({ - [Symbol.asyncIterator]: () => ({ - next: () => Promise.resolve({ done: true, value: undefined }), - }), -}); - describe("text completion common helpers", () => { it("maps provider rate-limit status fields to tagged retry errors", () => { expect(providerStatusError("test-provider", { status: 429 })).toMatchObject({ @@ -61,19 +53,6 @@ describe("text completion common helpers", () => { }); }); - it("maps fallback generator throw failures into tagged provider errors", async () => { - const generator = toAsyncGenerator( - emptyChunkIterator(), - (error) => providerRuntimeError("test-provider", error), - ); - - await expect(generator.throw("provider failed")).rejects.toMatchObject({ - _tag: "TextCompletionProviderError", - provider: "test-provider", - message: "provider failed", - }); - }); - it.effect( "builds streaming chunks from async iterables with final token totals", Effect.fnUntraced(function* () { @@ -117,107 +96,129 @@ describe("text completion common helpers", () => { expect(textFromContent([{ text: 1 }])).toBe(""); }); - it("adapts Effect LanguageModel generateText responses to LlmProvider results", async () => { - const provider = makeLanguageModelProvider({ - provider: "FakeLanguageModel", - defaultModel: "fake-model", - defaultTemperature: 0.1, - runtime: languageModelRuntime, - makeLanguageModel: ({ model, temperature }) => - LanguageModel.make({ - generateText: () => - Effect.succeed([ - { type: "text", text: `model=${model};temperature=${temperature}` }, - finishPart(11, 7), - ]), - streamText: () => Stream.empty, - }), - }); - - await expect(provider.generateContent("system", "prompt", "override-model", 0.4)).resolves.toEqual({ - text: "model=override-model;temperature=0.4", - inToken: 11, - outToken: 7, - model: "override-model", - }); - }); - - it("adapts Effect LanguageModel stream parts to TrustGraph chunks", async () => { - const provider = makeLanguageModelProvider({ - provider: "FakeLanguageModel", - defaultModel: "fake-stream-model", - defaultTemperature: 0, - runtime: languageModelRuntime, - makeLanguageModel: () => - LanguageModel.make({ - generateText: () => - Effect.succeed([ - { type: "text", text: "unused" }, - finishPart(1, 1), - ]), - streamText: () => - Stream.fromArray([ - Response.makePart("text-start", { id: "part-1" }), - { type: "text-delta", id: "part-1", delta: "hel" }, - { type: "text-delta", id: "part-1", delta: "lo" }, - finishPart(13, 8), - ]), - }), - }); - - const chunks: Array<LlmChunk> = []; - for await (const chunk of provider.generateContentStream("system", "prompt")) { - chunks.push(chunk); - } - - expect(chunks).toEqual([ - { - text: "hel", - inToken: null, - outToken: null, - model: "fake-stream-model", - isFinal: false, - }, - { - text: "lo", - inToken: null, - outToken: null, - model: "fake-stream-model", - isFinal: false, - }, - { - text: "", - inToken: 13, - outToken: 8, - model: "fake-stream-model", - isFinal: true, - }, - ]); - }); - - it("maps Effect AI rate and quota failures to TrustGraph retry errors", async () => { - const reasons = [ - new AiError.RateLimitError({}), - new AiError.QuotaExhaustedError({}), - ]; - - for (const reason of reasons) { + it.effect( + "adapts Effect LanguageModel generateText responses to LlmProvider results", + Effect.fnUntraced(function* () { const provider = makeLanguageModelProvider({ provider: "FakeLanguageModel", defaultModel: "fake-model", - defaultTemperature: 0, - runtime: languageModelRuntime, - makeLanguageModel: () => + defaultTemperature: 0.1, + context: languageModelContext, + makeLanguageModel: ({ model, temperature }) => LanguageModel.make({ - generateText: () => Effect.fail(aiError(reason)), - streamText: () => Stream.fail(aiError(reason)), + generateText: () => + Effect.succeed([ + { type: "text", text: `model=${model};temperature=${temperature}` }, + finishPart(11, 7), + ]), + streamText: () => Stream.empty, }), }); - await expect(provider.generateContent("system", "prompt")).rejects.toMatchObject({ - _tag: "TooManyRequestsError", - message: "Rate limit exceeded", + const result = yield* provider.generateContent("system", "prompt", "override-model", 0.4); + expect(result).toEqual({ + text: "model=override-model;temperature=0.4", + inToken: 11, + outToken: 7, + model: "override-model", }); - } - }); + }), + ); + + it.effect( + "adapts Effect LanguageModel stream parts to TrustGraph chunks", + Effect.fnUntraced(function* () { + const provider = makeLanguageModelProvider({ + provider: "FakeLanguageModel", + defaultModel: "fake-stream-model", + defaultTemperature: 0, + context: languageModelContext, + makeLanguageModel: () => + LanguageModel.make({ + generateText: () => + Effect.succeed([ + { type: "text", text: "unused" }, + finishPart(1, 1), + ]), + streamText: () => + Stream.fromArray([ + Response.makePart("text-start", { id: "part-1" }), + { type: "text-delta", id: "part-1", delta: "hel" }, + { type: "text-delta", id: "part-1", delta: "lo" }, + finishPart(13, 8), + ]), + }), + }); + + const chunks = yield* Stream.runCollect( + provider.generateContentStream("system", "prompt"), + ); + + expect(Array.from(chunks)).toEqual([ + { + text: "hel", + inToken: null, + outToken: null, + model: "fake-stream-model", + isFinal: false, + }, + { + text: "lo", + inToken: null, + outToken: null, + model: "fake-stream-model", + isFinal: false, + }, + { + text: "", + inToken: 13, + outToken: 8, + model: "fake-stream-model", + isFinal: true, + }, + ]); + }), + ); + + it.effect( + "maps Effect AI rate and quota failures to TrustGraph retry errors", + Effect.fnUntraced(function* () { + const reasons = [ + new AiError.RateLimitError({}), + new AiError.QuotaExhaustedError({}), + ]; + + for (const reason of reasons) { + const provider = makeLanguageModelProvider({ + provider: "FakeLanguageModel", + defaultModel: "fake-model", + defaultTemperature: 0, + context: languageModelContext, + makeLanguageModel: () => + LanguageModel.make({ + generateText: () => Effect.fail(aiError(reason)), + streamText: () => Stream.fail(aiError(reason)), + }), + }); + + const generateError = yield* provider.generateContent("system", "prompt").pipe( + Effect.flip, + ); + expect(generateError).toMatchObject({ + _tag: "TooManyRequestsError", + message: "Rate limit exceeded", + }); + + const streamError = yield* Stream.runCollect( + provider.generateContentStream("system", "prompt"), + ).pipe( + Effect.flip, + ); + expect(streamError).toMatchObject({ + _tag: "TooManyRequestsError", + message: "Rate limit exceeded", + }); + } + }), + ); }); diff --git a/ts/packages/flow/src/agent/mcp-tool/index.ts b/ts/packages/flow/src/agent/mcp-tool/index.ts index 312f1ef1..5a3bd7bc 100644 --- a/ts/packages/flow/src/agent/mcp-tool/index.ts +++ b/ts/packages/flow/src/agent/mcp-tool/index.ts @@ -1 +1 @@ -export { McpToolService, run, runMain } from "./service.js"; +export { McpToolService, program, runMain } from "./service.js"; diff --git a/ts/packages/flow/src/agent/mcp-tool/service.ts b/ts/packages/flow/src/agent/mcp-tool/service.ts index 303136c8..efa9cb47 100644 --- a/ts/packages/flow/src/agent/mcp-tool/service.ts +++ b/ts/packages/flow/src/agent/mcp-tool/service.ts @@ -31,7 +31,7 @@ import { type MessagingDeliveryError, type Spec, } from "@trustgraph/base"; -import { Context, Effect, Layer, ManagedRuntime, Ref } from "effect"; +import { Context, Effect, Layer, Ref } from "effect"; import * as O from "effect/Option"; import * as S from "effect/Schema"; @@ -342,9 +342,9 @@ export function makeMcpToolService(config: ProcessorConfig): McpToolService { provide: (effect) => effect.pipe(Effect.provideService(McpToolRuntime, runtime)), }); service.registerConfigHandler((pushedConfig, version) => - Effect.runPromise(onMcpConfig(pushedConfig, version).pipe( + onMcpConfig(pushedConfig, version).pipe( Effect.provideService(McpToolRuntime, runtime), - )), + ), ); return service; } @@ -358,12 +358,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolR layer: () => McpToolRuntimeLive, }); -const mcpToolRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return mcpToolRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/agent/react/service.ts b/ts/packages/flow/src/agent/react/service.ts index 8dcac144..0d4763fd 100644 --- a/ts/packages/flow/src/agent/react/service.ts +++ b/ts/packages/flow/src/agent/react/service.ts @@ -46,7 +46,7 @@ import { type MessagingDeliveryError, type Spec, } from "@trustgraph/base"; -import {Context, Effect, Layer, ManagedRuntime, Match, Ref} from "effect"; +import {Context, Effect, Layer, Match, Ref} from "effect"; import * as O from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; @@ -64,13 +64,6 @@ import type { AgentTool, ToolArg } from "./types.js"; const MAX_ITERATIONS = 10; -class AgentToolExecutionError extends S.TaggedErrorClass<AgentToolExecutionError>()( - "AgentToolExecutionError", - { - message: S.String, - }, -) {} - const AgentResponseProducer = makeProducerSpec<AgentResponse>("agent-response"); const AgentLlmClient = makeRequestResponseSpec<TextCompletionRequest, TextCompletionResponse>( "llm", @@ -157,7 +150,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi : "Query the knowledge graph for information about entities and their relationships.", args: [{ name: "question", type: "string", description: "The question to ask" }], config, - execute: () => Promise.resolve(""), + execute: () => Effect.succeed(""), }) ), @@ -170,7 +163,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi : "Search documents for relevant information.", args: [{ name: "question", type: "string", description: "The question to search for" }], config, - execute: () => Promise.resolve(""), + execute: () => Effect.succeed(""), }) ), @@ -187,7 +180,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi { name: "object", type: "string", description: "Object entity (optional)" }, ], config, - execute: () => Promise.resolve(""), + execute: () => Effect.succeed(""), }) ), @@ -203,7 +196,7 @@ const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(functi description, args, config, - execute: () => Promise.resolve(""), + execute: () => Effect.succeed(""), }); }), @@ -355,12 +348,9 @@ const executeTool = ( tool: AgentTool, input: string, ): Effect.Effect<string> => - Effect.tryPromise({ - try: () => tool.execute(input), - catch: (cause) => AgentToolExecutionError.make({ message: errorMessage(cause) }), - }).pipe( - Effect.catch((error: AgentToolExecutionError) => - Effect.succeed(`Error executing tool: ${error.message}`), + tool.execute(input).pipe( + Effect.catch((cause) => + Effect.succeed(`Error executing tool: ${errorMessage(cause)}`), ), ); @@ -520,9 +510,9 @@ export function makeAgentService(config: ProcessorConfig): AgentService { provide: (effect) => effect.pipe(Effect.provideService(AgentRuntime, runtime)), }); service.registerConfigHandler((pushedConfig, version) => - Effect.runPromise(onToolsConfig(pushedConfig, version).pipe( + onToolsConfig(pushedConfig, version).pipe( Effect.provideService(AgentRuntime, runtime), - )), + ), ); Effect.runSync(Effect.log("[AgentService] Service initialized")); return service; @@ -616,12 +606,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, AgentRun layer: () => AgentRuntimeLive, }); -const agentRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return agentRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/agent/react/tools.ts b/ts/packages/flow/src/agent/react/tools.ts index 271ad0ad..023359a0 100644 --- a/ts/packages/flow/src/agent/react/tools.ts +++ b/ts/packages/flow/src/agent/react/tools.ts @@ -24,7 +24,7 @@ import * as O from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; -import type { AgentTool, ToolArg } from "./types.js"; +import { agentToolError, type AgentTool, type ToolArg } from "./types.js"; const decodeJsonUnknown = S.decodeUnknownOption(S.UnknownFromJsonString); const decodeTerm = S.decodeUnknownOption(TermSchema); @@ -88,14 +88,16 @@ export function createKnowledgeQueryTool( description: "The question to ask the knowledge graph", }, ], - execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () { + execute: Effect.fn("KnowledgeQuery.execute")(function* (input: string) { const question = parseQuestion(input); yield* Effect.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`); const request: GraphRagRequest = { query: question, ...(collection !== undefined ? { collection } : {}), }; - const res = yield* client.request(request); + const res = yield* client.request(request).pipe( + Effect.mapError((cause) => agentToolError("knowledge-query", cause)), + ); yield* Effect.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`); const explainTriples = res.explain_triples; @@ -108,7 +110,7 @@ export function createKnowledgeQueryTool( if (res.error !== undefined) return `Error: ${res.error.message}`; return res.response; - })), + }), }; } @@ -130,16 +132,18 @@ export function createDocumentQueryTool( description: "The question to search documents for", }, ], - execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () { + execute: Effect.fn("DocumentQuery.execute")(function* (input: string) { const question = parseQuestion(input); const request: DocumentRagRequest = { query: question, ...(collection !== undefined ? { collection } : {}), }; - const res = yield* client.request(request); + const res = yield* client.request(request).pipe( + Effect.mapError((cause) => agentToolError("document-query", cause)), + ); if (res.error !== undefined) return `Error: ${res.error.message}`; return res.response; - })), + }), }; } @@ -221,7 +225,7 @@ export function createTriplesQueryTool( description: "The object entity to search for (optional)", }, ], - execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () { + execute: Effect.fn("TriplesQuery.execute")(function* (input: string) { const { s, p, o, limit } = parseTriplesInput(input); const request: TriplesQueryRequest = { limit: limit ?? 20, @@ -230,7 +234,9 @@ export function createTriplesQueryTool( ...(o !== undefined ? { o } : {}), ...(collection !== undefined ? { collection } : {}), }; - const res = yield* client.request(request); + const res = yield* client.request(request).pipe( + Effect.mapError((cause) => agentToolError("triples-query", cause)), + ); if (res.error !== undefined) return `Error: ${res.error.message}`; @@ -243,7 +249,7 @@ export function createTriplesQueryTool( `(${termToString(t.s)}) -[${termToString(t.p)}]-> (${termToString(t.o)})`, ); return lines.join("\n"); - })), + }), }; } @@ -263,12 +269,14 @@ export function createMcpTool( name: toolName, description, args, - execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () { - const res = yield* client.request({ name: toolName, parameters: input }); + execute: Effect.fn("McpTool.execute")(function* (input: string) { + const res = yield* client.request({ name: toolName, parameters: input }).pipe( + Effect.mapError((cause) => agentToolError("mcp-tool", cause)), + ); 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/react/types.ts b/ts/packages/flow/src/agent/react/types.ts index 88b29f21..23daefbc 100644 --- a/ts/packages/flow/src/agent/react/types.ts +++ b/ts/packages/flow/src/agent/react/types.ts @@ -2,6 +2,24 @@ * Types for the ReAct agent service. */ +import type { Effect } from "effect"; +import * as S from "effect/Schema"; +import { errorMessage } from "@trustgraph/base"; + +export class AgentToolError extends S.TaggedErrorClass<AgentToolError>()( + "AgentToolError", + { + message: S.String, + operation: S.String, + }, +) {} + +export const agentToolError = (operation: string, cause: unknown): AgentToolError => + AgentToolError.make({ + operation, + message: errorMessage(cause), + }); + export interface ToolArg { name: string; type: string; @@ -12,7 +30,7 @@ export interface AgentTool { name: string; description: string; args: ToolArg[]; - execute: (input: string) => Promise<string>; + execute: (input: string) => Effect.Effect<string, AgentToolError>; /** Full tool config from config-push (used by tool filtering). */ config?: Record<string, unknown>; } @@ -30,6 +48,6 @@ export interface ParsedEvent { content: string; } -export type OnThought = (text: string, isFinal: boolean) => Promise<void>; -export type OnObservation = (text: string, isFinal: boolean) => Promise<void>; -export type OnAnswer = (text: string) => Promise<void>; +export type OnThought = (text: string, isFinal: boolean) => Effect.Effect<void, AgentToolError>; +export type OnObservation = (text: string, isFinal: boolean) => Effect.Effect<void, AgentToolError>; +export type OnAnswer = (text: string) => Effect.Effect<void, AgentToolError>; diff --git a/ts/packages/flow/src/chunking/service.ts b/ts/packages/flow/src/chunking/service.ts index 13e88d60..3dfd3991 100644 --- a/ts/packages/flow/src/chunking/service.ts +++ b/ts/packages/flow/src/chunking/service.ts @@ -26,14 +26,14 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import * as S from "effect/Schema"; import { recursiveSplit } from "./recursive-splitter.js"; const DEFAULT_CHUNK_SIZE = 2000; const DEFAULT_CHUNK_OVERLAP = 100; -const ChunkSizeParameter = makeParameterSpec("chunk-size", S.Number); -const ChunkOverlapParameter = makeParameterSpec("chunk-overlap", S.Number); +const ChunkSizeParameter = makeParameterSpec("chunk-size", S.Finite); +const ChunkOverlapParameter = makeParameterSpec("chunk-overlap", S.Finite); const ChunkOutputProducer = makeProducerSpec<Chunk>("chunk-output"); const ChunkTriplesProducer = makeProducerSpec<Triples>("chunk-triples"); @@ -108,12 +108,6 @@ export const program = makeFlowProcessorProgram({ specs: () => makeChunkingSpecs(), }); -const chunkingRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return chunkingRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/config/service.ts b/ts/packages/flow/src/config/service.ts index 46b7bb97..1be6a7fc 100644 --- a/ts/packages/flow/src/config/service.ts +++ b/ts/packages/flow/src/config/service.ts @@ -5,7 +5,7 @@ */ import {NodeRuntime} from "@effect/platform-node"; -import {Duration, Effect, HashMap, Layer, ManagedRuntime, Match, Option, SynchronizedRef} from "effect"; +import {Duration, Effect, HashMap, Match, Option, SynchronizedRef} from "effect"; import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; import { @@ -16,6 +16,7 @@ import { makeAsyncProcessor, makeProcessorProgram, optionalStringConfig, + processorLifecycleError, topics, type AsyncProcessorRuntime, type BackendConsumer, @@ -26,7 +27,7 @@ import { type Message, type ProcessorConfig, } from "@trustgraph/base"; -import {readTextFile, writeTextFile} from "../runtime/effect-files.js"; +import {readTextFileEffect, writeTextFileEffect} from "../runtime/effect-files.js"; export interface ConfigServiceConfig extends ProcessorConfig { readonly persistPath?: string; @@ -38,7 +39,7 @@ interface ConfigPush { } const ConfigPushSchema = S.Struct({ - version: S.Number, + version: S.Finite, config: S.Record(S.String, S.Unknown), }); @@ -84,7 +85,7 @@ interface ConfigServiceState { } const PersistedConfigSchema = S.Struct({ - version: S.optionalKey(S.Number), + version: S.optionalKey(S.Finite), data: S.optionalKey(S.Record(S.String, S.Record(S.String, S.Unknown))), workspaces: S.optionalKey(S.Record(S.String, S.Record(S.String, S.Record(S.String, S.Unknown)))), }); @@ -94,24 +95,17 @@ type PersistedConfig = typeof PersistedConfigSchema.Type; export interface ConfigService extends AsyncProcessorRuntime<ConfigServiceError> { readonly state: SynchronizedRef.SynchronizedRef<ConfigServiceState>; readonly persistPath: string | null; - readonly handleMessage: (msg: Message<ConfigRequest>) => Promise<void>; readonly handleMessageEffect: (msg: Message<ConfigRequest>) => Effect.Effect<void, ConfigServiceError>; - readonly handleOperation: (request: ConfigRequest) => Promise<ConfigResponse>; readonly handleOperationEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, ConfigServiceError>; readonly handleGet: (request: ConfigRequest) => ConfigResponse; - readonly handlePut: (request: ConfigRequest) => Promise<ConfigResponse>; readonly handlePutEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, ConfigServiceError>; - readonly handleDelete: (request: ConfigRequest) => Promise<ConfigResponse>; readonly handleDeleteEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, ConfigServiceError>; readonly handleList: (request: ConfigRequest) => ConfigResponse; readonly handleGetValues: (request: ConfigRequest) => ConfigResponse; readonly handleGetValuesAllWorkspaces: (request: ConfigRequest) => ConfigResponse; readonly handleConfigDump: (request: ConfigRequest) => ConfigResponse; - readonly pushConfig: () => Promise<void>; readonly pushConfigEffect: Effect.Effect<void, ConfigServiceError>; - readonly persist: () => Promise<void>; readonly persistEffect: Effect.Effect<void>; - readonly loadFromDisk: () => Promise<void>; readonly loadFromDiskEffect: Effect.Effect<void>; } @@ -325,10 +319,9 @@ const persistStateEffect = Effect.fn("ConfigService.persistState")( Effect.mapError((cause) => configServiceError("persist-encode", cause)), ); - yield* Effect.tryPromise({ - try: () => writeTextFile(persistPath, json), - catch: (cause) => configServiceError("persist-write", cause), - }); + yield* writeTextFileEffect(persistPath, json).pipe( + Effect.mapError((cause) => configServiceError("persist-write", cause)), + ); }, (effect) => effect.pipe( @@ -344,24 +337,21 @@ const pushConfigWithStateEffect = Effect.fn("ConfigService.pushConfigWithState") const pushProducer = state.pushProducer; if (pushProducer === null) return; - yield* Effect.tryPromise({ - try: () => - pushProducer.send({ - version: state.version, - config: configDumpForState(state), - }), - catch: (cause) => configServiceError("push-config", cause), - }); + yield* pushProducer.send({ + version: state.version, + config: configDumpForState(state), + }).pipe( + Effect.mapError((cause) => configServiceError("push-config", cause)), + ); yield* Effect.log(`[ConfigService] Pushed configuration version ${state.version}`); }); const readPersistedConfigEffect = Effect.fn("ConfigService.readPersistedConfig")( function* (persistPath: string) { - const raw = yield* Effect.tryPromise({ - try: () => readTextFile(persistPath), - catch: (cause) => configServiceError("persist-read", cause), - }); + const raw = yield* readTextFileEffect(persistPath).pipe( + Effect.mapError((cause) => configServiceError("persist-read", cause)), + ); return yield* S.decodeUnknownEffect(PersistedConfigJsonSchema)(raw).pipe( Effect.mapError((cause) => configServiceError("persist-decode", cause)), ); @@ -644,24 +634,21 @@ const closeConfigResourcesEffect = Effect.fn("ConfigService.closeResources")(fun const consumer = state.consumer; if (consumer !== null) { - yield* Effect.tryPromise({ - try: () => consumer.close(), - catch: (cause) => configServiceError("close-consumer", cause), - }); + yield* consumer.close.pipe( + Effect.mapError((cause) => configServiceError("close-consumer", cause)), + ); } const responseProducer = state.responseProducer; if (responseProducer !== null) { - yield* Effect.tryPromise({ - try: () => responseProducer.close(), - catch: (cause) => configServiceError("close-response-producer", cause), - }); + yield* responseProducer.close.pipe( + Effect.mapError((cause) => configServiceError("close-response-producer", cause)), + ); } const pushProducer = state.pushProducer; if (pushProducer !== null) { - yield* Effect.tryPromise({ - try: () => pushProducer.close(), - catch: (cause) => configServiceError("close-push-producer", cause), - }); + yield* pushProducer.close.pipe( + Effect.mapError((cause) => configServiceError("close-push-producer", cause)), + ); } yield* updateHandles(stateRef, { @@ -680,17 +667,15 @@ const consumeOnceEffect = Effect.fnUntraced(function* ( return yield* configServiceError("consume", "Config consumer not started"); } - const msg = yield* Effect.tryPromise({ - try: () => consumer.receive(2000), - catch: (cause) => configServiceError("consume-receive", cause), - }); + const msg = yield* consumer.receive(2000).pipe( + Effect.mapError((cause) => configServiceError("consume-receive", cause)), + ); if (msg === null) return; yield* service.handleMessageEffect(msg); - yield* Effect.tryPromise({ - try: () => consumer.acknowledge(msg), - catch: (cause) => configServiceError("consume-acknowledge", cause), - }); + yield* consumer.acknowledge(msg).pipe( + Effect.mapError((cause) => configServiceError("consume-acknowledge", cause)), + ); }); const runConfigServiceEffect = Effect.fn("ConfigService.run")(function* ( @@ -698,35 +683,29 @@ const runConfigServiceEffect = Effect.fn("ConfigService.run")(function* ( ) { yield* service.loadFromDiskEffect; - const responseProducer = yield* Effect.tryPromise({ - try: () => - service.pubsub.createProducer<ConfigResponse>({ - topic: topics.configResponse, - schema: ConfigResponseSchema, - }), - catch: (cause) => configServiceError("response-producer", cause), - }); + const responseProducer = yield* service.pubsub.createProducer<ConfigResponse>({ + topic: topics.configResponse, + schema: ConfigResponseSchema, + }).pipe( + Effect.mapError((cause) => configServiceError("response-producer", cause)), + ); yield* updateHandles(service.state, {responseProducer}); - const pushProducer = yield* Effect.tryPromise({ - try: () => - service.pubsub.createProducer<ConfigPush>({ - topic: topics.configPush, - schema: ConfigPushSchema, - }), - catch: (cause) => configServiceError("push-producer", cause), - }); + const pushProducer = yield* service.pubsub.createProducer<ConfigPush>({ + topic: topics.configPush, + schema: ConfigPushSchema, + }).pipe( + Effect.mapError((cause) => configServiceError("push-producer", cause)), + ); yield* updateHandles(service.state, {pushProducer}); - const consumer = yield* Effect.tryPromise({ - try: () => - service.pubsub.createConsumer<ConfigRequest>({ - topic: topics.configRequest, - subscription: `${service.config.id}-config-request`, - schema: ConfigRequestSchema, - }), - catch: (cause) => configServiceError("consumer", cause), - }); + const consumer = yield* service.pubsub.createConsumer<ConfigRequest>({ + topic: topics.configRequest, + subscription: `${service.config.id}-config-request`, + schema: ConfigRequestSchema, + }).pipe( + Effect.mapError((cause) => configServiceError("consumer", cause)), + ); const state = yield* updateHandles(service.state, {consumer}); yield* pushConfigWithStateEffect(state); @@ -762,7 +741,6 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService { const base = makeAsyncProcessor<ConfigServiceError>(config, { runEffect: () => getService.pipe(Effect.flatMap(runConfigServiceEffect)), }); - const baseStop = base.stop; const persistPath = config.persistPath ?? null; const handleOperationEffect = Effect.fn("ConfigService.handleOperation")(function* ( @@ -800,10 +778,9 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService { if (responseProducer === null) { return yield* configServiceError("respond", "Config response producer not started"); } - yield* Effect.tryPromise({ - try: () => responseProducer.send(response, {id: requestId}), - catch: (cause) => configServiceError("respond", cause), - }); + yield* responseProducer.send(response, {id: requestId}).pipe( + Effect.mapError((cause) => configServiceError("respond", cause)), + ); }); yield* handleOperationEffect(request).pipe( @@ -830,40 +807,42 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService { yield* Effect.log(`[ConfigService] Loaded persisted config (version=${next.version}, workspaces=${HashMap.size(next.store)})`); }); - service = Object.assign(base, { + const serviceStopEffect = closeConfigResourcesEffect(state).pipe( + Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)), + Effect.flatMap(() => base.stop), + ); + + const serviceBase = Object.create(base, { + stop: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + stopEffect: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + }); + + service = Object.assign(serviceBase, { state, persistPath, - handleMessage: (msg: Message<ConfigRequest>) => Effect.runPromise(handleMessageEffect(msg)), handleMessageEffect, - handleOperation: (request: ConfigRequest) => Effect.runPromise(handleOperationEffect(request)), handleOperationEffect, handleGet: (request: ConfigRequest) => handleGetWithState(stateSnapshot(state), request), - handlePut: (request: ConfigRequest) => Effect.runPromise(handlePutEffect(state, persistPath, request)), handlePutEffect: (request: ConfigRequest) => handlePutEffect(state, persistPath, request), - handleDelete: (request: ConfigRequest) => Effect.runPromise(handleDeleteEffect(state, persistPath, request)), handleDeleteEffect: (request: ConfigRequest) => handleDeleteEffect(state, persistPath, request), handleList: (request: ConfigRequest) => handleListWithState(stateSnapshot(state), request), handleGetValues: (request: ConfigRequest) => handleGetValuesWithState(stateSnapshot(state), request), handleGetValuesAllWorkspaces: (request: ConfigRequest) => handleGetValuesAllWorkspacesWithState(stateSnapshot(state), request), handleConfigDump: (request: ConfigRequest) => handleConfigDumpWithState(stateSnapshot(state), request), - pushConfig: () => Effect.runPromise(SynchronizedRef.get(state).pipe(Effect.flatMap(pushConfigWithStateEffect))), pushConfigEffect: SynchronizedRef.get(state).pipe(Effect.flatMap(pushConfigWithStateEffect)), - persist: () => Effect.runPromise(SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current)))), persistEffect: SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current))), - loadFromDisk: () => Effect.runPromise(loadFromDiskEffect()), loadFromDiskEffect: loadFromDiskEffect(), - stop: () => - Effect.runPromise( - closeConfigResourcesEffect(state).pipe( - Effect.flatMap(() => - Effect.tryPromise({ - try: () => baseStop(), - catch: (cause) => configServiceError("stop", cause), - }) - ), - ), - ), - }); + }) as ConfigService; return service; } @@ -887,12 +866,6 @@ export const program = makeProcessorProgram({ make: (config) => makeConfigService(config), }); -const configServiceRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return configServiceRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/cores/service.ts b/ts/packages/flow/src/cores/service.ts index ae8f513c..8756cadb 100644 --- a/ts/packages/flow/src/cores/service.ts +++ b/ts/packages/flow/src/cores/service.ts @@ -15,6 +15,7 @@ import { makeAsyncProcessor, makeProcessorProgram, optionalStringConfig, + processorLifecycleError, topics, type AsyncProcessorRuntime, type BackendConsumer, @@ -24,17 +25,18 @@ import { type KnowledgeResponse, type Message, type ProcessorConfig, + type PubSubError, } from "@trustgraph/base"; -import {Duration, Effect, HashMap, Layer, ManagedRuntime, Match, SynchronizedRef} from "effect"; +import {Duration, Effect, HashMap, Match, SynchronizedRef} from "effect"; import * as O from "effect/Option"; import * as S from "effect/Schema"; -import {ensureDirectory, joinPath, readTextFile, writeTextFile} from "../runtime/effect-files.js"; +import {ensureDirectoryEffect, joinPath, readTextFileEffect, writeTextFileEffect} from "../runtime/effect-files.js"; export interface KnowledgeCoreServiceConfig extends ProcessorConfig { readonly dataDir?: string; } -const NumberArray = S.Array(S.Number).pipe(S.mutable); +const NumberArray = S.Array(S.Finite).pipe(S.mutable); const NumberArrays = S.Array(NumberArray).pipe(S.mutable); const GraphEmbeddingSchema = S.Struct({ @@ -98,35 +100,20 @@ export interface KnowledgeCoreService extends AsyncProcessorRuntime<KnowledgeCor readonly coreKey: (user: string, id: string) => string; readonly graphEmbeddings: (request: KnowledgeRequest) => ReadonlyArray<GraphEmbedding>; readonly documentEmbeddings: (request: KnowledgeRequest) => DocumentEmbeddingsCore | undefined; - readonly handleMessage: (msg: Message<KnowledgeRequest>) => Promise<void>; readonly handleMessageEffect: (msg: Message<KnowledgeRequest>) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly handleOperation: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly handleOperationEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly listKgCores: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly listKgCoresEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly getKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly getKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly deleteKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly deleteKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly putKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly putKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly loadKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly loadKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly unloadKgCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly unloadKgCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly listDeCores: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly listDeCoresEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly getDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly getDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly deleteDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly deleteDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly putDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly putDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly loadDeCore: (request: KnowledgeRequest, requestId: string) => Promise<void>; readonly loadDeCoreEffect: (request: KnowledgeRequest, requestId: string) => Effect.Effect<void, KnowledgeCoreServiceError>; - readonly persist: () => Promise<void>; readonly persistEffect: Effect.Effect<void, never>; - readonly loadFromDisk: () => Promise<void>; readonly loadFromDiskEffect: Effect.Effect<void, never>; } @@ -204,20 +191,12 @@ const updateHandles = ( responseProducer: handles.responseProducer === undefined ? state.responseProducer : handles.responseProducer, })); -const tryPromise = <A>( - operation: string, - evaluate: () => Promise<A>, -): Effect.Effect<A, KnowledgeCoreServiceError> => - Effect.tryPromise({ - try: evaluate, - catch: (cause) => knowledgeCoreServiceError(operation, cause), - }); - const closeResource = ( - resource: {readonly close: () => Promise<void>}, + resource: {readonly close: Effect.Effect<void, PubSubError>}, operation: string, ): Effect.Effect<void> => - tryPromise(operation, () => resource.close()).pipe( + resource.close.pipe( + Effect.mapError((cause) => knowledgeCoreServiceError(operation, cause)), Effect.catch((error) => Effect.logError("[KnowledgeCoreService] Failed to close resource", { error: error.message, @@ -237,12 +216,16 @@ const sendResponse = Effect.fnUntraced(function* ( return yield* knowledgeCoreServiceError(operation, "Knowledge response producer not started"); } - yield* tryPromise(operation, () => responseProducer.send(response, {id: requestId})); + yield* responseProducer.send(response, {id: requestId}).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError(operation, cause)), + ); }); const readPersistedKnowledgeEffect = Effect.fn("KnowledgeCoreService.readPersistedKnowledge")( function* (persistPath: string) { - const raw = yield* tryPromise("load-read", () => readTextFile(persistPath)); + const raw = yield* readTextFileEffect(persistPath).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("load-read", cause)), + ); const current = S.decodeUnknownOption(PersistedKnowledgeSnapshotJsonSchema)(raw); if (O.isSome(current)) { return { @@ -282,7 +265,9 @@ const persistStateEffect = Effect.fn("KnowledgeCoreService.persistState")( const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(snapshot).pipe( Effect.mapError((cause) => knowledgeCoreServiceError("persist-encode", cause)), ); - yield* tryPromise("persist-write", () => writeTextFile(persistPath, json)); + yield* writeTextFileEffect(persistPath, json).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("persist-write", cause)), + ); }, (effect) => effect.pipe( @@ -317,12 +302,16 @@ const closeKnowledgeResourcesEffect = Effect.fn("KnowledgeCoreService.closeResou const consumer = state.consumer; if (consumer !== null) { - yield* tryPromise("close-consumer", () => consumer.close()); + yield* consumer.close.pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("close-consumer", cause)), + ); } const responseProducer = state.responseProducer; if (responseProducer !== null) { - yield* tryPromise("close-response-producer", () => responseProducer.close()); + yield* responseProducer.close.pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("close-response-producer", cause)), + ); } yield* updateHandles(stateRef, { @@ -339,33 +328,39 @@ const consumeOnceEffect = Effect.fnUntraced(function* ( return yield* knowledgeCoreServiceError("consume", "Knowledge request consumer not started"); } - const msg = yield* tryPromise("consume-receive", () => consumer.receive(2000)); + const msg = yield* consumer.receive(2000).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("consume-receive", cause)), + ); if (msg === null) return; yield* service.handleMessageEffect(msg); - yield* tryPromise("consume-acknowledge", () => consumer.acknowledge(msg)); + yield* consumer.acknowledge(msg).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("consume-acknowledge", cause)), + ); }); const runKnowledgeCoreServiceEffect = Effect.fn("KnowledgeCoreService.run")(function* ( service: KnowledgeCoreService, ) { - yield* tryPromise("ensure-directory", () => ensureDirectory(service.dataDir)); + yield* ensureDirectoryEffect(service.dataDir).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("ensure-directory", cause)), + ); yield* service.loadFromDiskEffect; - const responseProducer = yield* tryPromise("response-producer", () => - service.pubsub.createProducer<KnowledgeResponse>({ - topic: topics.knowledgeResponse, - schema: KnowledgeResponseSchema, - }), + const responseProducer = yield* service.pubsub.createProducer<KnowledgeResponse>({ + topic: topics.knowledgeResponse, + schema: KnowledgeResponseSchema, + }).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("response-producer", cause)), ); yield* updateHandles(service.state, {responseProducer}); - const consumer = yield* tryPromise("consumer", () => - service.pubsub.createConsumer<KnowledgeRequest>({ - topic: topics.knowledgeRequest, - subscription: `${service.config.id}-knowledge-request`, - schema: KnowledgeRequestSchema, - }), + const consumer = yield* service.pubsub.createConsumer<KnowledgeRequest>({ + topic: topics.knowledgeRequest, + subscription: `${service.config.id}-knowledge-request`, + schema: KnowledgeRequestSchema, + }).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("consumer", cause)), ); yield* updateHandles(service.state, {consumer}); @@ -504,12 +499,11 @@ const loadKgCoreEffect = Effect.fn("loadKgCoreEffect")(function* ( if (core.triples.length > 0) { yield* Effect.acquireUseRelease( - tryPromise("triples-producer", () => - service.pubsub.createProducer<unknown>({topic: "tg.flow.triples"}), + service.pubsub.createProducer<unknown>({topic: "tg.flow.triples"}).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("triples-producer", cause)), ), (producer) => - tryPromise("send-triples", () => - producer.send({ + producer.send({ metadata: { id: coreId, root: coreId, @@ -517,8 +511,9 @@ const loadKgCoreEffect = Effect.fn("loadKgCoreEffect")(function* ( collection: request.collection ?? "default", }, triples: core.triples, - }), - ), + }).pipe( + Effect.mapError((cause) => knowledgeCoreServiceError("send-triples", cause)), + ), (producer) => closeResource(producer, "close-triples-producer"), ); } @@ -637,7 +632,6 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn const base = makeAsyncProcessor<KnowledgeCoreServiceError>(config, { runEffect: () => getService.pipe(Effect.flatMap(runKnowledgeCoreServiceEffect)), }); - const baseStop = base.stop; const handleOperationEffect = Effect.fn("KnowledgeCoreService.handleOperation")(function* ( request: KnowledgeRequest, @@ -699,54 +693,50 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn yield* Effect.log(`[KnowledgeCoreService] Loaded persisted state (kg=${HashMap.size(next.kgCores)}, de=${HashMap.size(next.deCores)})`); }); - service = Object.assign(base, { + const serviceStopEffect = closeKnowledgeResourcesEffect(state).pipe( + Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)), + Effect.flatMap(() => base.stop), + ); + + const serviceBase = Object.create(base, { + stop: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + stopEffect: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + }); + + service = Object.assign(serviceBase, { state, dataDir, persistPath, coreKey, graphEmbeddings: graphEmbeddingsFor, documentEmbeddings: documentEmbeddingsFor, - handleMessage: (msg: Message<KnowledgeRequest>) => Effect.runPromise(handleMessageEffect(msg)), handleMessageEffect, - handleOperation: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(handleOperationEffect(request, requestId)), handleOperationEffect, - listKgCores: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(listKgCoresEffect(state, request, requestId)), listKgCoresEffect: (request: KnowledgeRequest, requestId: string) => listKgCoresEffect(state, request, requestId), - getKgCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(getKgCoreEffect(state, request, requestId)), getKgCoreEffect: (request: KnowledgeRequest, requestId: string) => getKgCoreEffect(state, request, requestId), - deleteKgCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(deleteKgCoreEffect(state, persistPath, request, requestId)), deleteKgCoreEffect: (request: KnowledgeRequest, requestId: string) => deleteKgCoreEffect(state, persistPath, request, requestId), - putKgCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(putKgCoreEffect(state, persistPath, request, requestId)), putKgCoreEffect: (request: KnowledgeRequest, requestId: string) => putKgCoreEffect(state, persistPath, request, requestId), - loadKgCore: (request: KnowledgeRequest, requestId: string) => - Effect.runPromise(getService.pipe(Effect.flatMap((current) => loadKgCoreEffect(state, current, request, requestId)))), loadKgCoreEffect: (request: KnowledgeRequest, requestId: string) => getService.pipe(Effect.flatMap((current) => loadKgCoreEffect(state, current, request, requestId))), - unloadKgCore: (_request: KnowledgeRequest, requestId: string) => Effect.runPromise(sendResponse(state, {}, requestId)), unloadKgCoreEffect: (_request: KnowledgeRequest, requestId: string) => sendResponse(state, {}, requestId), - listDeCores: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(listDeCoresEffect(state, request, requestId)), listDeCoresEffect: (request: KnowledgeRequest, requestId: string) => listDeCoresEffect(state, request, requestId), - getDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(getDeCoreEffect(state, request, requestId)), getDeCoreEffect: (request: KnowledgeRequest, requestId: string) => getDeCoreEffect(state, request, requestId), - deleteDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(deleteDeCoreEffect(state, persistPath, request, requestId)), deleteDeCoreEffect: (request: KnowledgeRequest, requestId: string) => deleteDeCoreEffect(state, persistPath, request, requestId), - putDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(putDeCoreEffect(state, persistPath, request, requestId)), putDeCoreEffect: (request: KnowledgeRequest, requestId: string) => putDeCoreEffect(state, persistPath, request, requestId), - loadDeCore: (request: KnowledgeRequest, requestId: string) => Effect.runPromise(loadDeCoreEffect(state, request, requestId)), loadDeCoreEffect: (request: KnowledgeRequest, requestId: string) => loadDeCoreEffect(state, request, requestId), - persist: () => Effect.runPromise(SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current)))), persistEffect: SynchronizedRef.get(state).pipe(Effect.flatMap((current) => persistStateEffect(persistPath, current))), - loadFromDisk: () => Effect.runPromise(loadFromDiskEffect()), loadFromDiskEffect: loadFromDiskEffect(), - stop: () => - Effect.runPromise( - closeKnowledgeResourcesEffect(state).pipe( - Effect.flatMap(() => - tryPromise("base-stop", () => baseStop()) - ), - ), - ), - }); + }) as KnowledgeCoreService; return service; } @@ -770,12 +760,6 @@ export const program = makeProcessorProgram({ make: (config) => makeKnowledgeCoreService(config), }); -const knowledgeCoreRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return knowledgeCoreRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/decoding/pdf-decoder.ts b/ts/packages/flow/src/decoding/pdf-decoder.ts index 492ab157..061258ab 100644 --- a/ts/packages/flow/src/decoding/pdf-decoder.ts +++ b/ts/packages/flow/src/decoding/pdf-decoder.ts @@ -39,7 +39,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Clock, Effect, Layer, ManagedRuntime } from "effect"; +import { Clock, Effect } from "effect"; import * as S from "effect/Schema"; export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()( @@ -48,7 +48,7 @@ export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()( message: S.String, operation: S.String, documentId: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -257,12 +257,6 @@ export const program = makeFlowProcessorProgram({ specs: () => makePdfDecoderSpecs(), }); -const pdfDecoderRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return pdfDecoderRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/embeddings/ollama.ts b/ts/packages/flow/src/embeddings/ollama.ts index 97ab497a..be95cddb 100644 --- a/ts/packages/flow/src/embeddings/ollama.ts +++ b/ts/packages/flow/src/embeddings/ollama.ts @@ -5,7 +5,7 @@ */ import { NodeRuntime } from "@effect/platform-node"; -import { Config, Effect, Layer, ManagedRuntime } from "effect"; +import { Config, Effect, Layer } from "effect"; import * as O from "effect/Option"; import * as S from "effect/Schema"; import { @@ -25,7 +25,7 @@ export interface OllamaEmbeddingsConfig extends ProcessorConfig { fetch?: typeof fetch; } -const EmbeddingVector = S.Array(S.Number); +const EmbeddingVector = S.Array(S.Finite); const OllamaEmbedResponse = S.Struct({ embeddings: S.Array(EmbeddingVector), @@ -71,9 +71,6 @@ const loadOllamaEmbeddingsConfig = Effect.fn("OllamaEmbeddings.loadConfig")(func } satisfies ResolvedOllamaEmbeddingsConfig; }); -const responseJson = (response: Response): Promise<unknown> => - response.json(); - const makeOllamaEmbeddingsFromConfig = ({ defaultModel, ollamaHost, @@ -116,7 +113,7 @@ const makeOllamaEmbeddingsFromConfig = ({ } const data = yield* Effect.tryPromise({ - try: () => responseJson(response), + try: () => response.json(), catch: (error) => ollamaEmbeddingsError("ollama.response-json", error), }); const decoded = yield* S.decodeUnknownEffect(OllamaEmbedResponse)(data).pipe( @@ -166,12 +163,6 @@ export const program = makeFlowProcessorProgram<OllamaEmbeddingsConfig, Embeddin layer: (config) => OllamaEmbeddingsLive(config), }); -const ollamaEmbeddingsRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return ollamaEmbeddingsRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/extract/knowledge-extract.ts b/ts/packages/flow/src/extract/knowledge-extract.ts index 38d60d34..d53b6016 100644 --- a/ts/packages/flow/src/extract/knowledge-extract.ts +++ b/ts/packages/flow/src/extract/knowledge-extract.ts @@ -35,7 +35,7 @@ import { type Spec, } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import * as O from "effect/Option"; import * as S from "effect/Schema"; @@ -392,12 +392,6 @@ export const program = makeFlowProcessorProgram({ specs: () => makeKnowledgeExtractSpecs(), }); -const knowledgeExtractRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return knowledgeExtractRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/flow-manager/service.ts b/ts/packages/flow/src/flow-manager/service.ts index 1af460f9..7f0ddb58 100644 --- a/ts/packages/flow/src/flow-manager/service.ts +++ b/ts/packages/flow/src/flow-manager/service.ts @@ -30,23 +30,45 @@ import { type FlowRequest, type FlowResponse, errorMessage, + processorLifecycleError, } from "@trustgraph/base"; import { makeProcessorProgram } from "@trustgraph/base"; import type { Message } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; -import { Duration, Effect, HashMap, Layer, ManagedRuntime, Match, Option, SynchronizedRef } from "effect"; +import { Duration, Effect, HashMap, Match, Option, SynchronizedRef } from "effect"; import * as S from "effect/Schema"; // ---------- Internal state types ---------- -interface FlowInstance { - id: string; - blueprintName: string; - description: string; - parameters: Record<string, unknown>; - status: "running" | "stopped"; +class FlowInstanceRunning extends S.Class<FlowInstanceRunning>("FlowInstanceRunning")({ + id: S.String, + blueprintName: S.String, + description: S.optionalKey(S.String), + parameters: S.Record(S.String, S.Unknown), + status: S.tag("running") +}) {} + +class FlowInstanceStopped extends S.Class<FlowInstanceStopped>("FlowInstanceStopped")({ + id: S.String, + blueprintName: S.String, + description: S.optionalKey(S.String), + parameters: S.Record(S.String, S.Unknown), + status: S.tag("stopped") +}) { + } +export const FlowInstance = S.Union( + [ + FlowInstanceRunning, + FlowInstanceStopped + ] +).pipe( + S.toTaggedUnion("status") +); + +export type FlowInstance = typeof FlowInstance.Type; + interface Blueprint { description: string; topics: Record<string, string>; @@ -175,35 +197,21 @@ interface FlowManagerServiceState { export interface FlowManagerService extends AsyncProcessorRuntime<FlowManagerError> { readonly state: SynchronizedRef.SynchronizedRef<FlowManagerServiceState>; - readonly handleMessage: (msg: Message<FlowRequest>) => Promise<void>; readonly handleMessageEffect: (msg: Message<FlowRequest>) => Effect.Effect<void, FlowManagerError>; - readonly configRequest: (request: ConfigRequest) => Promise<ConfigResponse>; readonly configRequestEffect: (request: ConfigRequest) => Effect.Effect<ConfigResponse, FlowManagerError>; - readonly ensureDefaultBlueprint: () => Promise<void>; readonly ensureDefaultBlueprintEffect: Effect.Effect<void, FlowManagerError>; - readonly refreshBlueprintsFromConfig: () => Promise<void>; readonly refreshBlueprintsFromConfigEffect: Effect.Effect<void, FlowManagerError>; - readonly refreshFlowsFromConfig: () => Promise<void>; readonly refreshFlowsFromConfigEffect: Effect.Effect<void, FlowManagerError>; - readonly handleOperation: (request: FlowRequest) => Promise<FlowResponse>; readonly handleOperationEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; readonly handleListBlueprints: () => FlowResponse; - readonly handleGetBlueprint: (request: FlowRequest) => Promise<FlowResponse>; readonly handleGetBlueprintEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; - readonly handlePutBlueprint: (request: FlowRequest) => Promise<FlowResponse>; readonly handlePutBlueprintEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; - readonly handleDeleteBlueprint: (request: FlowRequest) => Promise<FlowResponse>; readonly handleDeleteBlueprintEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; readonly handleListFlows: () => FlowResponse; - readonly handleGetFlow: (request: FlowRequest) => Promise<FlowResponse>; readonly handleGetFlowEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; - readonly handleStartFlow: (request: FlowRequest) => Promise<FlowResponse>; readonly handleStartFlowEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; - readonly handleStopFlow: (request: FlowRequest) => Promise<FlowResponse>; readonly handleStopFlowEffect: (request: FlowRequest) => Effect.Effect<FlowResponse, FlowManagerError>; - readonly pushFlowsConfig: () => Promise<void>; readonly pushFlowsConfigEffect: Effect.Effect<void>; - readonly deleteFlowConfig: (id: string) => Promise<void>; readonly deleteFlowConfigEffect: (id: string) => Effect.Effect<void, FlowManagerError>; } @@ -259,13 +267,13 @@ function blueprintFromConfig(value: unknown): Blueprint | undefined { function flowFromConfig(id: string, value: unknown): FlowInstance | undefined { const parsed = parseConfigRecord(value); if (parsed === undefined) return undefined; - return { + return FlowInstanceRunning.make({ id, blueprintName: optionalString(parsed["blueprint-name"]) ?? optionalString(parsed.blueprintName) ?? "default", description: optionalString(parsed.description) ?? "", parameters: isRecord(parsed.parameters) ? parsed.parameters : {}, status: "running", - }; + }); } const updateHandles = ( @@ -291,10 +299,9 @@ const configRequestEffect = Effect.fn("FlowManager.configRequest")(function* ( if (configClient === null) { return yield* flowManagerError("config-request", "Config client not started"); } - return yield* Effect.tryPromise({ - try: () => configClient.request(request), - catch: (cause) => flowManagerError("config-request", cause), - }); + return yield* configClient.request(request).pipe( + Effect.mapError((cause) => flowManagerError("config-request", cause)), + ); }); const ensureDefaultBlueprintEffect = Effect.fn("FlowManager.ensureDefaultBlueprint")(function* ( @@ -571,24 +578,20 @@ const pushFlowsConfigEffect = Effect.fn("FlowManager.pushFlowsConfig")( } } - yield* Effect.tryPromise({ - try: () => - configClient.request({ - operation: "put", - keys: ["flows"], - values: flowsConfig, - }), - catch: (cause) => flowManagerError("put-flows-config", cause), - }); - yield* Effect.tryPromise({ - try: () => - configClient.request({ - operation: "put", - keys: ["flow"], - values: flowRecords, - }), - catch: (cause) => flowManagerError("put-flow-records", cause), - }); + yield* configClient.request({ + operation: "put", + keys: ["flows"], + values: flowsConfig, + }).pipe( + Effect.mapError((cause) => flowManagerError("put-flows-config", cause)), + ); + yield* configClient.request({ + operation: "put", + keys: ["flow"], + values: flowRecords, + }).pipe( + Effect.mapError((cause) => flowManagerError("put-flow-records", cause)), + ); yield* Effect.log(`[FlowManager] Pushed flows config (${HashMap.size(state.flows)} active flows)`); }, (effect) => @@ -605,22 +608,18 @@ const deleteFlowConfigEffect = Effect.fn("FlowManager.deleteFlowConfig")(functio ) { const configClient = (yield* SynchronizedRef.get(stateRef)).configClient; if (configClient === null) return; - yield* Effect.tryPromise({ - try: () => - configClient.request({ - operation: "delete", - keys: ["flows", id], - }), - catch: (cause) => flowManagerError("delete-flows-config", cause), - }); - yield* Effect.tryPromise({ - try: () => - configClient.request({ - operation: "delete", - keys: ["flow", id], - }), - catch: (cause) => flowManagerError("delete-flow-record", cause), - }); + yield* configClient.request({ + operation: "delete", + keys: ["flows", id], + }).pipe( + Effect.mapError((cause) => flowManagerError("delete-flows-config", cause)), + ); + yield* configClient.request({ + operation: "delete", + keys: ["flow", id], + }).pipe( + Effect.mapError((cause) => flowManagerError("delete-flow-record", cause)), + ); }); const closeFlowManagerResourcesEffect = Effect.fn("FlowManager.closeResources")(function* ( @@ -630,24 +629,19 @@ const closeFlowManagerResourcesEffect = Effect.fn("FlowManager.closeResources")( const consumer = state.consumer; if (consumer !== null) { - yield* Effect.tryPromise({ - try: () => consumer.close(), - catch: (cause) => flowManagerError("consumer-close", cause), - }); + yield* consumer.close.pipe( + Effect.mapError((cause) => flowManagerError("consumer-close", cause)), + ); } const responseProducer = state.responseProducer; if (responseProducer !== null) { - yield* Effect.tryPromise({ - try: () => responseProducer.close(), - catch: (cause) => flowManagerError("response-producer-close", cause), - }); + yield* responseProducer.close.pipe( + Effect.mapError((cause) => flowManagerError("response-producer-close", cause)), + ); } const configClient = state.configClient; if (configClient !== null) { - yield* Effect.tryPromise({ - try: () => configClient.stop(), - catch: (cause) => flowManagerError("config-client-stop", cause), - }); + yield* configClient.stop; } yield* updateHandles(stateRef, { @@ -665,17 +659,15 @@ const consumeOnceEffect = Effect.fnUntraced(function* ( return yield* flowManagerError("consume", "Flow request consumer not started"); } - const msg = yield* Effect.tryPromise({ - try: () => consumer.receive(2000), - catch: (cause) => flowManagerError("consume-receive", cause), - }); + const msg = yield* consumer.receive(2000).pipe( + Effect.mapError((cause) => flowManagerError("consume-receive", cause)), + ); if (msg === null) return; yield* service.handleMessageEffect(msg); - yield* Effect.tryPromise({ - try: () => consumer.acknowledge(msg), - catch: (cause) => flowManagerError("consume-acknowledge", cause), - }); + yield* consumer.acknowledge(msg).pipe( + Effect.mapError((cause) => flowManagerError("consume-acknowledge", cause)), + ); }); const runFlowManagerServiceEffect = Effect.fn("FlowManager.runService")(function* ( @@ -688,32 +680,27 @@ const runFlowManagerServiceEffect = Effect.fn("FlowManager.runService")(function subscription: `${service.config.id}-config-client`, }); yield* updateHandles(service.state, { configClient }); - yield* Effect.tryPromise({ - try: () => configClient.start(), - catch: (cause) => flowManagerError("config-client-start", cause), - }); + yield* configClient.start.pipe( + Effect.mapError((cause) => flowManagerError("config-client-start", cause)), + ); yield* ensureDefaultBlueprintEffect(service.state); yield* refreshBlueprintsFromConfigEffect(service.state); - const responseProducer = yield* Effect.tryPromise({ - try: () => - service.pubsub.createProducer<FlowResponse>({ - topic: topics.flowResponse, - schema: FlowResponseSchema, - }), - catch: (cause) => flowManagerError("response-producer", cause), - }); + const responseProducer = yield* service.pubsub.createProducer<FlowResponse>({ + topic: topics.flowResponse, + schema: FlowResponseSchema, + }).pipe( + Effect.mapError((cause) => flowManagerError("response-producer", cause)), + ); yield* updateHandles(service.state, { responseProducer }); - const consumer = yield* Effect.tryPromise({ - try: () => - service.pubsub.createConsumer<FlowRequest>({ - topic: topics.flowRequest, - subscription: `${service.config.id}-flow-request`, - schema: FlowRequestSchema, - }), - catch: (cause) => flowManagerError("consumer", cause), - }); + const consumer = yield* service.pubsub.createConsumer<FlowRequest>({ + topic: topics.flowRequest, + subscription: `${service.config.id}-flow-request`, + schema: FlowRequestSchema, + }).pipe( + Effect.mapError((cause) => flowManagerError("consumer", cause)), + ); yield* updateHandles(service.state, { consumer }); yield* Effect.log(`[FlowManager] Listening on ${topics.flowRequest}`); @@ -748,7 +735,6 @@ export function makeFlowManagerService(config: ProcessorConfig): FlowManagerServ const base = makeAsyncProcessor<FlowManagerError>(config, { runEffect: () => getService.pipe(Effect.flatMap(runFlowManagerServiceEffect)), }); - const baseStop = base.stop; const handleOperationEffect = Effect.fn("FlowManager.handleOperation")(function* (request: FlowRequest) { const op = optionalString(request.operation); @@ -784,10 +770,9 @@ export function makeFlowManagerService(config: ProcessorConfig): FlowManagerServ if (responseProducer === null) { return yield* flowManagerError("respond", "Flow response producer not started"); } - yield* Effect.tryPromise({ - try: () => responseProducer.send(response, { id: requestId }), - catch: (cause) => flowManagerError("respond", cause), - }); + yield* responseProducer.send(response, { id: requestId }).pipe( + Effect.mapError((cause) => flowManagerError("respond", cause)), + ); }); yield* handleOperationEffect(request).pipe( @@ -800,50 +785,45 @@ export function makeFlowManagerService(config: ProcessorConfig): FlowManagerServ ); }); - const flowManagerService: FlowManagerService = Object.assign(base, { + const serviceStopEffect = closeFlowManagerResourcesEffect(state).pipe( + Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)), + Effect.flatMap(() => base.stop), + ); + + const serviceBase = Object.create(base, { + stop: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + stopEffect: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + }); + + const flowManagerService = Object.assign(serviceBase, { state, - handleMessage: (msg: Message<FlowRequest>) => Effect.runPromise(handleMessageEffect(msg)), handleMessageEffect, - configRequest: (request: ConfigRequest) => Effect.runPromise(configRequestEffect(state, request)), configRequestEffect: (request: ConfigRequest) => configRequestEffect(state, request), - ensureDefaultBlueprint: () => Effect.runPromise(ensureDefaultBlueprintEffect(state)), ensureDefaultBlueprintEffect: ensureDefaultBlueprintEffect(state), - refreshBlueprintsFromConfig: () => Effect.runPromise(refreshBlueprintsFromConfigEffect(state)), refreshBlueprintsFromConfigEffect: refreshBlueprintsFromConfigEffect(state), - refreshFlowsFromConfig: () => Effect.runPromise(refreshFlowsFromConfigEffect(state)), refreshFlowsFromConfigEffect: refreshFlowsFromConfigEffect(state), - handleOperation: (request: FlowRequest) => Effect.runPromise(handleOperationEffect(request)), handleOperationEffect, handleListBlueprints: () => handleListBlueprintsWithState(state.pipe(stateSnapshot)), - handleGetBlueprint: (request: FlowRequest) => Effect.runPromise(handleGetBlueprintEffect(state, request)), handleGetBlueprintEffect: (request: FlowRequest) => handleGetBlueprintEffect(state, request), - handlePutBlueprint: (request: FlowRequest) => Effect.runPromise(handlePutBlueprintEffect(state, request)), handlePutBlueprintEffect: (request: FlowRequest) => handlePutBlueprintEffect(state, request), - handleDeleteBlueprint: (request: FlowRequest) => Effect.runPromise(handleDeleteBlueprintEffect(state, request)), handleDeleteBlueprintEffect: (request: FlowRequest) => handleDeleteBlueprintEffect(state, request), handleListFlows: () => handleListFlowsWithState(state.pipe(stateSnapshot)), - handleGetFlow: (request: FlowRequest) => Effect.runPromise(handleGetFlowEffect(state, request)), handleGetFlowEffect: (request: FlowRequest) => handleGetFlowEffect(state, request), - handleStartFlow: (request: FlowRequest) => Effect.runPromise(handleStartFlowEffect(state, request)), handleStartFlowEffect: (request: FlowRequest) => handleStartFlowEffect(state, request), - handleStopFlow: (request: FlowRequest) => Effect.runPromise(handleStopFlowEffect(state, request)), handleStopFlowEffect: (request: FlowRequest) => handleStopFlowEffect(state, request), - pushFlowsConfig: () => Effect.runPromise(pushFlowsConfigEffect(state)), pushFlowsConfigEffect: pushFlowsConfigEffect(state), - deleteFlowConfig: (id: string) => Effect.runPromise(deleteFlowConfigEffect(state, id)), deleteFlowConfigEffect: (id: string) => deleteFlowConfigEffect(state, id), - stop: () => - Effect.runPromise( - closeFlowManagerResourcesEffect(state).pipe( - Effect.flatMap(() => - Effect.tryPromise({ - try: () => baseStop(), - catch: (cause) => flowManagerError("base-stop", cause), - }) - ), - ), - ), - }); + }) as FlowManagerService; service = flowManagerService; return flowManagerService; @@ -856,12 +836,6 @@ export const program = makeProcessorProgram({ make: (config) => makeFlowManagerService(config), }); -const flowManagerRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return flowManagerRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/gateway/dispatch/manager.ts b/ts/packages/flow/src/gateway/dispatch/manager.ts index 9b6aee83..0d1dc934 100644 --- a/ts/packages/flow/src/gateway/dispatch/manager.ts +++ b/ts/packages/flow/src/gateway/dispatch/manager.ts @@ -31,7 +31,6 @@ import { type DispatchSerializationError, } from "./serialize.js"; -export type Responder = (response: unknown, complete: boolean) => Promise<void>; export type EffectResponder<E = never, R = never> = ( response: unknown, complete: boolean, @@ -106,18 +105,13 @@ function topicName(name: string): string { // ---------- Manager ---------- export interface DispatcherManager { - readonly start: () => Promise<void>; - readonly stop: () => Promise<void>; + readonly start: Effect.Effect<void, MessagingLifecycleError>; + readonly stop: Effect.Effect<void, MessagingLifecycleError>; readonly dispatchGlobalService: ( kind: string, request: Record<string, unknown>, - ) => Promise<unknown>; - readonly dispatchGlobalServiceStreaming: ( - kind: string, - request: Record<string, unknown>, - responder: Responder, - ) => Promise<void>; - readonly dispatchGlobalServiceStreamingEffect: <E = never, R = never>( + ) => Effect.Effect<unknown, DispatcherStreamError>; + readonly dispatchGlobalServiceStreaming: <E = never, R = never>( kind: string, request: Record<string, unknown>, responder: EffectResponder<E, R>, @@ -126,14 +120,8 @@ export interface DispatcherManager { flow: string, kind: string, request: Record<string, unknown>, - ) => Promise<unknown>; - readonly dispatchFlowServiceStreaming: ( - flow: string, - kind: string, - request: Record<string, unknown>, - responder: Responder, - ) => Promise<void>; - readonly dispatchFlowServiceStreamingEffect: <E = never, R = never>( + ) => Effect.Effect<unknown, DispatcherStreamError>; + readonly dispatchFlowServiceStreaming: <E = never, R = never>( flow: string, kind: string, request: Record<string, unknown>, @@ -143,7 +131,7 @@ export interface DispatcherManager { topic: string, message: unknown, id?: string, - ) => Promise<void>; + ) => Effect.Effect<void, MessagingDeliveryError>; } export const dispatcherManagerFlowServiceNames = (): readonly string[] => [ @@ -214,8 +202,6 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager runtime = nextRuntime; }); - const start = (): Promise<void> => Effect.runPromise(startEffect()); - const stopEffect = Effect.fn("DispatcherManager.stop")(function* () { const current = runtime; runtime = null; @@ -225,15 +211,12 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager } if (ownsPubSub) { - yield* Effect.tryPromise({ - try: () => pubsub.close(), - catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause), - }); + yield* pubsub.close.pipe( + Effect.mapError((cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause)), + ); } }); - const stop = (): Promise<void> => Effect.runPromise(stopEffect()); - // ---------- Internal helpers ---------- const ensureRuntimeEffect = Effect.fn("DispatcherManager.ensureRuntime")(function* () { @@ -303,13 +286,7 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager // ---------- Global service dispatch ---------- - const dispatchGlobalService = ( - kind: string, - request: Record<string, unknown>, - ): Promise<unknown> => - Effect.runPromise(dispatchGlobalServiceEffect(kind, request)); - - const dispatchGlobalServiceEffect = Effect.fn("DispatcherManager.dispatchGlobalService")(function* ( + const dispatchGlobalService = Effect.fn("DispatcherManager.dispatchGlobalService")(function* ( kind: string, request: Record<string, unknown>, ) { @@ -321,7 +298,7 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager return yield* translateResponseEffect(kind, response); }); - const dispatchGlobalServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* < + const dispatchGlobalServiceStreaming = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* < E, R, >( @@ -342,34 +319,9 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager }); }); - const dispatchGlobalServiceStreaming = ( - kind: string, - request: Record<string, unknown>, - responder: Responder, - ): Promise<void> => - Effect.runPromise( - dispatchGlobalServiceStreamingEffect(kind, request, (response, complete) => - Effect.tryPromise({ - try: () => responder(response, complete), - catch: (error) => messagingDeliveryError( - resolveGlobalTopics(kind).responseTopic, - "stream-responder", - error, - ), - }) - ), - ); - // ---------- Flow-scoped service dispatch ---------- - const dispatchFlowService = ( - flow: string, - kind: string, - request: Record<string, unknown>, - ): Promise<unknown> => - Effect.runPromise(dispatchFlowServiceEffect(flow, kind, request)); - - const dispatchFlowServiceEffect = Effect.fn("DispatcherManager.dispatchFlowService")(function* ( + const dispatchFlowService = Effect.fn("DispatcherManager.dispatchFlowService")(function* ( flow: string, kind: string, request: Record<string, unknown>, @@ -386,7 +338,7 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager return yield* translateResponseEffect(kind, response); }); - const dispatchFlowServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* < + const dispatchFlowServiceStreaming = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* < E, R, >( @@ -412,65 +364,40 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager }); }); - const dispatchFlowServiceStreaming = ( - flow: string, - kind: string, - request: Record<string, unknown>, - responder: Responder, - ): Promise<void> => - Effect.runPromise( - dispatchFlowServiceStreamingEffect(flow, kind, request, (response, complete) => - Effect.tryPromise({ - try: () => responder(response, complete), - catch: (error) => messagingDeliveryError( - resolveFlowTopics(kind).responseTopic, - "stream-responder", - error, - ), - }) - ), - ); - // ---------- Fire-and-forget publish ---------- /** * Publish a single message to an arbitrary topic (no request/response). * Used for injecting documents into the processing pipeline. */ - const publishToTopic = (topic: string, message: unknown, id?: string): Promise<void> => - Effect.runPromise( - Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => pubsub.createProducer<unknown>({ topic }), - catch: (cause) => messagingDeliveryError(topic, "create-producer", cause), - }), - (producer) => - Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true }); - const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`; - - yield* Effect.tryPromise({ - try: () => producer.send(message, { id: messageId }), - catch: (cause) => messagingDeliveryError(topic, "send", cause), - }); - }), - (producer) => Effect.tryPromise({ - try: () => producer.close(), - catch: (cause) => messagingDeliveryError(topic, "close-producer", cause), - }), + const publishToTopic = (topic: string, message: unknown, id?: string) => + Effect.acquireUseRelease( + pubsub.createProducer<unknown>({ topic }).pipe( + Effect.mapError((cause) => messagingDeliveryError(topic, "create-producer", cause)), ), + (producer) => + Effect.gen(function* () { + const timestamp = yield* Clock.currentTimeMillis; + const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true }); + const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`; + + yield* producer.send(message, { id: messageId }).pipe( + Effect.mapError((cause) => messagingDeliveryError(topic, "send", cause)), + ); + }), + (producer) => + producer.close.pipe( + Effect.mapError((cause) => messagingDeliveryError(topic, "close-producer", cause)), + ), ); return { - start, - stop, + start: startEffect(), + stop: stopEffect(), dispatchGlobalService, dispatchGlobalServiceStreaming, - dispatchGlobalServiceStreamingEffect, dispatchFlowService, dispatchFlowServiceStreaming, - dispatchFlowServiceStreamingEffect, publishToTopic, }; } diff --git a/ts/packages/flow/src/gateway/index.ts b/ts/packages/flow/src/gateway/index.ts index cfb30767..f4939f56 100644 --- a/ts/packages/flow/src/gateway/index.ts +++ b/ts/packages/flow/src/gateway/index.ts @@ -1,4 +1,4 @@ -export { createGateway, run, type GatewayConfig } from "./server.js"; +export { createGateway, program, runMain, type GatewayConfig } from "./server.js"; export { dispatcherManagerFlowServiceNames, dispatcherManagerGlobalServiceNames, diff --git a/ts/packages/flow/src/gateway/rpc-server.ts b/ts/packages/flow/src/gateway/rpc-server.ts index 44c06891..38dfeb09 100644 --- a/ts/packages/flow/src/gateway/rpc-server.ts +++ b/ts/packages/flow/src/gateway/rpc-server.ts @@ -40,10 +40,9 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) => TrustGraphRpcs.toLayer(Effect.succeed( TrustGraphRpcs.of({ Dispatch: (payload) => - Effect.tryPromise({ - try: () => dispatchOne(dispatcher, payload), - catch: (cause) => DispatchError.make({ message: errorMessage(cause) }), - }), + dispatchOne(dispatcher, payload).pipe( + Effect.mapError((cause) => DispatchError.make({ message: errorMessage(cause) })), + ), DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) { const queue = yield* Queue.bounded<DispatchStreamChunk, DispatchError | Cause.Done>(16); yield* Effect.addFinalizer(() => Queue.shutdown(queue)); @@ -64,7 +63,7 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) => function dispatchOne( dispatcher: DispatcherManager, payload: DispatchPayload, -): Promise<unknown> { +): Effect.Effect<unknown, DispatcherStreamError> { if (payload.scope === "flow") { return dispatcher.dispatchFlowService( payload.flow ?? "default", @@ -81,7 +80,7 @@ function dispatchStreamEffect( responder: (response: unknown, complete: boolean) => Effect.Effect<void>, ): Effect.Effect<void, DispatcherStreamError> { if (payload.scope === "flow") { - return dispatcher.dispatchFlowServiceStreamingEffect( + return dispatcher.dispatchFlowServiceStreaming( payload.flow ?? "default", payload.service, payload.request, @@ -89,7 +88,7 @@ function dispatchStreamEffect( ); } - return dispatcher.dispatchGlobalServiceStreamingEffect( + return dispatcher.dispatchGlobalServiceStreaming( payload.service, payload.request, responder, diff --git a/ts/packages/flow/src/gateway/server.ts b/ts/packages/flow/src/gateway/server.ts index 4e775664..7714270d 100644 --- a/ts/packages/flow/src/gateway/server.ts +++ b/ts/packages/flow/src/gateway/server.ts @@ -1,19 +1,16 @@ +/** @effect-diagnostics nodeBuiltinImport:skip-file effectFnOpportunity:skip-file catchToOrElseSucceed:skip-file */ /** - * API Gateway — HTTP + WebSocket server. - * - * Replaces the Python aiohttp gateway with Fastify. - * Uses Effect RPC over WebSocket for streaming client requests. + * API Gateway -- Effect HTTP + RPC server. * * Python reference: trustgraph-flow/trustgraph/gateway/service.py */ -import Fastify, { type FastifyReply } from "fastify"; -import websocketPlugin from "@fastify/websocket"; -import { NodeRuntime } from "@effect/platform-node"; -import { Cause, Clock, Config, Effect, Exit, Layer, ManagedRuntime, Random, Scope } from "effect"; +import { createServer } from "node:http"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { Clock, Config, Effect, Exit, Layer, Random, Scope } from "effect"; import * as O from "effect/Option"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; -import * as EffectSocket from "effect/unstable/socket/Socket"; import { formatPrometheusMetrics, messagingLifecycleError, @@ -22,8 +19,8 @@ import { toTgError, type PubSubBackend, } from "@trustgraph/base"; -import { makeDispatcherManager } from "./dispatch/manager.js"; -import { makeGatewayRpcServer } from "./rpc-server.js"; +import { makeDispatcherManager, type DispatcherManager } from "./dispatch/manager.js"; +import { makeGatewayRpcServer, type GatewayRpcServer } from "./rpc-server.js"; export interface GatewayConfig { port: number; @@ -33,231 +30,253 @@ export interface GatewayConfig { pubsub?: PubSubBackend; } -export function createGateway(config: GatewayConfig) { - const app = Fastify({ logger: true }); - const dispatcher = makeDispatcherManager(config); +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); - const sendDispatchResult = (reply: FastifyReply, result: unknown): unknown => { - const err = (result as Record<string, unknown>)?.error as { type?: string; message?: string } | undefined; - if (err !== undefined) { - const statusCode = err.type === "not-found" ? 404 : 400; - return reply.code(statusCode).send(result); - } - return result; - }; +const json = (body: unknown, status = 200) => + HttpServerResponse.jsonUnsafe(body, { status }); - const sendDispatchError = (reply: FastifyReply, error: unknown): unknown => - reply.code(500).send({ error: toTgError(error) }); +const badRequest = (message: string) => + json({ error: { type: "bad-request", message } }, 400); - return Effect.runPromise( +const dispatchError = (error: unknown) => + json({ error: toTgError(error) }, 500); + +const dispatchResult = (result: unknown) => { + const err = isRecord(result) && isRecord(result.error) + ? result.error as { readonly type?: string; readonly message?: string } + : undefined; + if (err !== undefined) { + return json(result, err.type === "not-found" ? 404 : 400); + } + return json(result); +}; + +const readJsonRecord = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const body = yield* request.json; + return isRecord(body) ? body : {}; +}); + +const bearerAuthResponse = (config: GatewayConfig) => + Effect.gen(function* () { + if (config.secret === undefined || config.secret.length === 0) return null; + const request = yield* HttpServerRequest.HttpServerRequest; + const auth = request.headers.authorization; + return auth === `Bearer ${config.secret}` + ? null + : json({ error: "Unauthorized" }, 401); + }); + +type RouteRequirements = + | HttpServerRequest.HttpServerRequest + | HttpRouter.RouteContext; + +const withBearerAuth = ( + config: GatewayConfig, + handler: Effect.Effect<HttpServerResponse.HttpServerResponse, never, RouteRequirements>, +) => + Effect.gen(function* () { + const denied = yield* bearerAuthResponse(config); + if (denied !== null) return denied; + return yield* handler; + }); + +const withDispatchError = <A, E>( + effect: Effect.Effect<A, E>, + operation: string, +): Effect.Effect<HttpServerResponse.HttpServerResponse> => + effect.pipe( + Effect.mapError((cause) => messagingLifecycleError("gateway", operation, cause)), + Effect.map(dispatchResult), + Effect.catch((error) => Effect.succeed(dispatchError(error))), + ); + +const workbenchDispatch = ( + config: GatewayConfig, + dispatcher: DispatcherManager, +) => + withBearerAuth( + config, Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => app.register(websocketPlugin), - catch: (cause) => messagingLifecycleError("gateway", "register-websocket", cause), - }); + const body = yield* readJsonRecord.pipe( + Effect.catch(() => Effect.succeed<Record<string, unknown>>({})), + ); + const service = typeof body.service === "string" ? body.service : undefined; + const payload = isRecord(body.request) ? body.request : undefined; + if (service === undefined || service.length === 0 || payload === undefined) { + return badRequest("service and request are required"); + } - yield* Effect.tryPromise({ - try: () => dispatcher.start(), - catch: (cause) => messagingLifecycleError("gateway", "dispatcher-start", cause), - }); + const dispatch = body.scope === "flow" + ? dispatcher.dispatchFlowService( + typeof body.flow === "string" ? body.flow : "default", + service, + payload, + ) + : dispatcher.dispatchGlobalService(service, payload); + + return yield* withDispatchError(dispatch, "workbench-dispatch"); + }), + ); + +const globalDispatch = ( + config: GatewayConfig, + dispatcher: DispatcherManager, +) => + withBearerAuth( + config, + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const body = yield* readJsonRecord.pipe( + Effect.catch(() => Effect.succeed<Record<string, unknown>>({})), + ); + return yield* withDispatchError( + dispatcher.dispatchGlobalService(params.kind ?? "", body), + "global-dispatch", + ); + }), + ); + +const flowDispatch = ( + config: GatewayConfig, + dispatcher: DispatcherManager, +) => + withBearerAuth( + config, + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const body = yield* readJsonRecord.pipe( + Effect.catch(() => Effect.succeed<Record<string, unknown>>({})), + ); + return yield* withDispatchError( + dispatcher.dispatchFlowService(params.flow ?? "default", params.kind ?? "", body), + "flow-dispatch", + ); + }), + ); + +const flowLoad = ( + config: GatewayConfig, + dispatcher: DispatcherManager, +) => + withBearerAuth( + config, + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const body = yield* readJsonRecord.pipe( + Effect.catch(() => Effect.succeed<Record<string, unknown>>({})), + ); + const documentId = typeof body.documentId === "string" ? body.documentId : undefined; + if (documentId === undefined || documentId.length === 0) { + return badRequest("documentId is required"); + } + + const user = typeof body.user === "string" ? body.user : "default"; + const collection = typeof body.collection === "string" ? body.collection : "default"; + const timestamp = yield* Clock.currentTimeMillis; + const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true }); + const metadata = { + id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`, + root: documentId, + user, + collection, + }; + + yield* dispatcher.publishToTopic("tg.flow.document", { metadata, documentId }).pipe( + Effect.mapError((cause) => messagingLifecycleError("gateway", "publish-load", cause)), + ); + + return json({ status: "processing", documentId, flow: params.flow ?? "default" }); + }).pipe( + Effect.catch((error) => Effect.succeed(dispatchError(error))), + ), + ); + +const rpcRoute = ( + config: GatewayConfig, + rpcServer: GatewayRpcServer, + rpcScope: Scope.Scope, +) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(request.url, "http://localhost"); + const token = url.searchParams.get("token"); + if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) { + return json({ error: "Unauthorized" }, 401); + } + + const socket = yield* request.upgrade; + yield* rpcServer.onSocket(socket, headersFrom(request.headers)).pipe( + Scope.provide(rpcScope), + ); + return HttpServerResponse.empty(); + }).pipe( + Effect.catch((error) => Effect.succeed(dispatchError(error))), + ); + +const metricsRoute = + formatPrometheusMetrics.pipe( + Effect.map((body) => + HttpServerResponse.text(body, { + headers: { "content-type": prometheusContentType }, + }) + ), + ); + +const gatewayRoutes = ( + config: GatewayConfig, + dispatcher: DispatcherManager, + rpcServer: GatewayRpcServer, + rpcScope: Scope.Scope, +) => + Layer.mergeAll( + HttpRouter.add("POST", "/api/v1/workbench/dispatch", workbenchDispatch(config, dispatcher)), + HttpRouter.add("POST", "/api/v1/:kind", globalDispatch(config, dispatcher)), + HttpRouter.add("POST", "/api/v1/flow/:flow/service/:kind", flowDispatch(config, dispatcher)), + HttpRouter.add("POST", "/api/v1/flow/:flow/load", flowLoad(config, dispatcher)), + HttpRouter.add("GET", "/api/v1/rpc", rpcRoute(config, rpcServer, rpcScope)), + HttpRouter.add("GET", "/api/v1/metrics", metricsRoute), + ); + +export function createGateway(config: GatewayConfig) { + return Layer.effectDiscard( + Effect.scoped(Effect.gen(function* () { + const dispatcher = makeDispatcherManager(config); + yield* dispatcher.start.pipe( + Effect.mapError((cause) => messagingLifecycleError("gateway", "dispatcher-start", cause)), + ); + yield* Effect.addFinalizer(() => + dispatcher.stop.pipe( + Effect.catch((cause) => + Effect.logError("[Gateway] Failed to stop dispatcher", { + error: cause.message, + operation: cause.operation, + }), + ), + ), + ); const rpcScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(rpcScope, Exit.void)); const rpcServer = yield* makeGatewayRpcServer(dispatcher).pipe( Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson), Scope.provide(rpcScope), ); - return { rpcScope, rpcServer }; - }), - ).then(({ rpcScope, rpcServer }) => { - // Authentication middleware - app.addHook("onRequest", (request, reply) => { - if (request.url === "/api/v1/metrics") return; - if (request.url.startsWith("/api/v1/rpc")) return; // RPC socket auth via query param - - if (config.secret !== undefined && config.secret.length > 0) { - const auth = request.headers.authorization; - if (auth === undefined || auth !== `Bearer ${config.secret}`) { - reply.code(401).send({ error: "Unauthorized" }); - } - } - }); - - app.post<{ - Body: { - scope?: string; - service?: string; - flow?: string; - request?: Record<string, unknown>; - }; - }>("/api/v1/workbench/dispatch", (request, reply) => { - const body = request.body; - const service = body.service; - const payload = body.request; - if (service === undefined || service.length === 0 || payload === undefined) { - return reply.code(400).send({ - error: { type: "bad-request", message: "service and request are required" }, - }); - } - - return Effect.runPromise( - Effect.tryPromise({ - try: () => - body.scope === "flow" - ? dispatcher.dispatchFlowService(body.flow ?? "default", service, payload) - : dispatcher.dispatchGlobalService(service, payload), - catch: (cause) => messagingLifecycleError("gateway", "workbench-dispatch", cause), - }).pipe( - Effect.map((result) => sendDispatchResult(reply, result)), - Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))), - ), - ); - }); - - // REST endpoint: POST /api/v1/:kind (global services) - app.post<{ Params: { kind: string } }>("/api/v1/:kind", (request, reply) => { - const { kind } = request.params; - const body = request.body as Record<string, unknown>; - - return Effect.runPromise( - Effect.tryPromise({ - try: () => dispatcher.dispatchGlobalService(kind, body), - catch: (cause) => messagingLifecycleError("gateway", "global-dispatch", cause), - }).pipe( - Effect.map((result) => sendDispatchResult(reply, result)), - Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))), - ), - ); - }); - - // REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services) - app.post<{ Params: { flow: string; kind: string } }>( - "/api/v1/flow/:flow/service/:kind", - (request, reply) => { - const { flow, kind } = request.params; - const body = request.body as Record<string, unknown>; - - return Effect.runPromise( - Effect.tryPromise({ - try: () => dispatcher.dispatchFlowService(flow, kind, body), - catch: (cause) => messagingLifecycleError("gateway", "flow-dispatch", cause), - }).pipe( - Effect.map((result) => sendDispatchResult(reply, result)), - Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))), - ), - ); - }, + const serverLayer = HttpRouter.serve( + gatewayRoutes(config, dispatcher, rpcServer, rpcScope), + ).pipe( + Layer.provideMerge(NodeHttpServer.layer(createServer, { + port: config.port, + host: "0.0.0.0", + })), ); - // REST endpoint: POST /api/v1/flow/:flow/load (trigger document processing) - app.post<{ Params: { flow: string } }>( - "/api/v1/flow/:flow/load", - (request, reply) => { - const { flow } = request.params; - const body = request.body as { - documentId?: string; - user?: string; - collection?: string; - }; - - if (body.documentId === undefined || body.documentId.length === 0) { - return reply.code(400).send({ - error: { type: "bad-request", message: "documentId is required" }, - }); - } - - return Effect.runPromise( - Effect.gen(function* () { - const user = body.user ?? "default"; - const collection = body.collection ?? "default"; - const documentId = body.documentId; - const timestamp = yield* Clock.currentTimeMillis; - const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true }); - - // Publish Document message to the decode-input topic - const topic = "tg.flow.document"; - const metadata = { - id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`, - root: documentId, - user, - collection, - }; - - yield* Effect.tryPromise({ - try: () => dispatcher.publishToTopic(topic, { metadata, documentId }), - catch: (cause) => messagingLifecycleError("gateway", "publish-load", cause), - }); - - return { status: "processing", documentId, flow }; - }).pipe( - Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))), - ), - ); - }, - ); - - // Effect RPC WebSocket endpoint: /api/v1/rpc - app.get("/api/v1/rpc", { websocket: true }, (socket, request) => { - const url = new URL(request.url, `http://${request.headers.host}`); - const token = url.searchParams.get("token"); - if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) { - socket.close(4001, "Unauthorized"); - return; - } - - const program = Effect.scoped( - Effect.gen(function* () { - const effectSocket = yield* EffectSocket.fromWebSocket( - Effect.succeed(socket as unknown as globalThis.WebSocket), - { closeCodeIsError: (code) => code !== 1000 }, - ); - yield* rpcServer.onSocket(effectSocket, headersFrom(request.headers)); - }), - ); - - void Effect.runPromise( - program.pipe( - Scope.provide(rpcScope), - Effect.sandbox, - Effect.catch((cause) => - Effect.logError("[Gateway] RPC WebSocket error", { error: Cause.pretty(cause) }).pipe( - Effect.flatMap(() => - Effect.sync(() => { - if (socket.readyState === 1) { - socket.close(1011, "Internal server error"); - } - }), - ), - ) - ), - ), - ); - }); - - // Metrics endpoint — returns Effect metrics in Prometheus exposition format. - app.get("/api/v1/metrics", (_, reply) => { - reply.header("content-type", prometheusContentType); - return Effect.runPromise(formatPrometheusMetrics); - }); - - return { - start: () => app.listen({ port: config.port, host: "0.0.0.0" }), - stop: () => - Effect.runPromise( - Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => app.close(), - catch: (cause) => messagingLifecycleError("gateway", "app-close", cause), - }); - yield* Scope.close(rpcScope, Exit.void); - yield* Effect.tryPromise({ - try: () => dispatcher.stop(), - catch: (cause) => messagingLifecycleError("gateway", "dispatcher-stop", cause), - }); - }), - ), - }; - }); + yield* Effect.log(`[Gateway] Listening on port ${config.port}`); + return yield* Layer.launch(serverLayer); + })), + ); } function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> { @@ -269,10 +288,6 @@ function headersFrom(headers: Record<string, string | string[] | number | undefi }); } -export function run(): Promise<void> { - return gatewayRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } @@ -290,22 +305,8 @@ export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () { } 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; - }), -); +export const gatewayProgram = (config: GatewayConfig) => Layer.launch(createGateway(config)); -const gatewayRuntime = ManagedRuntime.make(Layer.empty); +export const program = loadGatewayConfig().pipe( + Effect.flatMap(gatewayProgram), +); diff --git a/ts/packages/flow/src/librarian/service.ts b/ts/packages/flow/src/librarian/service.ts index 7d3a0e6a..9782ae23 100644 --- a/ts/packages/flow/src/librarian/service.ts +++ b/ts/packages/flow/src/librarian/service.ts @@ -28,21 +28,22 @@ import { ProcessingMetadata as ProcessingMetadataSchema, type ProcessingMetadata, Triple as TripleSchema, + processorLifecycleError, } from "@trustgraph/base"; import type { Message } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; -import { Clock, Config, DateTime, Duration, Effect, Layer, ManagedRuntime, Match, Option, Random, SynchronizedRef } from "effect"; +import { Clock, Config, DateTime, Duration, Effect, Match, Option, Random, SynchronizedRef } from "effect"; import * as MutableHashMap from "effect/MutableHashMap"; import * as S from "effect/Schema"; import { makeCollectionManager, type CollectionManager } from "./collection-manager.js"; import { - ensureDirectory, + ensureDirectoryEffect, joinPath, - readBinaryFile, - readTextFile, - removePath, - writeBinaryFile, - writeTextFile, + readBinaryFileEffect, + readTextFileEffect, + removePathEffect, + writeBinaryFileEffect, + writeTextFileEffect, } from "../runtime/effect-files.js"; export interface LibrarianServiceConfig extends ProcessorConfig { @@ -151,38 +152,38 @@ export interface LibrarianService extends AsyncProcessorRuntime<LibrarianService requestRecord: (request: LibrarianRequest) => Record<string, unknown>; documentId: (request: LibrarianRequest) => string | undefined; processingId: (request: LibrarianRequest) => string | undefined; - documentMetadata: (request: LibrarianRequest) => Promise<DocumentMetadata | undefined>; - processingMetadata: (request: LibrarianRequest) => Promise<ProcessingMetadata | undefined>; - normaliseDocumentMetadata: (value: Record<string, unknown>) => Promise<DocumentMetadata>; + documentMetadata: (request: LibrarianRequest) => Effect.Effect<DocumentMetadata | undefined, LibrarianServiceError>; + processingMetadata: (request: LibrarianRequest) => Effect.Effect<ProcessingMetadata | undefined, LibrarianServiceError>; + normaliseDocumentMetadata: (value: Record<string, unknown>) => Effect.Effect<DocumentMetadata, LibrarianServiceError>; publicDocument: (doc: DocumentMetadata) => DocumentMetadata; publicProcessing: (proc: ProcessingMetadata) => ProcessingMetadata; documentResponse: (doc: DocumentMetadata) => LibrarianResponse; documentsResponse: (docs: DocumentMetadata[]) => LibrarianResponse; processingResponse: (records: ProcessingMetadata[]) => LibrarianResponse; - handleLibrarianMessage: (msg: Message<LibrarianRequest>) => Promise<void>; - handleLibrarianOperation: (request: LibrarianRequest) => Promise<LibrarianResponse>; - addDocument: (request: LibrarianRequest) => Promise<LibrarianResponse>; - removeDocument: (request: LibrarianRequest) => Promise<LibrarianResponse>; - updateDocument: (request: LibrarianRequest) => Promise<LibrarianResponse>; - listDocuments: (request: LibrarianRequest) => LibrarianResponse; - getDocumentMetadata: (request: LibrarianRequest) => Promise<LibrarianResponse>; - getDocumentContent: (request: LibrarianRequest) => Promise<LibrarianResponse>; - addChildDocument: (request: LibrarianRequest) => Promise<LibrarianResponse>; - listChildren: (request: LibrarianRequest) => Promise<LibrarianResponse>; - addProcessing: (request: LibrarianRequest) => Promise<LibrarianResponse>; - removeProcessing: (request: LibrarianRequest) => Promise<LibrarianResponse>; - listProcessing: (request: LibrarianRequest) => LibrarianResponse; - beginUpload: (request: LibrarianRequest) => Promise<LibrarianResponse>; - uploadChunk: (request: LibrarianRequest) => Promise<LibrarianResponse>; - completeUpload: (request: LibrarianRequest) => Promise<LibrarianResponse>; - getUploadStatus: (request: LibrarianRequest) => Promise<LibrarianResponse>; - abortUpload: (request: LibrarianRequest) => Promise<LibrarianResponse>; - listUploads: (request: LibrarianRequest) => Promise<LibrarianResponse>; - streamDocument: (request: LibrarianRequest) => Promise<LibrarianResponse[]>; - handleCollectionMessage: (msg: Message<CollectionManagementRequest>) => Promise<void>; - handleCollectionOperation: (request: CollectionManagementRequest) => Promise<CollectionManagementResponse>; - persist: () => Promise<void>; - loadFromDisk: () => Promise<void>; + handleLibrarianMessage: (msg: Message<LibrarianRequest>) => Effect.Effect<void, LibrarianServiceError>; + handleLibrarianOperation: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + addDocument: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + removeDocument: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + updateDocument: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + listDocuments: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + getDocumentMetadata: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + getDocumentContent: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + addChildDocument: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + listChildren: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + addProcessing: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + removeProcessing: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + listProcessing: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + beginUpload: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + uploadChunk: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + completeUpload: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + getUploadStatus: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + abortUpload: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + listUploads: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse, LibrarianServiceError>; + streamDocument: (request: LibrarianRequest) => Effect.Effect<LibrarianResponse[], LibrarianServiceError>; + handleCollectionMessage: (msg: Message<CollectionManagementRequest>) => Effect.Effect<void, LibrarianServiceError>; + handleCollectionOperation: (request: CollectionManagementRequest) => Effect.Effect<CollectionManagementResponse, LibrarianServiceError>; + persist: Effect.Effect<void>; + loadFromDisk: Effect.Effect<void>; } interface LibrarianServiceState { @@ -274,78 +275,66 @@ const consumeOnceEffect = Effect.fnUntraced(function* ( return yield* librarianServiceError("consume", "Collection consumer not started"); } - const libMsg = yield* Effect.tryPromise({ - try: () => libConsumer.receive(2000), - catch: (cause) => librarianServiceError("librarian-receive", cause), - }); + const libMsg = yield* libConsumer.receive(2000).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-receive", cause)), + ); if (libMsg !== null) { - yield* Effect.tryPromise({ - try: () => service.handleLibrarianMessage(libMsg), - catch: (cause) => librarianServiceError("librarian-handle", cause), - }); - yield* Effect.tryPromise({ - try: () => libConsumer.acknowledge(libMsg), - catch: (cause) => librarianServiceError("librarian-acknowledge", cause), - }); + yield* service.handleLibrarianMessage(libMsg).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-handle", cause)), + ); + yield* libConsumer.acknowledge(libMsg).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-acknowledge", cause)), + ); } - const colMsg = yield* Effect.tryPromise({ - try: () => colConsumer.receive(2000), - catch: (cause) => librarianServiceError("collection-receive", cause), - }); + const colMsg = yield* colConsumer.receive(2000).pipe( + Effect.mapError((cause) => librarianServiceError("collection-receive", cause)), + ); if (colMsg !== null) { - yield* Effect.tryPromise({ - try: () => service.handleCollectionMessage(colMsg), - catch: (cause) => librarianServiceError("collection-handle", cause), - }); - yield* Effect.tryPromise({ - try: () => colConsumer.acknowledge(colMsg), - catch: (cause) => librarianServiceError("collection-acknowledge", cause), - }); + yield* service.handleCollectionMessage(colMsg).pipe( + Effect.mapError((cause) => librarianServiceError("collection-handle", cause)), + ); + yield* colConsumer.acknowledge(colMsg).pipe( + Effect.mapError((cause) => librarianServiceError("collection-acknowledge", cause)), + ); } }); const runLibrarianServiceEffect = Effect.fn("LibrarianService.run")(function* ( service: LibrarianService, ) { - yield* Effect.tryPromise({ - try: () => ensureDirectory(joinPath(service.dataDir, "docs")), - catch: (cause) => librarianServiceError("ensure-data-dir", cause), - }); + yield* ensureDirectoryEffect(joinPath(service.dataDir, "docs")).pipe( + Effect.mapError((cause) => librarianServiceError("ensure-data-dir", cause)), + ); - yield* Effect.tryPromise({ - try: () => service.loadFromDisk(), - catch: (cause) => librarianServiceError("load", cause), - }); + yield* service.loadFromDisk.pipe( + Effect.mapError((cause) => librarianServiceError("load", cause)), + ); - const libProducer = yield* Effect.tryPromise({ - try: () => service.pubsub.createProducer<LibrarianResponse>({ - topic: topics.librarianResponse, - }), - catch: (cause) => librarianServiceError("librarian-producer", cause), - }); - const colProducer = yield* Effect.tryPromise({ - try: () => service.pubsub.createProducer<CollectionManagementResponse>({ - topic: topics.collectionManagementResponse, - }), - catch: (cause) => librarianServiceError("collection-producer", cause), - }); + const libProducer = yield* service.pubsub.createProducer<LibrarianResponse>({ + topic: topics.librarianResponse, + }).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-producer", cause)), + ); + const colProducer = yield* service.pubsub.createProducer<CollectionManagementResponse>({ + topic: topics.collectionManagementResponse, + }).pipe( + Effect.mapError((cause) => librarianServiceError("collection-producer", cause)), + ); yield* updateHandles(service.state, { libProducer, colProducer }); - const libConsumer = yield* Effect.tryPromise({ - try: () => service.pubsub.createConsumer<LibrarianRequest>({ - topic: topics.librarianRequest, - subscription: `${service.config.id}-librarian-request`, - }), - catch: (cause) => librarianServiceError("librarian-consumer", cause), - }); - const colConsumer = yield* Effect.tryPromise({ - try: () => service.pubsub.createConsumer<CollectionManagementRequest>({ - topic: topics.collectionManagementRequest, - subscription: `${service.config.id}-collection-management-request`, - }), - catch: (cause) => librarianServiceError("collection-consumer", cause), - }); + const libConsumer = yield* service.pubsub.createConsumer<LibrarianRequest>({ + topic: topics.librarianRequest, + subscription: `${service.config.id}-librarian-request`, + }).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-consumer", cause)), + ); + const colConsumer = yield* service.pubsub.createConsumer<CollectionManagementRequest>({ + topic: topics.collectionManagementRequest, + subscription: `${service.config.id}-collection-management-request`, + }).pipe( + Effect.mapError((cause) => librarianServiceError("collection-consumer", cause)), + ); yield* updateHandles(service.state, { libConsumer, colConsumer }); yield* Effect.log(`[LibrarianService] Listening on ${topics.librarianRequest} and ${topics.collectionManagementRequest}`); @@ -380,7 +369,6 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS const base = makeAsyncProcessor<LibrarianServiceError>(config, { runEffect: () => getService.pipe(Effect.flatMap(runLibrarianServiceEffect)), }); - const baseStop = base.stop; const dataDir = resolveDataDir(config); const persistPath = joinPath(dataDir, "librarian-state.json"); @@ -516,7 +504,59 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS }); }); - const librarianService: LibrarianService = Object.assign(base, { + const serviceStopEffect = Effect.gen(function* () { + const serviceState = yield* SynchronizedRef.get(state); + const libConsumer = serviceState.libConsumer; + if (libConsumer !== null) { + yield* libConsumer.close.pipe( + Effect.mapError((cause) => librarianServiceError("close-librarian-consumer", cause)), + ); + } + const libProducer = serviceState.libProducer; + if (libProducer !== null) { + yield* libProducer.close.pipe( + Effect.mapError((cause) => librarianServiceError("close-librarian-producer", cause)), + ); + } + const colConsumer = serviceState.colConsumer; + if (colConsumer !== null) { + yield* colConsumer.close.pipe( + Effect.mapError((cause) => librarianServiceError("close-collection-consumer", cause)), + ); + } + const colProducer = serviceState.colProducer; + if (colProducer !== null) { + yield* colProducer.close.pipe( + Effect.mapError((cause) => librarianServiceError("close-collection-producer", cause)), + ); + } + yield* updateHandles(state, { + libConsumer: null, + libProducer: null, + colConsumer: null, + colProducer: null, + }); + }).pipe( + Effect.mapError((cause) => processorLifecycleError(config.id, "stop", cause)), + Effect.flatMap(() => base.stop), + ); + + const serviceBase = Object.create(base, { + stop: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + stopEffect: { + value: serviceStopEffect, + writable: true, + enumerable: true, + configurable: true, + }, + }); + + const librarianService = Object.assign(serviceBase, { state, dataDir, persistPath, @@ -546,20 +586,19 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS - documentMetadata: function(this: LibrarianService, request: LibrarianRequest): Promise<DocumentMetadata | undefined> { + documentMetadata: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<DocumentMetadata | undefined, LibrarianServiceError> { const req = this.requestRecord(request); const value = req.documentMetadata ?? req["document-metadata"]; - if (!isRecord(value)) return Promise.resolve(undefined); + if (!isRecord(value)) return Effect.sync(() => undefined); return this.normaliseDocumentMetadata(value); }, - processingMetadata: function(this: LibrarianService, request: LibrarianRequest): Promise<ProcessingMetadata | undefined> { + processingMetadata: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<ProcessingMetadata | undefined, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const req = service.requestRecord(request); const value = req.processingMetadata ?? req["processing-metadata"]; if (!isRecord(value)) return undefined; @@ -576,16 +615,14 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS collection: optionalString(value.collection) ?? optionalString(service.requestRecord(request).collection) ?? "default", tags: Array.isArray(value.tags) ? value.tags.filter((tag): tag is string => typeof tag === "string") : [], }; - }), - ); + }); }, - normaliseDocumentMetadata: function(this: LibrarianService, value: Record<string, unknown>): Promise<DocumentMetadata> { - return Effect.runPromise( - Effect.gen(function* () { + normaliseDocumentMetadata: function(this: LibrarianService, value: Record<string, unknown>): Effect.Effect<DocumentMetadata, LibrarianServiceError> { + return Effect.gen(function* () { const id = optionalString(value.id) ?? (yield* randomUuid); const parentId = optionalString(value.parentId) ?? optionalString(value["parent-id"]); const documentType = optionalString(value.documentType) ?? optionalString(value["document-type"]) ?? "source"; @@ -606,8 +643,7 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS "document-type": documentType, ...(metadata === undefined ? {} : { metadata }), }; - }), - ); + }); }, @@ -673,10 +709,9 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS - handleLibrarianMessage: function(this: LibrarianService, msg: Message<LibrarianRequest>): Promise<void> { + handleLibrarianMessage: function(this: LibrarianService, msg: Message<LibrarianRequest>): Effect.Effect<void, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const request = msg.value(); const props = msg.properties(); const requestId = props.id; @@ -691,28 +726,25 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS if (producer === null) { return yield* librarianServiceError("librarian-respond", "Librarian producer not started"); } - yield* Effect.tryPromise({ - try: () => producer.send(response, { id: requestId }), - catch: (cause) => librarianServiceError("librarian-respond", cause), - }); + yield* producer.send(response, { id: requestId }).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-respond", cause)), + ); }); yield* Effect.gen(function* () { if (request.operation === "stream-document") { - const responses = yield* Effect.tryPromise<LibrarianResponse[], LibrarianServiceError>({ - try: () => service.streamDocument(request), - catch: (cause) => librarianServiceError("stream-document", cause), - }); + const responses = yield* service.streamDocument(request).pipe( + Effect.mapError((cause) => librarianServiceError("stream-document", cause)), + ); for (const response of responses) { yield* sendResponse(response); } return; } - const response = yield* Effect.tryPromise<LibrarianResponse, LibrarianServiceError>({ - try: () => service.handleLibrarianOperation(request), - catch: (cause) => librarianServiceError("librarian-operation", cause), - }); + const response = yield* service.handleLibrarianOperation(request).pipe( + Effect.mapError((cause) => librarianServiceError("librarian-operation", cause)), + ); yield* sendResponse(response); }).pipe( Effect.catch((err) => @@ -721,94 +753,32 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS }), ), ); - }), - ); + }); }, - handleLibrarianOperation: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + handleLibrarianOperation: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Match.value(request.operation).pipe( - Match.when("add-document", () => - Effect.tryPromise({ - try: () => service.addDocument(request), - catch: (cause) => librarianServiceError("add-document", cause), - }) - ), - Match.when("remove-document", () => - Effect.tryPromise({ - try: () => service.removeDocument(request), - catch: (cause) => librarianServiceError("remove-document", cause), - }) - ), - Match.when("update-document", () => - Effect.tryPromise({ - try: () => service.updateDocument(request), - catch: (cause) => librarianServiceError("update-document", cause), - }) - ), - Match.when("list-documents", () => - Effect.try({ - try: () => service.listDocuments(request), - catch: (cause) => librarianServiceError("list-documents", cause), - }) - ), + return Match.value(request.operation).pipe( + Match.when("add-document", () => service.addDocument(request)), + Match.when("remove-document", () => service.removeDocument(request)), + Match.when("update-document", () => service.updateDocument(request)), + Match.when("list-documents", () => service.listDocuments(request)), Match.when("get-document-metadata", () => getDocumentMetadataEffect(request)), - Match.when("get-document-content", () => - Effect.tryPromise({ - try: () => service.getDocumentContent(request), - catch: (cause) => librarianServiceError("get-document-content", cause), - }) - ), - Match.when("add-child-document", () => - Effect.tryPromise({ - try: () => service.addChildDocument(request), - catch: (cause) => librarianServiceError("add-child-document", cause), - }) - ), + Match.when("get-document-content", () => service.getDocumentContent(request)), + Match.when("add-child-document", () => service.addChildDocument(request)), Match.when("list-children", () => listChildrenEffect(request)), - Match.when("add-processing", () => - Effect.tryPromise({ - try: () => service.addProcessing(request), - catch: (cause) => librarianServiceError("add-processing", cause), - }) - ), - Match.when("remove-processing", () => - Effect.tryPromise({ - try: () => service.removeProcessing(request), - catch: (cause) => librarianServiceError("remove-processing", cause), - }) - ), - Match.when("list-processing", () => - Effect.try({ - try: () => service.listProcessing(request), - catch: (cause) => librarianServiceError("list-processing", cause), - }) - ), - Match.when("begin-upload", () => - Effect.tryPromise({ - try: () => service.beginUpload(request), - catch: (cause) => librarianServiceError("begin-upload", cause), - }) - ), + Match.when("add-processing", () => service.addProcessing(request)), + Match.when("remove-processing", () => service.removeProcessing(request)), + Match.when("list-processing", () => service.listProcessing(request)), + Match.when("begin-upload", () => service.beginUpload(request)), Match.when("upload-chunk", () => uploadChunkEffect(request)), - Match.when("complete-upload", () => - Effect.tryPromise({ - try: () => service.completeUpload(request), - catch: (cause) => librarianServiceError("complete-upload", cause), - }) - ), + Match.when("complete-upload", () => service.completeUpload(request)), Match.when("get-upload-status", () => getUploadStatusEffect(request)), Match.when("abort-upload", () => abortUploadEffect(request)), - Match.when("list-uploads", () => - Effect.tryPromise({ - try: () => service.listUploads(request), - catch: (cause) => librarianServiceError("list-uploads", cause), - }) - ), + Match.when("list-uploads", () => service.listUploads(request)), Match.when("stream-document", () => Effect.fail( librarianServiceError("stream-document", "stream-document must be handled as a streaming operation"), @@ -817,21 +787,18 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS Match.orElse((operation) => Effect.fail(librarianServiceError("operation", `Unknown librarian operation: ${String(operation)}`)) ), - ), ); }, - addDocument: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + addDocument: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const meta = yield* Effect.tryPromise<DocumentMetadata | undefined, LibrarianServiceError>({ - try: () => service.documentMetadata(request), - catch: (cause) => librarianServiceError("add-document-metadata", cause), - }); + return Effect.gen(function* () { + const meta = yield* service.documentMetadata(request).pipe( + Effect.mapError((cause) => librarianServiceError("add-document-metadata", cause)), + ); if (meta === undefined) return yield* librarianServiceError("add-document", "add-document requires documentMetadata"); const id = meta.id; @@ -856,30 +823,26 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS if (request.content !== undefined && request.content.length > 0) { const filePath = joinPath(service.dataDir, "docs", `${id}.bin`); const buf = Buffer.from(request.content, "base64"); - yield* Effect.tryPromise({ - try: () => writeBinaryFile(filePath, buf), - catch: (cause) => librarianServiceError("add-document-write", cause), - }); + yield* writeBinaryFileEffect(filePath, buf).pipe( + Effect.mapError((cause) => librarianServiceError("add-document-write", cause)), + ); } - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("add-document-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("add-document-persist", cause)), + ); yield* Effect.log(`[LibrarianService] Added document ${id}: ${doc.title}`); return service.documentResponse(doc); - }), - ); + }); }, - removeDocument: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + removeDocument: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const id = service.documentId(request); if (id === undefined || id.length === 0) { return yield* librarianServiceError("remove-document", "remove-document requires documentId"); @@ -912,41 +875,37 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS }); // Remove the file - yield* Effect.tryPromise({ - try: () => removePath(joinPath(service.dataDir, "docs", `${id}.bin`)), - catch: (cause) => librarianServiceError("remove-document-file", cause), - }).pipe(Effect.orElseSucceed(() => undefined)); + yield* removePathEffect(joinPath(service.dataDir, "docs", `${id}.bin`)).pipe( + Effect.mapError((cause) => librarianServiceError("remove-document-file", cause)), + Effect.orElseSucceed(() => undefined), + ); // Cascade: remove children for (const childId of removal.childIds) { - yield* Effect.tryPromise({ - try: () => removePath(joinPath(service.dataDir, "docs", `${childId}.bin`)), - catch: (cause) => librarianServiceError("remove-child-file", cause), - }).pipe(Effect.orElseSucceed(() => undefined)); + yield* removePathEffect(joinPath(service.dataDir, "docs", `${childId}.bin`)).pipe( + Effect.mapError((cause) => librarianServiceError("remove-child-file", cause)), + Effect.orElseSucceed(() => undefined), + ); } - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("remove-document-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("remove-document-persist", cause)), + ); yield* Effect.log(`[LibrarianService] Removed document ${id} (cascade: ${removal.childIds.length} children, ${removal.procIds.length} processing)`); return {}; - }), - ); + }); }, - updateDocument: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + updateDocument: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const meta = yield* Effect.tryPromise<DocumentMetadata | undefined, LibrarianServiceError>({ - try: () => service.documentMetadata(request), - catch: (cause) => librarianServiceError("update-document-metadata", cause), - }); + return Effect.gen(function* () { + const meta = yield* service.documentMetadata(request).pipe( + Effect.mapError((cause) => librarianServiceError("update-document-metadata", cause)), + ); const id = service.documentId(request) ?? meta?.id; if (id === undefined || id.length === 0) { return yield* librarianServiceError("update-document", "update-document requires documentId"); @@ -971,19 +930,18 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS documents, })); }); - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("update-document-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("update-document-persist", cause)), + ); return service.documentResponse(doc); - }), - ); + }); }, - listDocuments: function(this: LibrarianService, request: LibrarianRequest): LibrarianResponse { + listDocuments: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return Effect.sync(() => { const user = request.user ?? ""; const includeChildren = this.requestRecord(request)["include-children"] === true; const docs: DocumentMetadata[] = []; @@ -998,22 +956,22 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS } return this.documentsResponse(docs); + }); }, - getDocumentMetadata: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { - return Effect.runPromise(getDocumentMetadataEffect(request)); + getDocumentMetadata: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return getDocumentMetadataEffect(request); }, - getDocumentContent: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + getDocumentContent: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const id = service.documentId(request); if (id === undefined || id.length === 0) { return yield* librarianServiceError("get-document-content", "get-document-content requires documentId"); @@ -1025,27 +983,23 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS if (doc === undefined) return yield* librarianServiceError("get-document-content", `Document not found: ${id}`); const filePath = joinPath(service.dataDir, "docs", `${id}.bin`); - const buf = yield* Effect.tryPromise({ - try: () => readBinaryFile(filePath), - catch: () => librarianServiceError("get-document-content", `Document content not found on disk: ${id}`), - }); + const buf = yield* readBinaryFileEffect(filePath).pipe( + Effect.mapError(() => librarianServiceError("get-document-content", `Document content not found on disk: ${id}`)), + ); const content = Buffer.from(buf).toString("base64"); return { ...service.documentResponse(doc), content }; - }), - ); + }); }, - addChildDocument: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + addChildDocument: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const meta = yield* Effect.tryPromise<DocumentMetadata | undefined, LibrarianServiceError>({ - try: () => service.documentMetadata(request), - catch: (cause) => librarianServiceError("add-child-document-metadata", cause), - }); + return Effect.gen(function* () { + const meta = yield* service.documentMetadata(request).pipe( + Effect.mapError((cause) => librarianServiceError("add-child-document-metadata", cause)), + ); if (meta === undefined) { return yield* librarianServiceError("add-child-document", "add-child-document requires documentMetadata"); } @@ -1079,41 +1033,36 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS if (request.content !== undefined && request.content.length > 0) { const filePath = joinPath(service.dataDir, "docs", `${id}.bin`); const buf = Buffer.from(request.content, "base64"); - yield* Effect.tryPromise({ - try: () => writeBinaryFile(filePath, buf), - catch: (cause) => librarianServiceError("add-child-document-write", cause), - }); + yield* writeBinaryFileEffect(filePath, buf).pipe( + Effect.mapError((cause) => librarianServiceError("add-child-document-write", cause)), + ); } - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("add-child-document-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("add-child-document-persist", cause)), + ); yield* Effect.log(`[LibrarianService] Added child document ${id} (parent: ${parentId})`); return service.documentResponse(doc); - }), - ); + }); }, - listChildren: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { - return Effect.runPromise(listChildrenEffect(request)); + listChildren: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return listChildrenEffect(request); }, - addProcessing: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + addProcessing: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const proc = yield* Effect.tryPromise<ProcessingMetadata | undefined, LibrarianServiceError>({ - try: () => service.processingMetadata(request), - catch: (cause) => librarianServiceError("add-processing-metadata", cause), - }); + return Effect.gen(function* () { + const proc = yield* service.processingMetadata(request).pipe( + Effect.mapError((cause) => librarianServiceError("add-processing-metadata", cause)), + ); if (proc === undefined) return yield* librarianServiceError("add-processing", "add-processing requires processingMetadata"); const id = proc.id; @@ -1133,24 +1082,21 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS processing, }; }); - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("add-processing-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("add-processing-persist", cause)), + ); yield* Effect.log(`[LibrarianService] Added processing ${id} for document ${proc.documentId}`); return service.processingResponse([record]); - }), - ); + }); }, - removeProcessing: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + removeProcessing: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const id = service.processingId(request); if (id === undefined || id.length === 0) { return yield* librarianServiceError("remove-processing", "remove-processing requires processingId"); @@ -1164,20 +1110,19 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS processing, }; }); - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("remove-processing-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("remove-processing-persist", cause)), + ); return {}; - }), - ); + }); }, - listProcessing: function(this: LibrarianService, request: LibrarianRequest): LibrarianResponse { + listProcessing: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return Effect.sync(() => { const documentId = this.documentId(request); const records: ProcessingMetadata[] = []; const serviceState = this.state.pipe(stateSnapshot); @@ -1191,19 +1136,18 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS } return this.processingResponse(records); + }); }, - beginUpload: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + beginUpload: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const meta = yield* Effect.tryPromise<DocumentMetadata | undefined, LibrarianServiceError>({ - try: () => service.documentMetadata(request), - catch: (cause) => librarianServiceError("begin-upload-metadata", cause), - }); + return Effect.gen(function* () { + const meta = yield* service.documentMetadata(request).pipe( + Effect.mapError((cause) => librarianServiceError("begin-upload-metadata", cause)), + ); if (meta === undefined) return yield* librarianServiceError("begin-upload", "begin-upload requires documentMetadata"); const req = service.requestRecord(request); const totalSize = typeof req["total-size"] === "number" ? req["total-size"] : 0; @@ -1240,24 +1184,22 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS "chunk-size": chunkSize, "total-chunks": totalChunks, }; - }), - ); + }); }, - uploadChunk: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { - return Effect.runPromise(uploadChunkEffect(request)); + uploadChunk: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return uploadChunkEffect(request); }, - completeUpload: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + completeUpload: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const uploadId = optionalString(service.requestRecord(request)["upload-id"]); if (uploadId === undefined) return yield* librarianServiceError("complete-upload", "complete-upload requires upload-id"); const session = Option.getOrUndefined( @@ -1272,16 +1214,15 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS const content = Array.from({ length: session.totalChunks }, (_, i) => Option.getOrUndefined(MutableHashMap.get(session.chunks, i)) ?? "" ).join(""); - const response = yield* Effect.tryPromise<LibrarianResponse, LibrarianServiceError>({ - try: () => service.addDocument({ + const response = yield* service.addDocument({ operation: "add-document", documentMetadata: session.documentMetadata, "document-metadata": session.documentMetadata, content, user: session.user, - }), - catch: (cause) => librarianServiceError("complete-upload-add-document", cause), - }); + }).pipe( + Effect.mapError((cause) => librarianServiceError("complete-upload-add-document", cause)), + ); yield* SynchronizedRef.update(service.state, (serviceState) => { const uploads = cloneUploads(serviceState.uploads); MutableHashMap.remove(uploads, uploadId); @@ -1296,31 +1237,29 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS "document-id": documentId, "object-id": documentId, }; - }), - ); + }); }, - getUploadStatus: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { - return Effect.runPromise(getUploadStatusEffect(request)); + getUploadStatus: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return getUploadStatusEffect(request); }, - abortUpload: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { - return Effect.runPromise(abortUploadEffect(request)); + abortUpload: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { + return abortUploadEffect(request); }, - listUploads: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse> { + listUploads: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const user = optionalString(service.requestRecord(request).user); const sessions = []; const serviceState = yield* SynchronizedRef.get(service.state); @@ -1342,17 +1281,15 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS }); } return { "upload-sessions": sessions }; - }), - ); + }); }, - streamDocument: function(this: LibrarianService, request: LibrarianRequest): Promise<LibrarianResponse[]> { + streamDocument: function(this: LibrarianService, request: LibrarianRequest): Effect.Effect<LibrarianResponse[], LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const id = service.documentId(request); if (id === undefined) return yield* librarianServiceError("stream-document", "stream-document requires documentId"); const req = service.requestRecord(request); @@ -1360,10 +1297,9 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS ? req["chunk-size"] : 1024 * 1024; const filePath = joinPath(service.dataDir, "docs", `${id}.bin`); - const buf = yield* Effect.tryPromise({ - try: () => readBinaryFile(filePath), - catch: (cause) => librarianServiceError("stream-document-read", cause), - }); + const buf = yield* readBinaryFileEffect(filePath).pipe( + Effect.mapError((cause) => librarianServiceError("stream-document-read", cause)), + ); const base64 = Buffer.from(buf).toString("base64"); const totalChunks = Math.max(1, Math.ceil(base64.length / chunkSize)); return Array.from({ length: totalChunks }, (_, index) => { @@ -1376,8 +1312,7 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS eos: index === totalChunks - 1, }; }); - }), - ); + }); }, @@ -1385,10 +1320,9 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS // ---------- Collection management ---------- - handleCollectionMessage: function(this: LibrarianService, msg: Message<CollectionManagementRequest>): Promise<void> { + handleCollectionMessage: function(this: LibrarianService, msg: Message<CollectionManagementRequest>): Effect.Effect<void, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Effect.gen(function* () { + return Effect.gen(function* () { const request = msg.value(); const props = msg.properties(); const requestId = props.id; @@ -1403,17 +1337,15 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS if (producer === null) { return yield* librarianServiceError("collection-respond", "Collection producer not started"); } - yield* Effect.tryPromise({ - try: () => producer.send(response, { id: requestId }), - catch: (cause) => librarianServiceError("collection-respond", cause), - }); + yield* producer.send(response, { id: requestId }).pipe( + Effect.mapError((cause) => librarianServiceError("collection-respond", cause)), + ); }); yield* Effect.gen(function* () { - const response = yield* Effect.tryPromise<CollectionManagementResponse, LibrarianServiceError>({ - try: () => service.handleCollectionOperation(request), - catch: (cause) => librarianServiceError("collection-operation", cause), - }); + const response = yield* service.handleCollectionOperation(request).pipe( + Effect.mapError((cause) => librarianServiceError("collection-operation", cause)), + ); yield* sendResponse(response); }).pipe( Effect.catch((err) => @@ -1422,17 +1354,15 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS }), ), ); - }), - ); + }); }, - handleCollectionOperation: function(this: LibrarianService, request: CollectionManagementRequest): Promise<CollectionManagementResponse> { + handleCollectionOperation: function(this: LibrarianService, request: CollectionManagementRequest): Effect.Effect<CollectionManagementResponse, LibrarianServiceError> { const service = this; - return Effect.runPromise( - Match.value(request.operation).pipe( + return Match.value(request.operation).pipe( Match.when("list-collections", () => Effect.gen(function* () { const user = request.user ?? ""; @@ -1457,10 +1387,9 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS collectionManager, })); }); - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("update-collection-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("update-collection-persist", cause)), + ); return { collections }; }) @@ -1479,10 +1408,9 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS collectionManager, }; }); - yield* Effect.tryPromise({ - try: () => service.persist(), - catch: (cause) => librarianServiceError("delete-collection-persist", cause), - }); + yield* service.persist.pipe( + Effect.mapError((cause) => librarianServiceError("delete-collection-persist", cause)), + ); return {}; }) @@ -1490,7 +1418,6 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS Match.orElse((operation) => Effect.fail(librarianServiceError("collection-operation", `Unknown collection operation: ${String(operation)}`)) ), - ), ); }, @@ -1499,11 +1426,9 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS // ---------- Persistence ---------- - persist: function(this: LibrarianService): Promise<void> { - const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const serviceState = yield* SynchronizedRef.get(service.state); + persist: Effect.gen(function* () { + const current = service!; + const serviceState = yield* SynchronizedRef.get(current.state); const data = { documents: Object.fromEntries(serviceState.documents), processing: Object.fromEntries(serviceState.processing), @@ -1511,30 +1436,23 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS }; const json = yield* encodeJsonString("persist-encode", data); - yield* Effect.tryPromise({ - try: () => writeTextFile(service.persistPath, json), - catch: (cause) => librarianServiceError("persist-write", cause), - }); + yield* writeTextFileEffect(current.persistPath, json).pipe( + Effect.mapError((cause) => librarianServiceError("persist-write", cause)), + ); }).pipe( Effect.catch((err) => Effect.logError("[LibrarianService] Failed to persist state", { error: err.message }), ), ), - ); - - }, - loadFromDisk: function(this: LibrarianService): Promise<void> { - const service = this; - return Effect.runPromise( - Effect.gen(function* () { + loadFromDisk: Effect.gen(function* () { + const current = service!; const parsed = yield* Effect.gen(function* () { - const raw = yield* Effect.tryPromise({ - try: () => readTextFile(service.persistPath), - catch: (cause) => librarianServiceError("persist-read", cause), - }); + const raw = yield* readTextFileEffect(current.persistPath).pipe( + Effect.mapError((cause) => librarianServiceError("persist-read", cause)), + ); return yield* decodePersistedLibrarianState(raw); }).pipe( Effect.catch(() => @@ -1549,14 +1467,14 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS const documents = MutableHashMap.empty<string, DocumentMetadata>(); if (parsed.documents !== undefined) { for (const [id, doc] of Object.entries(parsed.documents)) { - MutableHashMap.set(documents, id, service.publicDocument(doc)); + MutableHashMap.set(documents, id, current.publicDocument(doc)); } } const processing = MutableHashMap.empty<string, ProcessingMetadata>(); if (parsed.processing !== undefined) { for (const [id, proc] of Object.entries(parsed.processing)) { - MutableHashMap.set(processing, id, service.publicProcessing(proc)); + MutableHashMap.set(processing, id, current.publicProcessing(proc)); } } @@ -1565,7 +1483,7 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS collectionManager.loadFromJSON(parsed.collections); } - yield* SynchronizedRef.update(service.state, (serviceState) => ({ + yield* SynchronizedRef.update(current.state, (serviceState) => ({ ...serviceState, documents, processing, @@ -1576,60 +1494,8 @@ export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianS `[LibrarianService] Loaded persisted state (documents=${MutableHashMap.size(documents)}, processing=${MutableHashMap.size(processing)})`, ); }), - ); - }, - - - - stop: function(this: LibrarianService): Promise<void> { - const service = this; - return Effect.runPromise( - Effect.gen(function* () { - const serviceState = yield* SynchronizedRef.get(service.state); - const libConsumer = serviceState.libConsumer; - if (libConsumer !== null) { - yield* Effect.tryPromise({ - try: () => libConsumer.close(), - catch: (cause) => librarianServiceError("close-librarian-consumer", cause), - }); - } - const libProducer = serviceState.libProducer; - if (libProducer !== null) { - yield* Effect.tryPromise({ - try: () => libProducer.close(), - catch: (cause) => librarianServiceError("close-librarian-producer", cause), - }); - } - const colConsumer = serviceState.colConsumer; - if (colConsumer !== null) { - yield* Effect.tryPromise({ - try: () => colConsumer.close(), - catch: (cause) => librarianServiceError("close-collection-consumer", cause), - }); - } - const colProducer = serviceState.colProducer; - if (colProducer !== null) { - yield* Effect.tryPromise({ - try: () => colProducer.close(), - catch: (cause) => librarianServiceError("close-collection-producer", cause), - }); - } - yield* updateHandles(service.state, { - libConsumer: null, - libProducer: null, - colConsumer: null, - colProducer: null, - }); - yield* Effect.tryPromise({ - try: () => baseStop(), - catch: (cause) => librarianServiceError("stop", cause), - }); - }), - ); - - } - }); + }) as LibrarianService; service = librarianService; return librarianService; } @@ -1641,12 +1507,6 @@ export const program = makeProcessorProgram({ make: (config) => makeLibrarianService(config), }); -const librarianRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return librarianRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } 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 2428f804..15a6ecf0 100644 --- a/ts/packages/flow/src/model/text-completion/azure-openai.ts +++ b/ts/packages/flow/src/model/text-completion/azure-openai.ts @@ -18,9 +18,8 @@ import { type LlmProvider, type ProcessorConfig, type LlmResult, - type LlmChunk, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { llmStreamPart, makeTextCompletionLayer, @@ -28,7 +27,6 @@ import { providerStatusError, requiredString, streamTextCompletionChunks, - toAsyncGenerator, type TextCompletionConfigError, type TextCompletionRuntimeError, } from "./common.ts"; @@ -89,7 +87,7 @@ const mapAzureOpenAIError = (error: unknown): TextCompletionRuntimeError => const makeAzureOpenAIProviderFromClient = ( resolved: ResolvedAzureOpenAIConfig, client: AzureOpenAI, -): LlmProvider => { +): LlmProvider<TextCompletionRuntimeError> => { const { defaultModel, defaultTemperature, @@ -102,31 +100,29 @@ const makeAzureOpenAIProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): Promise<LlmResult> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - return Effect.runPromise( - Effect.tryPromise({ - try: () => - client.chat.completions.create({ - model: modelName, - messages: [ - { role: "system", content: system }, - { role: "user", content: prompt }, - ], - temperature: temp, - max_completion_tokens: maxOutput, - }), - catch: mapAzureOpenAIError, - }).pipe( - Effect.map((resp): LlmResult => ({ - text: resp.choices[0].message.content ?? "", - inToken: resp.usage?.prompt_tokens ?? 0, - outToken: resp.usage?.completion_tokens ?? 0, + return Effect.tryPromise({ + try: () => + client.chat.completions.create({ model: modelName, - })), - ), + messages: [ + { role: "system", content: system }, + { role: "user", content: prompt }, + ], + temperature: temp, + max_completion_tokens: maxOutput, + }), + catch: mapAzureOpenAIError, + }).pipe( + Effect.map((resp): LlmResult => ({ + text: resp.choices[0].message.content ?? "", + inToken: resp.usage?.prompt_tokens ?? 0, + outToken: resp.usage?.completion_tokens ?? 0, + model: modelName, + })), ); }, supportsStreaming: () => true, @@ -135,11 +131,11 @@ const makeAzureOpenAIProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): AsyncGenerator<LlmChunk> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - const stream = Stream.fromEffect( + return Stream.fromEffect( Effect.tryPromise({ try: () => client.chat.completions.create({ @@ -169,13 +165,13 @@ const makeAzureOpenAIProviderFromClient = ( }) ), ); - - return toAsyncGenerator(Stream.toAsyncIterable(stream), mapAzureOpenAIError); }, - } satisfies LlmProvider; + } satisfies LlmProvider<TextCompletionRuntimeError>; }; -export function makeAzureOpenAIProvider(config: AzureOpenAIProcessorConfig): LlmProvider { +export function makeAzureOpenAIProvider( + config: AzureOpenAIProcessorConfig, +): LlmProvider<TextCompletionRuntimeError> { return Effect.runSync(makeAzureOpenAIProviderEffect(config)); } @@ -217,12 +213,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => makeTextCompletionLayer(makeAzureOpenAIProviderEffect(config)), }); -const azureOpenAITextCompletionRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return azureOpenAITextCompletionRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/model/text-completion/claude.ts b/ts/packages/flow/src/model/text-completion/claude.ts index 6aac0856..b23eb158 100644 --- a/ts/packages/flow/src/model/text-completion/claude.ts +++ b/ts/packages/flow/src/model/text-completion/claude.ts @@ -14,7 +14,7 @@ import { type LlmProvider, type ProcessorConfig, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Redacted } from "effect"; +import { Effect, Layer, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { makeLanguageModelProvider, @@ -55,30 +55,31 @@ const loadClaudeConfig = Effect.fn("loadClaudeConfig")(function* (config: Claude } satisfies ResolvedClaudeConfig; }); -const makeClaudeRuntime = (apiKey: string) => - ManagedRuntime.make( - AnthropicClient.layer({ - apiKey: Redacted.make(apiKey), - }).pipe( - Layer.provide(FetchHttpClient.layer), - ), +const makeClaudeLayer = (apiKey: string) => + AnthropicClient.layer({ + apiKey: Redacted.make(apiKey), + }).pipe( + Layer.provide(FetchHttpClient.layer), ); -export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider { - return Effect.runSync(makeClaudeProviderEffect(config)); +export function makeClaudeProvider( + config: ClaudeProcessorConfig, +): LlmProvider<TextCompletionRuntimeError> { + return Effect.runSync(Effect.scoped(makeClaudeProviderEffect(config))); } export const makeClaudeProviderEffect = Effect.fn("makeClaudeProvider")(function* ( config: ClaudeProcessorConfig, ) { const resolved = yield* loadClaudeConfig(config); + const context = yield* Layer.build(makeClaudeLayer(resolved.apiKey)); yield* Effect.log("[Claude] LLM service initialized"); return makeLanguageModelProvider({ provider: "Claude", defaultModel: resolved.defaultModel, defaultTemperature: resolved.defaultTemperature, - runtime: makeClaudeRuntime(resolved.apiKey), + context, makeLanguageModel: ({ model, temperature }) => AnthropicLanguageModel.make({ model, @@ -110,12 +111,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => makeTextCompletionLayer(makeClaudeProviderEffect(config)), }); -const claudeTextCompletionRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return claudeTextCompletionRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/model/text-completion/common.ts b/ts/packages/flow/src/model/text-completion/common.ts index 93ca4f22..37b8cf66 100644 --- a/ts/packages/flow/src/model/text-completion/common.ts +++ b/ts/packages/flow/src/model/text-completion/common.ts @@ -7,10 +7,11 @@ import { type LlmResult, type LlmProvider, } from "@trustgraph/base"; -import { Config, Effect, Layer, ManagedRuntime, Match, Ref, Result, Stream } from "effect"; +import { Config, Context, Effect, Layer, Match, Ref, Result, Stream } from "effect"; import * as O from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; +import type * as Scope from "effect/Scope"; import { AiError, LanguageModel, Prompt, Response } from "effect/unstable/ai"; export class TextCompletionConfigError extends S.TaggedErrorClass<TextCompletionConfigError>()( @@ -43,15 +44,15 @@ export interface LanguageModelProviderOptions<Requirements> { readonly provider: string; readonly defaultModel: string; readonly defaultTemperature: number; - readonly runtime: ManagedRuntime.ManagedRuntime<Requirements, TextCompletionRuntimeError>; + readonly context: Context.Context<Requirements>; readonly makeLanguageModel: ( request: LanguageModelProviderRequest, ) => Effect.Effect<LanguageModel.Service, TextCompletionRuntimeError, Requirements>; } -export const makeTextCompletionLayer = <E, R>( - provider: Effect.Effect<LlmProvider, E, R>, -): Layer.Layer<Llm, E, R> => +export const makeTextCompletionLayer = <ProviderError, E, R>( + provider: Effect.Effect<LlmProvider<ProviderError>, E, R>, +): Layer.Layer<Llm, E, Exclude<R, Scope.Scope>> => Layer.effect(Llm)( provider.pipe( Effect.map((resolvedProvider) => @@ -279,39 +280,25 @@ const languageModelStreamChunk = ( Match.orElse(() => Effect.succeed(Result.fail(undefined))), ); -const runLanguageModelStream = <RuntimeRequirements, StreamRequirements extends RuntimeRequirements>( - runtime: ManagedRuntime.ManagedRuntime<RuntimeRequirements, TextCompletionRuntimeError>, - stream: Stream.Stream<LlmChunk, TextCompletionRuntimeError, StreamRequirements>, -): AsyncIterable<LlmChunk> => ({ - [Symbol.asyncIterator]: () => { - const iterator = runtime.context().then((context) => - Stream.toAsyncIterableWith(stream, context)[Symbol.asyncIterator]() - ); - return { - next: () => iterator.then((current) => current.next()), - }; - }, -}); - export const makeLanguageModelProvider = <Requirements>( options: LanguageModelProviderOptions<Requirements>, -): LlmProvider => ({ +): LlmProvider<TextCompletionRuntimeError> => ({ generateContent: (system, prompt, model, temperature) => { const modelName = model ?? options.defaultModel; const temp = temperature ?? options.defaultTemperature; - return options.runtime.runPromise( - Effect.gen(function* () { - const languageModel = yield* options.makeLanguageModel({ - model: modelName, - temperature: temp, - }); - const response = yield* languageModel.generateText({ - prompt: languageModelPrompt(system, prompt), - }).pipe( - Effect.mapError((error) => effectAiProviderError(options.provider, error)), - ); - return languageModelResult(response, modelName); - }), + return Effect.gen(function* () { + const languageModel = yield* options.makeLanguageModel({ + model: modelName, + temperature: temp, + }); + const response = yield* languageModel.generateText({ + prompt: languageModelPrompt(system, prompt), + }).pipe( + Effect.mapError((error) => effectAiProviderError(options.provider, error)), + ); + return languageModelResult(response, modelName); + }).pipe( + Effect.provideContext(options.context), ); }, supportsStreaming: () => true, @@ -333,30 +320,9 @@ export const makeLanguageModelProvider = <Requirements>( ), ); }), + ).pipe( + Stream.provideContext(options.context), ); - return toAsyncGenerator(runLanguageModelStream(options.runtime, stream), (error) => - effectAiProviderError(options.provider, error) - ); + return stream; }, }); - -export const toAsyncGenerator = ( - iterable: AsyncIterable<LlmChunk>, - mapError: (error: unknown) => TextCompletionRuntimeError, -): AsyncGenerator<LlmChunk> => { - const iterator = iterable[Symbol.asyncIterator](); - let generator: AsyncGenerator<LlmChunk>; - generator = { - next: (value?: unknown) => iterator.next(value), - return: (value?: unknown) => - iterator.return === undefined - ? Promise.resolve({ done: true, value }) - : iterator.return(value), - throw: (error?: unknown) => - iterator.throw === undefined - ? Promise.reject(mapError(error)) - : iterator.throw(error), - [Symbol.asyncIterator]: () => generator, - }; - return generator; -}; diff --git a/ts/packages/flow/src/model/text-completion/mistral.ts b/ts/packages/flow/src/model/text-completion/mistral.ts index 2f2bb3ec..e8d808fa 100644 --- a/ts/packages/flow/src/model/text-completion/mistral.ts +++ b/ts/packages/flow/src/model/text-completion/mistral.ts @@ -16,9 +16,8 @@ import { type LlmProvider, type ProcessorConfig, type LlmResult, - type LlmChunk, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { llmStreamPart, makeTextCompletionLayer, @@ -27,7 +26,6 @@ import { requiredString, streamTextCompletionChunks, textFromContent, - toAsyncGenerator, type TextCompletionConfigError, type TextCompletionRuntimeError, } from "./common.ts"; @@ -71,7 +69,7 @@ const mapMistralError = (error: unknown): TextCompletionRuntimeError => const makeMistralProviderFromClient = ( resolved: ResolvedMistralConfig, client: Mistral, -): LlmProvider => { +): LlmProvider<TextCompletionRuntimeError> => { const { defaultModel, defaultTemperature, @@ -84,31 +82,29 @@ const makeMistralProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): Promise<LlmResult> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - return Effect.runPromise( - Effect.tryPromise({ - try: () => - client.chat.complete({ - model: modelName, - messages: [ - { role: "system", content: system }, - { role: "user", content: prompt }, - ], - temperature: temp, - maxTokens: maxOutput, - }), - catch: mapMistralError, - }).pipe( - Effect.map((resp): LlmResult => ({ - text: textFromContent(resp.choices?.[0]?.message?.content), - inToken: resp.usage?.promptTokens ?? 0, - outToken: resp.usage?.completionTokens ?? 0, + return Effect.tryPromise({ + try: () => + client.chat.complete({ model: modelName, - })), - ), + messages: [ + { role: "system", content: system }, + { role: "user", content: prompt }, + ], + temperature: temp, + maxTokens: maxOutput, + }), + catch: mapMistralError, + }).pipe( + Effect.map((resp): LlmResult => ({ + text: textFromContent(resp.choices?.[0]?.message?.content), + inToken: resp.usage?.promptTokens ?? 0, + outToken: resp.usage?.completionTokens ?? 0, + model: modelName, + })), ); }, supportsStreaming: () => true, @@ -117,11 +113,11 @@ const makeMistralProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): AsyncGenerator<LlmChunk> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - const stream = Stream.fromEffect( + return Stream.fromEffect( Effect.tryPromise({ try: () => client.chat.stream({ @@ -149,13 +145,13 @@ const makeMistralProviderFromClient = ( }) ), ); - - return toAsyncGenerator(Stream.toAsyncIterable(stream), mapMistralError); }, - } satisfies LlmProvider; + } satisfies LlmProvider<TextCompletionRuntimeError>; }; -export function makeMistralProvider(config: MistralProcessorConfig): LlmProvider { +export function makeMistralProvider( + config: MistralProcessorConfig, +): LlmProvider<TextCompletionRuntimeError> { return Effect.runSync(makeMistralProviderEffect(config)); } @@ -192,12 +188,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => makeTextCompletionLayer(makeMistralProviderEffect(config)), }); -const mistralTextCompletionRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return mistralTextCompletionRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/model/text-completion/ollama.ts b/ts/packages/flow/src/model/text-completion/ollama.ts index 051ac9fd..6ecae2e7 100644 --- a/ts/packages/flow/src/model/text-completion/ollama.ts +++ b/ts/packages/flow/src/model/text-completion/ollama.ts @@ -16,16 +16,14 @@ import { type LlmProvider, type ProcessorConfig, type LlmResult, - type LlmChunk, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { llmStreamPart, makeTextCompletionLayer, optionalStringConfig, providerRuntimeError, streamTextCompletionChunks, - toAsyncGenerator, type TextCompletionConfigError, type TextCompletionRuntimeError, } from "./common.ts"; @@ -59,7 +57,7 @@ const mapOllamaError = (error: unknown): TextCompletionRuntimeError => const makeOllamaProviderFromClient = ( resolved: ResolvedOllamaConfig, client: Ollama, -): LlmProvider => { +): LlmProvider<TextCompletionRuntimeError> => { const { defaultModel } = resolved; return { @@ -68,27 +66,25 @@ const makeOllamaProviderFromClient = ( prompt: string, model?: string, _temperature?: number, - ): Promise<LlmResult> => { + ) => { const modelName = model ?? defaultModel; const fullPrompt = system + "\n\n" + prompt; - return Effect.runPromise( - Effect.tryPromise({ - try: () => - client.generate({ - model: modelName, - prompt: fullPrompt, - stream: false, - }), - catch: mapOllamaError, - }).pipe( - Effect.map((resp): LlmResult => ({ - text: resp.response, - inToken: resp.prompt_eval_count ?? 0, - outToken: resp.eval_count ?? 0, + return Effect.tryPromise({ + try: () => + client.generate({ model: modelName, - })), - ), + prompt: fullPrompt, + stream: false, + }), + catch: mapOllamaError, + }).pipe( + Effect.map((resp): LlmResult => ({ + text: resp.response, + inToken: resp.prompt_eval_count ?? 0, + outToken: resp.eval_count ?? 0, + model: modelName, + })), ); }, supportsStreaming: () => true, @@ -97,11 +93,11 @@ const makeOllamaProviderFromClient = ( prompt: string, model?: string, _temperature?: number, - ): AsyncGenerator<LlmChunk> => { + ) => { const modelName = model ?? defaultModel; const fullPrompt = system + "\n\n" + prompt; - const stream = Stream.fromEffect( + return Stream.fromEffect( Effect.tryPromise({ try: () => client.generate({ @@ -125,13 +121,13 @@ const makeOllamaProviderFromClient = ( }) ), ); - - return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOllamaError); }, - } satisfies LlmProvider; + } satisfies LlmProvider<TextCompletionRuntimeError>; }; -export function makeOllamaProvider(config: OllamaProcessorConfig): LlmProvider { +export function makeOllamaProvider( + config: OllamaProcessorConfig, +): LlmProvider<TextCompletionRuntimeError> { return Effect.runSync(makeOllamaProviderEffect(config)); } @@ -170,12 +166,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => makeTextCompletionLayer(makeOllamaProviderEffect(config)), }); -const ollamaTextCompletionRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return ollamaTextCompletionRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } 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 07ef2d7a..9bc2c137 100644 --- a/ts/packages/flow/src/model/text-completion/openai-compatible.ts +++ b/ts/packages/flow/src/model/text-completion/openai-compatible.ts @@ -19,9 +19,8 @@ import { type LlmProvider, type ProcessorConfig, type LlmResult, - type LlmChunk, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { llmStreamPart, makeTextCompletionLayer, @@ -29,7 +28,6 @@ import { providerStatusError, requiredString, streamTextCompletionChunks, - toAsyncGenerator, type TextCompletionConfigError, type TextCompletionRuntimeError, } from "./common.ts"; @@ -79,7 +77,7 @@ const mapOpenAICompatibleError = (error: unknown): TextCompletionRuntimeError => const makeOpenAICompatibleProviderFromClient = ( resolved: ResolvedOpenAICompatibleConfig, client: OpenAI, -): LlmProvider => { +): LlmProvider<TextCompletionRuntimeError> => { const { defaultModel, defaultTemperature, @@ -92,31 +90,29 @@ const makeOpenAICompatibleProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): Promise<LlmResult> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - return Effect.runPromise( - Effect.tryPromise({ - try: () => - client.chat.completions.create({ - model: modelName, - messages: [ - { role: "system", content: system }, - { role: "user", content: prompt }, - ], - temperature: temp, - max_tokens: maxOutput, - }), - catch: mapOpenAICompatibleError, - }).pipe( - Effect.map((resp): LlmResult => ({ - text: resp.choices[0].message.content ?? "", - inToken: resp.usage?.prompt_tokens ?? 0, - outToken: resp.usage?.completion_tokens ?? 0, + return Effect.tryPromise({ + try: () => + client.chat.completions.create({ model: modelName, - })), - ), + messages: [ + { role: "system", content: system }, + { role: "user", content: prompt }, + ], + temperature: temp, + max_tokens: maxOutput, + }), + catch: mapOpenAICompatibleError, + }).pipe( + Effect.map((resp): LlmResult => ({ + text: resp.choices[0].message.content ?? "", + inToken: resp.usage?.prompt_tokens ?? 0, + outToken: resp.usage?.completion_tokens ?? 0, + model: modelName, + })), ); }, supportsStreaming: () => true, @@ -125,11 +121,11 @@ const makeOpenAICompatibleProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): AsyncGenerator<LlmChunk> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - const stream = Stream.fromEffect( + return Stream.fromEffect( Effect.tryPromise({ try: () => client.chat.completions.create({ @@ -158,15 +154,13 @@ const makeOpenAICompatibleProviderFromClient = ( }) ), ); - - return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOpenAICompatibleError); }, - } satisfies LlmProvider; + } satisfies LlmProvider<TextCompletionRuntimeError>; }; export function makeOpenAICompatibleProvider( config: OpenAICompatibleProcessorConfig, -): LlmProvider { +): LlmProvider<TextCompletionRuntimeError> { return Effect.runSync(makeOpenAICompatibleProviderEffect(config)); } @@ -203,12 +197,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => makeTextCompletionLayer(makeOpenAICompatibleProviderEffect(config)), }); -const openAICompatibleTextCompletionRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return openAICompatibleTextCompletionRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/model/text-completion/openai.ts b/ts/packages/flow/src/model/text-completion/openai.ts index b36cc2d7..182fb797 100644 --- a/ts/packages/flow/src/model/text-completion/openai.ts +++ b/ts/packages/flow/src/model/text-completion/openai.ts @@ -14,9 +14,8 @@ import { type LlmProvider, type ProcessorConfig, type LlmResult, - type LlmChunk, } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime, Stream } from "effect"; +import { Effect, Stream } from "effect"; import { llmStreamPart, makeTextCompletionLayer, @@ -24,7 +23,6 @@ import { providerStatusError, requiredString, streamTextCompletionChunks, - toAsyncGenerator, type TextCompletionConfigError, type TextCompletionRuntimeError, } from "./common.ts"; @@ -68,7 +66,7 @@ const mapOpenAIError = (error: unknown): TextCompletionRuntimeError => const makeOpenAIProviderFromClient = ( resolved: ResolvedOpenAIConfig, client: OpenAI, -): LlmProvider => { +): LlmProvider<TextCompletionRuntimeError> => { const { defaultModel, defaultTemperature, @@ -81,31 +79,29 @@ const makeOpenAIProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): Promise<LlmResult> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - return Effect.runPromise( - Effect.tryPromise({ - try: () => - client.chat.completions.create({ - model: modelName, - messages: [ - { role: "system", content: system }, - { role: "user", content: prompt }, - ], - temperature: temp, - max_completion_tokens: maxOutput, - }), - catch: mapOpenAIError, - }).pipe( - Effect.map((resp): LlmResult => ({ - text: resp.choices[0].message.content ?? "", - inToken: resp.usage?.prompt_tokens ?? 0, - outToken: resp.usage?.completion_tokens ?? 0, + return Effect.tryPromise({ + try: () => + client.chat.completions.create({ model: modelName, - })), - ), + messages: [ + { role: "system", content: system }, + { role: "user", content: prompt }, + ], + temperature: temp, + max_completion_tokens: maxOutput, + }), + catch: mapOpenAIError, + }).pipe( + Effect.map((resp): LlmResult => ({ + text: resp.choices[0].message.content ?? "", + inToken: resp.usage?.prompt_tokens ?? 0, + outToken: resp.usage?.completion_tokens ?? 0, + model: modelName, + })), ); }, supportsStreaming: () => true, @@ -114,11 +110,11 @@ const makeOpenAIProviderFromClient = ( prompt: string, model?: string, temperature?: number, - ): AsyncGenerator<LlmChunk> => { + ) => { const modelName = model ?? defaultModel; const temp = temperature ?? defaultTemperature; - const stream = Stream.fromEffect( + return Stream.fromEffect( Effect.tryPromise({ try: () => client.chat.completions.create({ @@ -148,13 +144,13 @@ const makeOpenAIProviderFromClient = ( }) ), ); - - return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOpenAIError); }, - } satisfies LlmProvider; + } satisfies LlmProvider<TextCompletionRuntimeError>; }; -export function makeOpenAIProvider(config: OpenAIProcessorConfig): LlmProvider { +export function makeOpenAIProvider( + config: OpenAIProcessorConfig, +): LlmProvider<TextCompletionRuntimeError> { return Effect.runSync(makeOpenAIProviderEffect(config)); } @@ -195,12 +191,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => makeTextCompletionLayer(makeOpenAIProviderEffect(config)), }); -const openAITextCompletionRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return openAITextCompletionRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/prompt/template.ts b/ts/packages/flow/src/prompt/template.ts index a82001a5..ec771277 100644 --- a/ts/packages/flow/src/prompt/template.ts +++ b/ts/packages/flow/src/prompt/template.ts @@ -40,7 +40,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import * as MutableHashMap from "effect/MutableHashMap"; import * as O from "effect/Option"; import * as S from "effect/Schema"; @@ -167,9 +167,7 @@ export function makePromptTemplateService(config: PromptTemplateConfig): PromptT specifications: runtime.specs, }); for (const handler of runtime.configHandlers) { - service.registerConfigHandler((pushedConfig, version) => - Effect.runPromise(handler(pushedConfig, version)), - ); + service.registerConfigHandler(handler); } Effect.runSync(Effect.log("[PromptTemplate] Service initialized")); return service; @@ -199,12 +197,6 @@ export const program = makeFlowProcessorProgram({ configHandlers: (config: PromptTemplateConfig) => promptTemplateRuntime(config).configHandlers, }); -const promptRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return promptRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/qdrant/client.ts b/ts/packages/flow/src/qdrant/client.ts index 3a8a6480..737dd775 100644 --- a/ts/packages/flow/src/qdrant/client.ts +++ b/ts/packages/flow/src/qdrant/client.ts @@ -1,4 +1,7 @@ import { QdrantClient, type QdrantClientParams } from "@qdrant/js-client-rest"; +import { errorMessage } from "@trustgraph/base"; +import { Effect } from "effect"; +import * as S from "effect/Schema"; export interface QdrantCollectionStatus { readonly exists: boolean; @@ -17,8 +20,21 @@ export interface QdrantScoredPoint { readonly payload?: unknown; } +export class QdrantClientError extends S.TaggedErrorClass<QdrantClientError>()("QdrantClientError", { + message: S.String, + operation: S.String, + cause: S.Defect({ includeStack: true }), +}) {} + +const qdrantClientError = (operation: string, cause: unknown) => + QdrantClientError.make({ + operation, + message: errorMessage(cause), + cause, + }); + export interface QdrantClientLike { - readonly collectionExists: (collectionName: string) => Promise<QdrantCollectionStatus>; + readonly collectionExists: (collectionName: string) => Effect.Effect<QdrantCollectionStatus, QdrantClientError>; readonly createCollection: ( collectionName: string, options: { @@ -27,7 +43,7 @@ export interface QdrantClientLike { readonly distance: "Cosine"; }; }, - ) => Promise<unknown>; + ) => Effect.Effect<void, QdrantClientError>; readonly upsert: ( collectionName: string, options: { @@ -37,9 +53,9 @@ export interface QdrantClientLike { readonly payload?: Record<string, unknown>; }>; }, - ) => Promise<unknown>; - readonly getCollections: () => Promise<QdrantCollections>; - readonly deleteCollection: (collectionName: string) => Promise<unknown>; + ) => Effect.Effect<void, QdrantClientError>; + readonly getCollections: Effect.Effect<QdrantCollections, QdrantClientError>; + readonly deleteCollection: (collectionName: string) => Effect.Effect<void, QdrantClientError>; readonly search: ( collectionName: string, options: { @@ -47,7 +63,7 @@ export interface QdrantClientLike { readonly limit: number; readonly with_payload: boolean; }, - ) => Promise<ReadonlyArray<QdrantScoredPoint>>; + ) => Effect.Effect<ReadonlyArray<QdrantScoredPoint>, QdrantClientError>; } export type QdrantClientFactory = (params: QdrantClientParams) => QdrantClientLike; @@ -61,24 +77,41 @@ export const makeQdrantClient = ( } const client = new QdrantClient(params); + const tryQdrantPromise = <A>(operation: string, try_: () => PromiseLike<A>) => + Effect.tryPromise({ + try: try_, + catch: (cause) => qdrantClientError(operation, cause), + }); + return { - collectionExists: (collectionName) => client.collectionExists(collectionName), - createCollection: (collectionName, options) => client.createCollection(collectionName, options), + collectionExists: (collectionName) => + tryQdrantPromise("collection-exists", () => client.collectionExists(collectionName)), + createCollection: (collectionName, options) => + tryQdrantPromise("create-collection", () => client.createCollection(collectionName, options)).pipe( + Effect.asVoid, + ), upsert: (collectionName, options) => - client.upsert(collectionName, { - points: options.points.map((point) => ({ - id: point.id, - vector: Array.from(point.vector), - ...(point.payload !== undefined ? { payload: point.payload } : {}), - })), - }), - getCollections: () => client.getCollections(), - deleteCollection: (collectionName) => client.deleteCollection(collectionName), + tryQdrantPromise("upsert", () => + client.upsert(collectionName, { + points: options.points.map((point) => ({ + id: point.id, + vector: Array.from(point.vector), + ...(point.payload !== undefined ? { payload: point.payload } : {}), + })), + }) + ).pipe(Effect.asVoid), + getCollections: tryQdrantPromise("get-collections", () => client.getCollections()), + deleteCollection: (collectionName) => + tryQdrantPromise("delete-collection", () => client.deleteCollection(collectionName)).pipe( + Effect.asVoid, + ), search: (collectionName, options) => - client.search(collectionName, { - vector: Array.from(options.vector), - limit: options.limit, - with_payload: options.with_payload, - }), + tryQdrantPromise("search", () => + client.search(collectionName, { + vector: Array.from(options.vector), + limit: options.limit, + with_payload: options.with_payload, + }) + ), }; }; 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 6cb8b55b..1ad19545 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-doc-service.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-doc-service.ts @@ -24,7 +24,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import { QdrantDocEmbeddingsQueryLive, QdrantDocEmbeddingsQueryService, @@ -111,12 +111,10 @@ const provideQdrantDocEmbeddingsQuery = (processorId: string) => }); export function makeDocEmbeddingsQueryService(config: ProcessorConfig): DocEmbeddingsQueryService { - const service = makeFlowProcessor(config, { + return makeFlowProcessor(config, { specifications: makeDocEmbeddingsQuerySpecs(), provide: provideQdrantDocEmbeddingsQuery(config.id), }); - void Effect.runPromise(Effect.log("[DocEmbeddingsQuery] Service initialized")); - return service; } export const DocEmbeddingsQueryService = makeDocEmbeddingsQueryService; @@ -131,12 +129,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => QdrantDocEmbeddingsQueryLive(config), }); -const docEmbeddingsQueryRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return docEmbeddingsQueryRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/query/embeddings/qdrant-doc.ts b/ts/packages/flow/src/query/embeddings/qdrant-doc.ts index 72fb7a20..84640e8b 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-doc.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-doc.ts @@ -37,7 +37,7 @@ export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocE { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -73,8 +73,7 @@ const decodeDocPointPayload = (payload: unknown) => S.decodeUnknownEffect(DocPointPayloadSchema)(payload).pipe(Effect.option); export interface QdrantDocEmbeddingsQuery { - readonly query: (request: DocEmbeddingsQueryRequest) => Promise<ReadonlyArray<ChunkMatch>>; - readonly queryEffect: ( + readonly query: ( request: DocEmbeddingsQueryRequest, ) => Effect.Effect<ReadonlyArray<ChunkMatch>, QdrantDocEmbeddingsQueryError>; } @@ -95,7 +94,7 @@ const makeQdrantDocEmbeddingsQueryClient = ( const makeQdrantDocEmbeddingsQueryFromClient = ( client: QdrantClientLike, ): QdrantDocEmbeddingsQueryServiceShape => { - const queryEffect = Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request: DocEmbeddingsQueryRequest) { + const queryImpl = Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request: DocEmbeddingsQueryRequest) { const { vector, user, collection, limit } = request; if (vector.length === 0) { @@ -106,10 +105,9 @@ const makeQdrantDocEmbeddingsQueryFromClient = ( const collectionName = `d_${user}_${collection}_${dim}`; // Check if collection exists -- return empty if not - const exists = yield* Effect.tryPromise({ - try: () => client.collectionExists(collectionName), - catch: (cause) => qdrantDocEmbeddingsQueryError("collection-exists", cause), - }); + const exists = yield* client.collectionExists(collectionName).pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsQueryError("collection-exists", cause)), + ); if (!exists.exists) { yield* Effect.log( `[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`, @@ -117,15 +115,16 @@ const makeQdrantDocEmbeddingsQueryFromClient = ( return []; } - const searchResult = yield* Effect.tryPromise({ - try: () => - client.search(collectionName, { - vector, - limit, - with_payload: true, - }), - catch: (cause) => qdrantDocEmbeddingsQueryError("search", cause), - }); + const searchResult = yield* client.search( + collectionName, + { + vector, + limit, + with_payload: true, + }, + ).pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsQueryError("search", cause)), + ); const chunks: ChunkMatch[] = []; for (const point of searchResult) { @@ -146,7 +145,7 @@ const makeQdrantDocEmbeddingsQueryFromClient = ( }); return { - query: queryEffect, + query: queryImpl, }; }; @@ -172,12 +171,8 @@ const withQdrantDocEmbeddingsQuery = <A>( export function makeQdrantDocEmbeddingsQuery( config: QdrantDocQueryConfig = {}, ): QdrantDocEmbeddingsQuery { - const queryEffect = (request: DocEmbeddingsQueryRequest) => - withQdrantDocEmbeddingsQuery(config, (query) => query.query(request)); - return { - query: (request) => Effect.runPromise(queryEffect(request)), - queryEffect, + query: (request) => withQdrantDocEmbeddingsQuery(config, (query) => query.query(request)), }; } 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 9e34f157..ad479145 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-graph-service.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-graph-service.ts @@ -24,7 +24,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import { QdrantGraphEmbeddingsQueryLive, QdrantGraphEmbeddingsQueryService, @@ -112,12 +112,10 @@ const provideQdrantGraphEmbeddingsQuery = (processorId: string) => }); export function makeGraphEmbeddingsQueryService(config: ProcessorConfig): GraphEmbeddingsQueryService { - const service = makeFlowProcessor(config, { + return makeFlowProcessor(config, { specifications: makeGraphEmbeddingsQuerySpecs(), provide: provideQdrantGraphEmbeddingsQuery(config.id), }); - void Effect.runPromise(Effect.log("[GraphEmbeddingsQuery] Service initialized")); - return service; } export const GraphEmbeddingsQueryService = makeGraphEmbeddingsQueryService; @@ -132,12 +130,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => QdrantGraphEmbeddingsQueryLive(config), }); -const graphEmbeddingsQueryRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return graphEmbeddingsQueryRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/query/embeddings/qdrant-graph.ts b/ts/packages/flow/src/query/embeddings/qdrant-graph.ts index f976cc51..811f923f 100644 --- a/ts/packages/flow/src/query/embeddings/qdrant-graph.ts +++ b/ts/packages/flow/src/query/embeddings/qdrant-graph.ts @@ -39,7 +39,7 @@ export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGr { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -81,8 +81,7 @@ const decodeGraphPointPayload = (payload: unknown) => S.decodeUnknownEffect(GraphPointPayloadSchema)(payload).pipe(Effect.option); export interface QdrantGraphEmbeddingsQuery { - readonly query: (request: GraphEmbeddingsQueryRequest) => Promise<ReadonlyArray<EntityMatch>>; - readonly queryEffect: ( + readonly query: ( request: GraphEmbeddingsQueryRequest, ) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>; } @@ -104,7 +103,7 @@ const makeQdrantGraphEmbeddingsQueryFromClient = ( client: QdrantClientLike, ): QdrantGraphEmbeddingsQueryServiceShape => { - const queryEffect = Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* ( + const queryImpl = Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* ( request: GraphEmbeddingsQueryRequest, ) { const { vector, user, collection, limit } = request; @@ -117,10 +116,9 @@ const makeQdrantGraphEmbeddingsQueryFromClient = ( const collectionName = `t_${user}_${collection}_${dim}`; // Check if collection exists -- return empty if not - const exists = yield* Effect.tryPromise({ - try: () => client.collectionExists(collectionName), - catch: (cause) => qdrantGraphEmbeddingsQueryError("collection-exists", cause), - }); + const exists = yield* client.collectionExists(collectionName).pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsQueryError("collection-exists", cause)), + ); if (!exists.exists) { yield* Effect.log( `[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`, @@ -130,15 +128,16 @@ const makeQdrantGraphEmbeddingsQueryFromClient = ( // Query 2x the limit so we have a better chance of getting `limit` // unique entities after deduplication (same heuristic as Python impl) - const searchResult = yield* Effect.tryPromise({ - try: () => - client.search(collectionName, { - vector, - limit: limit * 2, - with_payload: true, - }), - catch: (cause) => qdrantGraphEmbeddingsQueryError("search", cause), - }); + const searchResult = yield* client.search( + collectionName, + { + vector, + limit: limit * 2, + with_payload: true, + }, + ).pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsQueryError("search", cause)), + ); const entitySet = new Set<string>(); const entities: EntityMatch[] = []; @@ -168,7 +167,7 @@ const makeQdrantGraphEmbeddingsQueryFromClient = ( }); return { - query: queryEffect, + query: queryImpl, }; }; @@ -194,12 +193,8 @@ const withQdrantGraphEmbeddingsQuery = <A>( export function makeQdrantGraphEmbeddingsQuery( config: QdrantGraphQueryConfig = {}, ): QdrantGraphEmbeddingsQuery { - const queryEffect = (request: GraphEmbeddingsQueryRequest) => - withQdrantGraphEmbeddingsQuery(config, (query) => query.query(request)); - return { - query: (request) => Effect.runPromise(queryEffect(request)), - queryEffect, + query: (request) => withQdrantGraphEmbeddingsQuery(config, (query) => query.query(request)), }; } diff --git a/ts/packages/flow/src/query/triples/falkordb-service.ts b/ts/packages/flow/src/query/triples/falkordb-service.ts index 71fee7bd..3296e467 100644 --- a/ts/packages/flow/src/query/triples/falkordb-service.ts +++ b/ts/packages/flow/src/query/triples/falkordb-service.ts @@ -24,7 +24,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import { FalkorDBTriplesQueryLive, FalkorDBTriplesQueryService, @@ -98,12 +98,10 @@ const provideFalkorDBTriplesQuery = (processorId: string) => }); export function makeTriplesQueryService(config: ProcessorConfig): TriplesQueryService { - const service = makeFlowProcessor(config, { + return makeFlowProcessor(config, { specifications: makeTriplesQuerySpecs(), provide: provideFalkorDBTriplesQuery(config.id), }); - void Effect.runPromise(Effect.log("[TriplesQuery] Service initialized")); - return service; } export const TriplesQueryService = makeTriplesQueryService; @@ -118,12 +116,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => FalkorDBTriplesQueryLive(config), }); -const triplesQueryRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return triplesQueryRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/query/triples/falkordb.ts b/ts/packages/flow/src/query/triples/falkordb.ts index 6ad9ba1c..90b5804c 100644 --- a/ts/packages/flow/src/query/triples/falkordb.ts +++ b/ts/packages/flow/src/query/triples/falkordb.ts @@ -13,8 +13,8 @@ import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; export interface FalkorDBClosableClient { - readonly connect: () => Promise<unknown>; - readonly disconnect: () => Promise<unknown>; + readonly connect: Effect.Effect<void, FalkorDBTriplesQueryError>; + readonly disconnect: Effect.Effect<void, FalkorDBTriplesQueryError>; } export type FalkorDBQueryOptions = Parameters<Graph["query"]>[1]; @@ -23,7 +23,7 @@ export interface FalkorDBQueryGraph { readonly query: <T = unknown>( query: string, options?: FalkorDBQueryOptions, - ) => Promise<{ readonly data?: Array<T> }>; + ) => Effect.Effect<{ readonly data?: Array<T> }, FalkorDBTriplesQueryError>; } export type FalkorDBQueryClientFactory = (url: string) => FalkorDBClosableClient; @@ -73,7 +73,7 @@ export interface FalkorDBTriplesQuery { p?: Term, o?: Term, limit?: number, - ) => Promise<Triple[]>; + ) => Effect.Effect<ReadonlyArray<Triple>, FalkorDBTriplesQueryError>; } export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()( @@ -81,7 +81,7 @@ export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriple { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -113,6 +113,12 @@ interface FalkorDBQueryConnection { readonly graph: FalkorDBQueryGraph; } +const tryFalkorDBPromise = <A>(operation: string, try_: () => PromiseLike<A>) => + Effect.tryPromise({ + try: try_, + catch: (cause) => falkorDBTriplesQueryError(operation, cause), + }); + const resolveFalkorDBQueryConfig = Effect.fn("FalkorDBTriplesQuery.resolveConfig")(function* ( config: FalkorDBQueryConfig, ) { @@ -149,16 +155,21 @@ const connectFalkorDBTriplesQuery = Effect.fn("FalkorDBTriplesQuery.connect")(fu const client = clientFactory(url); return { client, graph: graphFactory(client, database) }; } - const client = createClient({ url }); - return { client, graph: new Graph(client, database) }; + const sdkClient = createClient({ url }); + const client: FalkorDBClosableClient = { + connect: tryFalkorDBPromise("connect", () => sdkClient.connect()).pipe(Effect.asVoid), + disconnect: tryFalkorDBPromise("disconnect", () => sdkClient.disconnect()).pipe(Effect.asVoid), + }; + const sdkGraph = new Graph(sdkClient, database); + const graph: FalkorDBQueryGraph = { + query: (query, options) => tryFalkorDBPromise("graph-query", () => sdkGraph.query(query, options)), + }; + return { client, graph }; }, catch: (cause) => falkorDBTriplesQueryError("create-client", cause), }); - yield* Effect.tryPromise({ - try: () => client.connect(), - catch: (cause) => falkorDBTriplesQueryError("connect", cause), - }).pipe( + yield* client.connect.pipe( Effect.tapError((error) => Effect.logError("[FalkorDBTriplesQuery] Connection failed", { error: error.message, @@ -174,10 +185,7 @@ const connectFalkorDBTriplesQuery = Effect.fn("FalkorDBTriplesQuery.connect")(fu const disconnectFalkorDBTriplesQuery = ( connection: FalkorDBQueryConnection, ): Effect.Effect<void> => - Effect.tryPromise({ - try: () => connection.client.disconnect(), - catch: (cause) => falkorDBTriplesQueryError("disconnect", cause), - }).pipe( + connection.client.disconnect.pipe( Effect.catch((error) => Effect.logError("[FalkorDBTriplesQuery] Disconnect failed", { error: error.message, @@ -201,10 +209,8 @@ const queryRows = ( query: string, options?: FalkorDBQueryOptions, ): Effect.Effect<ReadonlyArray<unknown>, FalkorDBTriplesQueryError> => - Effect.tryPromise({ - try: () => graph.query<unknown>(query, options), - catch: (cause) => falkorDBTriplesQueryError(operation, cause), - }).pipe( + graph.query<unknown>(query, options).pipe( + Effect.mapError((cause) => falkorDBTriplesQueryError(operation, cause)), Effect.map((result) => result.data ?? []), ); @@ -480,9 +486,7 @@ export function makeFalkorDBTriplesQuery( ): FalkorDBTriplesQuery { return { queryTriples: (s, p, o, limit = 100) => - Effect.runPromise( - withFalkorDBTriplesQuery(config, (query) => query.queryTriples(s, p, o, limit)), - ).then((triples) => Array.from(triples)), + withFalkorDBTriplesQuery(config, (query) => query.queryTriples(s, p, o, limit)), }; } diff --git a/ts/packages/flow/src/retrieval/document-rag-service.ts b/ts/packages/flow/src/retrieval/document-rag-service.ts index 973c76c1..87d7d26f 100644 --- a/ts/packages/flow/src/retrieval/document-rag-service.ts +++ b/ts/packages/flow/src/retrieval/document-rag-service.ts @@ -31,7 +31,7 @@ import { type TextCompletionRequest, type TextCompletionResponse, } from "@trustgraph/base"; -import {Effect, Layer, ManagedRuntime} from "effect"; +import {Effect} from "effect"; import { DocumentRagEngine, DocumentRagEngineError, @@ -139,12 +139,6 @@ export const program = makeFlowProcessorProgram({ layer: () => DocumentRagLive, }); -const documentRagRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return documentRagRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/retrieval/document-rag.ts b/ts/packages/flow/src/retrieval/document-rag.ts index 6e5b6be9..38386eaa 100644 --- a/ts/packages/flow/src/retrieval/document-rag.ts +++ b/ts/packages/flow/src/retrieval/document-rag.ts @@ -26,7 +26,10 @@ export interface DocumentRagClients { prompt: EffectRequestResponse<PromptRequest, PromptResponse>; } -export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>; +export type ChunkCallback = ( + text: string, + endOfStream: boolean, +) => Effect.Effect<void, DocumentRagEngineError>; export interface DocumentRagQueryOptions { readonly collection?: string; @@ -39,7 +42,7 @@ export class DocumentRagEngineError extends S.TaggedErrorClass<DocumentRagEngine { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -82,14 +85,13 @@ export interface DocumentRag { readonly query: ( queryText: string, options?: DocumentRagQueryOptions, - ) => Promise<string>; + ) => Effect.Effect<string, DocumentRagEngineError>; } export function makeDocumentRag(clients: DocumentRagClients): DocumentRag { const engine = makeDocumentRagEngine(); return { - query: (queryText, options) => - Effect.runPromise(engine.query(clients, queryText, options)), + query: (queryText, options) => engine.query(clients, queryText, options), }; } diff --git a/ts/packages/flow/src/retrieval/graph-rag-service.ts b/ts/packages/flow/src/retrieval/graph-rag-service.ts index c3d10158..81912fbd 100644 --- a/ts/packages/flow/src/retrieval/graph-rag-service.ts +++ b/ts/packages/flow/src/retrieval/graph-rag-service.ts @@ -33,7 +33,7 @@ import { type TriplesQueryRequest, type TriplesQueryResponse, } from "@trustgraph/base"; -import {Effect, Layer, ManagedRuntime} from "effect"; +import {Effect} from "effect"; import { GraphRagEngine, GraphRagEngineError, @@ -173,12 +173,6 @@ export const program = makeFlowProcessorProgram({ layer: () => GraphRagLive, }); -const graphRagRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return graphRagRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/retrieval/graph-rag.ts b/ts/packages/flow/src/retrieval/graph-rag.ts index 76a7838b..8471d952 100644 --- a/ts/packages/flow/src/retrieval/graph-rag.ts +++ b/ts/packages/flow/src/retrieval/graph-rag.ts @@ -42,7 +42,10 @@ export interface GraphRagClients { prompt: EffectRequestResponse<PromptRequest, PromptResponse>; } -export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>; +export type ChunkCallback = ( + text: string, + endOfStream: boolean, +) => Effect.Effect<void, GraphRagEngineError>; export interface GraphRagQueryOptions { readonly collection?: string; @@ -69,7 +72,7 @@ export class GraphRagEngineError extends S.TaggedErrorClass<GraphRagEngineError> { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -135,7 +138,7 @@ export interface GraphRag { readonly query: ( queryText: string, options?: GraphRagQueryOptions, - ) => Promise<GraphRagResult>; + ) => Effect.Effect<GraphRagResult, GraphRagEngineError>; } export function makeGraphRag( @@ -144,8 +147,7 @@ export function makeGraphRag( ): GraphRag { const engine = makeGraphRagEngine(); return { - query: (queryText, options) => - Effect.runPromise(engine.query(clients, queryText, options, config)), + query: (queryText, options) => engine.query(clients, queryText, options, config), }; } @@ -403,10 +405,9 @@ const synthesize = Effect.fn("GraphRagEngine.synthesize")(function* ( return Effect.succeed(resp.endOfStream === true); } fullText += resp.response; - return Effect.tryPromise({ - try: () => chunkCallback(resp.response, resp.endOfStream === true).then(() => resp.endOfStream === true), - catch: (cause) => graphRagError("synthesize-stream-callback", cause), - }); + return chunkCallback(resp.response, resp.endOfStream === true).pipe( + Effect.as(resp.endOfStream === true), + ); }, }, ); @@ -427,7 +428,7 @@ const synthesize = Effect.fn("GraphRagEngine.synthesize")(function* ( const ScoredEdge = S.Struct({ id: S.String, - score: S.Number, + score: S.Finite, }); const ScoredEdgesFromJson = S.Array(ScoredEdge).pipe(S.fromJsonString); const ScoredEdgeFromJson = ScoredEdge.pipe(S.fromJsonString); diff --git a/ts/packages/flow/src/runtime/effect-files.ts b/ts/packages/flow/src/runtime/effect-files.ts index cc7195a5..83816b86 100644 --- a/ts/packages/flow/src/runtime/effect-files.ts +++ b/ts/packages/flow/src/runtime/effect-files.ts @@ -1,10 +1,10 @@ +/** @effect-diagnostics strictEffectProvide:skip-file */ + import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"; -import { Effect, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import * as FileSystem from "effect/FileSystem"; import type { PlatformError } from "effect/PlatformError"; -const fileSystemRuntime = ManagedRuntime.make(BunFileSystem.layer); - export function joinPath(...segments: string[]): string { const joined = segments .filter((segment) => segment.length > 0) @@ -22,52 +22,33 @@ export function dirnamePath(path: string): string { return normalized.slice(0, index); } -export const ensureDirectoryEffect = (path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> => - Effect.flatMap(FileSystem.FileSystem, (fs) => +const withFileSystem = <A, E>( + effect: Effect.Effect<A, E, FileSystem.FileSystem>, +): Effect.Effect<A, E> => + effect.pipe(Effect.provide(BunFileSystem.layer)); + +export const ensureDirectoryEffect = (path: string): Effect.Effect<void, PlatformError> => + withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.makeDirectory(path, { recursive: true }) - ); + )); -export function ensureDirectory(path: string): Promise<void> { - return fileSystemRuntime.runPromise(ensureDirectoryEffect(path)); -} +export const readTextFileEffect = (path: string): Effect.Effect<string, PlatformError> => + withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(path))); -export const readTextFileEffect = (path: string): Effect.Effect<string, PlatformError, FileSystem.FileSystem> => - Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(path)); - -export function readTextFile(path: string): Promise<string> { - return fileSystemRuntime.runPromise(readTextFileEffect(path)); -} - -export const readBinaryFileEffect = (path: string): Effect.Effect<Uint8Array, PlatformError, FileSystem.FileSystem> => - Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFile(path)); - -export function readBinaryFile(path: string): Promise<Uint8Array> { - return fileSystemRuntime.runPromise(readBinaryFileEffect(path)); -} +export const readBinaryFileEffect = (path: string): Effect.Effect<Uint8Array, PlatformError> => + withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFile(path))); export const writeTextFileEffect = ( path: string, data: string, -): Effect.Effect<void, PlatformError, FileSystem.FileSystem> => - Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(path, data)); - -export function writeTextFile(path: string, data: string): Promise<void> { - return fileSystemRuntime.runPromise(writeTextFileEffect(path, data)); -} +): Effect.Effect<void, PlatformError> => + withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(path, data))); export const writeBinaryFileEffect = ( path: string, data: Uint8Array, -): Effect.Effect<void, PlatformError, FileSystem.FileSystem> => - Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFile(path, data)); +): Effect.Effect<void, PlatformError> => + withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFile(path, data))); -export function writeBinaryFile(path: string, data: Uint8Array): Promise<void> { - return fileSystemRuntime.runPromise(writeBinaryFileEffect(path, data)); -} - -export const removePathEffect = (path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> => - Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(path)); - -export function removePath(path: string): Promise<void> { - return fileSystemRuntime.runPromise(removePathEffect(path)); -} +export const removePathEffect = (path: string): Effect.Effect<void, PlatformError> => + withFileSystem(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(path))); 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 b5986099..a6143f41 100644 --- a/ts/packages/flow/src/storage/embeddings/graph-embeddings-service.ts +++ b/ts/packages/flow/src/storage/embeddings/graph-embeddings-service.ts @@ -29,7 +29,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import { QdrantGraphEmbeddingsStoreLive, QdrantGraphEmbeddingsStoreService, @@ -113,12 +113,10 @@ const provideQdrantGraphEmbeddingsStore = (processorId: string) => }); export function makeGraphEmbeddingsStoreService(config: ProcessorConfig): GraphEmbeddingsStoreService { - const service = makeFlowProcessor(config, { + return makeFlowProcessor(config, { specifications: makeGraphEmbeddingsStoreSpecs(), provide: provideQdrantGraphEmbeddingsStore(config.id), }); - void Effect.runPromise(Effect.log("[GraphEmbeddingsStore] Service initialized")); - return service; } export const GraphEmbeddingsStoreService = makeGraphEmbeddingsStoreService; @@ -133,12 +131,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => QdrantGraphEmbeddingsStoreLive(config), }); -const graphEmbeddingsStoreRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return graphEmbeddingsStoreRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts b/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts index 9861ad20..40c257c4 100644 --- a/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts +++ b/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts @@ -38,7 +38,7 @@ export class QdrantDocEmbeddingsStoreError extends S.TaggedErrorClass<QdrantDocE { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -85,12 +85,10 @@ const randomPointId = Effect.fn("QdrantDocEmbeddings.randomPointId")(function* ( }); export interface QdrantDocEmbeddingsStore { - readonly store: (message: DocEmbeddingsMessage) => Promise<void>; - readonly deleteCollection: (user: string, collection: string) => Promise<void>; - readonly storeEffect: ( + readonly store: ( message: DocEmbeddingsMessage, ) => Effect.Effect<void, QdrantDocEmbeddingsStoreError>; - readonly deleteCollectionEffect: ( + readonly deleteCollection: ( user: string, collection: string, ) => Effect.Effect<void, QdrantDocEmbeddingsStoreError>; @@ -133,25 +131,25 @@ const makeQdrantDocEmbeddingsStoreFromClient = ( ) { if (MutableHashSet.has(knownCollections, name)) return; - const exists = yield* Effect.tryPromise({ - try: () => client.collectionExists(name), - catch: (cause) => qdrantDocEmbeddingsStoreError("collection-exists", cause), - }); + const exists = yield* client.collectionExists(name).pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("collection-exists", cause)), + ); if (!exists.exists) { yield* Effect.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`); - yield* Effect.tryPromise({ - try: () => - client.createCollection(name, { - vectors: { size: dim, distance: "Cosine" }, - }), - catch: (cause) => qdrantDocEmbeddingsStoreError("create-collection", cause), - }); + yield* client.createCollection( + name, + { + vectors: { size: dim, distance: "Cosine" }, + }, + ).pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("create-collection", cause)), + ); } MutableHashSet.add(knownCollections, name); }); - const storeEffect = Effect.fn("QdrantDocEmbeddings.store")(function* (message: DocEmbeddingsMessage) { + const storeImpl = Effect.fn("QdrantDocEmbeddings.store")(function* (message: DocEmbeddingsMessage) { for (const chunk of message.chunks) { if (chunk.chunkId.length === 0) continue; if (chunk.vector.length === 0) continue; @@ -162,37 +160,37 @@ const makeQdrantDocEmbeddingsStoreFromClient = ( yield* ensureCollectionEffect(name, dim); const id = yield* randomPointId(); - yield* Effect.tryPromise({ - try: () => - client.upsert(name, { - points: [ - { - id, - vector: chunk.vector, - payload: { - chunk_id: chunk.chunkId, - ...(chunk.content !== undefined && chunk.content.length > 0 - ? { content: chunk.content } - : {}), - }, + yield* client.upsert( + name, + { + points: [ + { + id, + vector: chunk.vector, + payload: { + chunk_id: chunk.chunkId, + ...(chunk.content !== undefined && chunk.content.length > 0 + ? { content: chunk.content } + : {}), }, - ], - }), - catch: (cause) => qdrantDocEmbeddingsStoreError("upsert", cause), - }); + }, + ], + }, + ).pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("upsert", cause)), + ); } }); - const deleteCollectionEffect = Effect.fn("QdrantDocEmbeddings.deleteCollection")(function* ( + const deleteCollectionImpl = Effect.fn("QdrantDocEmbeddings.deleteCollection")(function* ( user: string, collection: string, ) { const prefix = `d_${user}_${collection}_`; - const allCollections = yield* Effect.tryPromise({ - try: () => client.getCollections(), - catch: (cause) => qdrantDocEmbeddingsStoreError("get-collections", cause), - }); + const allCollections = yield* client.getCollections.pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("get-collections", cause)), + ); const matching = allCollections.collections.filter((c) => c.name.startsWith(prefix), ); @@ -203,10 +201,9 @@ const makeQdrantDocEmbeddingsStoreFromClient = ( } for (const coll of matching) { - yield* Effect.tryPromise({ - try: () => client.deleteCollection(coll.name), - catch: (cause) => qdrantDocEmbeddingsStoreError("delete-collection", cause), - }); + yield* client.deleteCollection(coll.name).pipe( + Effect.mapError((cause) => qdrantDocEmbeddingsStoreError("delete-collection", cause)), + ); MutableHashSet.remove(knownCollections, coll.name); yield* Effect.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`); } @@ -217,8 +214,8 @@ const makeQdrantDocEmbeddingsStoreFromClient = ( }); return { - store: storeEffect, - deleteCollection: deleteCollectionEffect, + store: storeImpl, + deleteCollection: deleteCollectionImpl, }; }; @@ -244,16 +241,9 @@ const withQdrantDocEmbeddingsStore = <A>( export function makeQdrantDocEmbeddingsStore( config: QdrantDocEmbeddingsConfig = {}, ): QdrantDocEmbeddingsStore { - const storeEffect = (message: DocEmbeddingsMessage) => - withQdrantDocEmbeddingsStore(config, (store) => store.store(message)); - const deleteCollectionEffect = (user: string, collection: string) => - withQdrantDocEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)); - return { - store: (message) => Effect.runPromise(storeEffect(message)), + store: (message) => withQdrantDocEmbeddingsStore(config, (store) => store.store(message)), deleteCollection: (user, collection) => - Effect.runPromise(deleteCollectionEffect(user, collection)), - storeEffect, - deleteCollectionEffect, + withQdrantDocEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)), }; } diff --git a/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts b/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts index ad008ebf..af0c4dac 100644 --- a/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts +++ b/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts @@ -38,7 +38,7 @@ export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGr { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -96,12 +96,10 @@ function getTermValue(term: Term): string | null { } export interface QdrantGraphEmbeddingsStore { - readonly store: (message: GraphEmbeddingsMessage) => Promise<void>; - readonly deleteCollection: (user: string, collection: string) => Promise<void>; - readonly storeEffect: ( + readonly store: ( message: GraphEmbeddingsMessage, ) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>; - readonly deleteCollectionEffect: ( + readonly deleteCollection: ( user: string, collection: string, ) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>; @@ -134,25 +132,25 @@ const makeQdrantGraphEmbeddingsStoreFromClient = ( ) { if (MutableHashSet.has(knownCollections, name)) return; - const exists = yield* Effect.tryPromise({ - try: () => client.collectionExists(name), - catch: (cause) => qdrantGraphEmbeddingsStoreError("collection-exists", cause), - }); + const exists = yield* client.collectionExists(name).pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("collection-exists", cause)), + ); if (!exists.exists) { yield* Effect.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`); - yield* Effect.tryPromise({ - try: () => - client.createCollection(name, { - vectors: { size: dim, distance: "Cosine" }, - }), - catch: (cause) => qdrantGraphEmbeddingsStoreError("create-collection", cause), - }); + yield* client.createCollection( + name, + { + vectors: { size: dim, distance: "Cosine" }, + }, + ).pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("create-collection", cause)), + ); } MutableHashSet.add(knownCollections, name); }); - const storeEffect = Effect.fn("QdrantGraphEmbeddings.store")(function* (message: GraphEmbeddingsMessage) { + const storeImpl = Effect.fn("QdrantGraphEmbeddings.store")(function* (message: GraphEmbeddingsMessage) { for (const entry of message.entities) { const entityValue = getTermValue(entry.entity); if (entityValue === null || entityValue.length === 0) continue; @@ -169,32 +167,32 @@ const makeQdrantGraphEmbeddingsStoreFromClient = ( } const id = yield* randomPointId(); - yield* Effect.tryPromise({ - try: () => - client.upsert(name, { - points: [ - { - id, - vector: entry.vector, - payload, - }, - ], - }), - catch: (cause) => qdrantGraphEmbeddingsStoreError("upsert", cause), - }); + yield* client.upsert( + name, + { + points: [ + { + id, + vector: entry.vector, + payload, + }, + ], + }, + ).pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("upsert", cause)), + ); } }); - const deleteCollectionEffect = Effect.fn("QdrantGraphEmbeddings.deleteCollection")(function* ( + const deleteCollectionImpl = Effect.fn("QdrantGraphEmbeddings.deleteCollection")(function* ( user: string, collection: string, ) { const prefix = `t_${user}_${collection}_`; - const allCollections = yield* Effect.tryPromise({ - try: () => client.getCollections(), - catch: (cause) => qdrantGraphEmbeddingsStoreError("get-collections", cause), - }); + const allCollections = yield* client.getCollections.pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("get-collections", cause)), + ); const matching = allCollections.collections.filter((c) => c.name.startsWith(prefix), ); @@ -205,10 +203,9 @@ const makeQdrantGraphEmbeddingsStoreFromClient = ( } for (const coll of matching) { - yield* Effect.tryPromise({ - try: () => client.deleteCollection(coll.name), - catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause), - }); + yield* client.deleteCollection(coll.name).pipe( + Effect.mapError((cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause)), + ); MutableHashSet.remove(knownCollections, coll.name); yield* Effect.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`); } @@ -219,8 +216,8 @@ const makeQdrantGraphEmbeddingsStoreFromClient = ( }); return { - store: storeEffect, - deleteCollection: deleteCollectionEffect, + store: storeImpl, + deleteCollection: deleteCollectionImpl, }; }; @@ -246,17 +243,10 @@ const withQdrantGraphEmbeddingsStore = <A>( export function makeQdrantGraphEmbeddingsStore( config: QdrantGraphEmbeddingsConfig = {}, ): QdrantGraphEmbeddingsStore { - const storeEffect = (message: GraphEmbeddingsMessage) => - withQdrantGraphEmbeddingsStore(config, (store) => store.store(message)); - const deleteCollectionEffect = (user: string, collection: string) => - withQdrantGraphEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)); - return { - store: (message) => Effect.runPromise(storeEffect(message)), + store: (message) => withQdrantGraphEmbeddingsStore(config, (store) => store.store(message)), deleteCollection: (user, collection) => - Effect.runPromise(deleteCollectionEffect(user, collection)), - storeEffect, - deleteCollectionEffect, + withQdrantGraphEmbeddingsStore(config, (store) => store.deleteCollection(user, collection)), }; } diff --git a/ts/packages/flow/src/storage/triples/falkordb-service.ts b/ts/packages/flow/src/storage/triples/falkordb-service.ts index 974c5f14..cd783941 100644 --- a/ts/packages/flow/src/storage/triples/falkordb-service.ts +++ b/ts/packages/flow/src/storage/triples/falkordb-service.ts @@ -21,7 +21,7 @@ import { } from "@trustgraph/base"; import { NodeRuntime } from "@effect/platform-node"; import { makeFlowProcessorProgram } from "@trustgraph/base"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect } from "effect"; import { FalkorDBTriplesStoreLive, FalkorDBTriplesStoreService, @@ -73,12 +73,10 @@ const provideFalkorDBTriplesStore = (processorId: string) => }); export function makeTriplesStoreService(config: ProcessorConfig): TriplesStoreService { - const service = makeFlowProcessor(config, { + return makeFlowProcessor(config, { specifications: makeTriplesStoreSpecs(), provide: provideFalkorDBTriplesStore(config.id), }); - void Effect.runPromise(Effect.log("[TriplesStore] Service initialized")); - return service; } export const TriplesStoreService = makeTriplesStoreService; @@ -93,12 +91,6 @@ export const program = makeFlowProcessorProgram< layer: (config) => FalkorDBTriplesStoreLive(config), }); -const triplesStoreRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return triplesStoreRuntime.runPromise(program); -} - export function runMain(): void { NodeRuntime.runMain(program); } diff --git a/ts/packages/flow/src/storage/triples/falkordb.ts b/ts/packages/flow/src/storage/triples/falkordb.ts index 39cec4b1..138f995c 100644 --- a/ts/packages/flow/src/storage/triples/falkordb.ts +++ b/ts/packages/flow/src/storage/triples/falkordb.ts @@ -13,8 +13,8 @@ import { Config, Context, Effect, Layer, Match } from "effect"; import * as S from "effect/Schema"; export interface FalkorDBClosableClient { - readonly connect: () => Promise<unknown>; - readonly disconnect: () => Promise<unknown>; + readonly connect: Effect.Effect<void, FalkorDBTriplesStoreError>; + readonly disconnect: Effect.Effect<void, FalkorDBTriplesStoreError>; } export type FalkorDBStoreQueryOptions = Parameters<Graph["query"]>[1]; @@ -23,7 +23,7 @@ export interface FalkorDBStoreGraph { readonly query: <T = unknown>( query: string, options?: FalkorDBStoreQueryOptions, - ) => Promise<{ readonly data?: Array<T> }>; + ) => Effect.Effect<{ readonly data?: Array<T> }, FalkorDBTriplesStoreError>; } export type FalkorDBStoreClientFactory = (url: string) => FalkorDBClosableClient; @@ -51,28 +51,39 @@ function getTermValue(term: Term): string { } export interface FalkorDBTriplesStore { - readonly createNode: (uri: string, user: string, collection: string) => Promise<void>; - readonly createLiteral: (value: string, user: string, collection: string) => Promise<void>; + readonly createNode: ( + uri: string, + user: string, + collection: string, + ) => Effect.Effect<void, FalkorDBTriplesStoreError>; + readonly createLiteral: ( + value: string, + user: string, + collection: string, + ) => Effect.Effect<void, FalkorDBTriplesStoreError>; readonly relateNode: ( src: string, uri: string, dest: string, user: string, collection: string, - ) => Promise<void>; + ) => Effect.Effect<void, FalkorDBTriplesStoreError>; readonly relateLiteral: ( src: string, uri: string, dest: string, user: string, collection: string, - ) => Promise<void>; + ) => Effect.Effect<void, FalkorDBTriplesStoreError>; readonly storeTriples: ( triples: Triple[], user?: string, collection?: string, - ) => Promise<void>; - readonly deleteCollection: (user: string, collection: string) => Promise<void>; + ) => Effect.Effect<void, FalkorDBTriplesStoreError>; + readonly deleteCollection: ( + user: string, + collection: string, + ) => Effect.Effect<void, FalkorDBTriplesStoreError>; } export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()( @@ -80,7 +91,7 @@ export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriple { message: S.String, operation: S.String, - cause: S.DefectWithStack, + cause: S.Defect({ includeStack: true }), }, ) {} @@ -115,6 +126,12 @@ interface FalkorDBStoreConnection { readonly graph: FalkorDBStoreGraph; } +const tryFalkorDBPromise = <A>(operation: string, try_: () => PromiseLike<A>) => + Effect.tryPromise({ + try: try_, + catch: (cause) => falkorDBTriplesStoreError(operation, cause), + }); + interface FalkorDBTriplesStoreEffectShape { readonly createNode: ( uri: string, @@ -187,16 +204,21 @@ const connectFalkorDBTriplesStore = Effect.fn("FalkorDBTriplesStore.connect")(fu const client = clientFactory(url); return { client, graph: graphFactory(client, database) }; } - const client = createClient({ url }); - return { client, graph: new Graph(client, database) }; + const sdkClient = createClient({ url }); + const client: FalkorDBClosableClient = { + connect: tryFalkorDBPromise("connect", () => sdkClient.connect()).pipe(Effect.asVoid), + disconnect: tryFalkorDBPromise("disconnect", () => sdkClient.disconnect()).pipe(Effect.asVoid), + }; + const sdkGraph = new Graph(sdkClient, database); + const graph: FalkorDBStoreGraph = { + query: (query, options) => tryFalkorDBPromise("graph-query", () => sdkGraph.query(query, options)), + }; + return { client, graph }; }, catch: (cause) => falkorDBTriplesStoreError("create-client", cause), }); - yield* Effect.tryPromise({ - try: () => client.connect(), - catch: (cause) => falkorDBTriplesStoreError("connect", cause), - }).pipe( + yield* client.connect.pipe( Effect.tapError((error) => Effect.logError("[FalkorDBTriplesStore] Connection failed", { error: error.message, @@ -212,10 +234,7 @@ const connectFalkorDBTriplesStore = Effect.fn("FalkorDBTriplesStore.connect")(fu const disconnectFalkorDBTriplesStore = ( connection: FalkorDBStoreConnection, ): Effect.Effect<void> => - Effect.tryPromise({ - try: () => connection.client.disconnect(), - catch: (cause) => falkorDBTriplesStoreError("disconnect", cause), - }).pipe( + connection.client.disconnect.pipe( Effect.catch((error) => Effect.logError("[FalkorDBTriplesStore] Disconnect failed", { error: error.message, @@ -239,10 +258,8 @@ const runGraphQuery = ( query: string, options?: FalkorDBStoreQueryOptions, ): Effect.Effect<void, FalkorDBTriplesStoreError> => - Effect.tryPromise({ - try: () => graph.query(query, options), - catch: (cause) => falkorDBTriplesStoreError(operation, cause), - }).pipe( + graph.query(query, options).pipe( + Effect.mapError((cause) => falkorDBTriplesStoreError(operation, cause)), Effect.asVoid, ); @@ -390,17 +407,17 @@ const withFalkorDBTriplesStore = <A>( export function makeFalkorDBTriplesStore(config: FalkorDBConfig = {}): FalkorDBTriplesStore { return { createNode: (uri, user, collection) => - Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.createNode(uri, user, collection))), + withFalkorDBTriplesStore(config, (store) => store.createNode(uri, user, collection)), createLiteral: (value, user, collection) => - Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.createLiteral(value, user, collection))), + withFalkorDBTriplesStore(config, (store) => store.createLiteral(value, user, collection)), relateNode: (src, uri, dest, user, collection) => - Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.relateNode(src, uri, dest, user, collection))), + withFalkorDBTriplesStore(config, (store) => store.relateNode(src, uri, dest, user, collection)), relateLiteral: (src, uri, dest, user, collection) => - Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.relateLiteral(src, uri, dest, user, collection))), + withFalkorDBTriplesStore(config, (store) => store.relateLiteral(src, uri, dest, user, collection)), storeTriples: (triples, user = "default", collection = "default") => - Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.storeTriples(triples, user, collection))), + withFalkorDBTriplesStore(config, (store) => store.storeTriples(triples, user, collection)), deleteCollection: (user, collection) => - Effect.runPromise(withFalkorDBTriplesStore(config, (store) => store.deleteCollection(user, collection))), + withFalkorDBTriplesStore(config, (store) => store.deleteCollection(user, collection)), }; } diff --git a/ts/packages/mcp/package.json b/ts/packages/mcp/package.json index 405a9990..e503301b 100644 --- a/ts/packages/mcp/package.json +++ b/ts/packages/mcp/package.json @@ -13,23 +13,21 @@ "dependencies": { "@trustgraph/base": "workspace:*", "@trustgraph/client": "workspace:*", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", - "@modelcontextprotocol/sdk": "^1.8.0", - "zod": "^3.23.0" + "@effect/platform-node": "4.0.0-beta.78", + "@effect/platform-node-shared": "4.0.0-beta.78", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^4.1.6" diff --git a/ts/packages/mcp/src/__tests__/server-effect.test.ts b/ts/packages/mcp/src/__tests__/server-effect.test.ts index 56019d5f..eff27f07 100644 --- a/ts/packages/mcp/src/__tests__/server-effect.test.ts +++ b/ts/packages/mcp/src/__tests__/server-effect.test.ts @@ -1,22 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; -import * as Predicate from "effect/Predicate"; -import { createMcpServer } from "../server.js"; +import { describe, expect, it } from "@effect/vitest"; +import type { BaseApi } from "@trustgraph/client"; +import { Effect, Layer, Stream } from "effect"; +import * as S from "effect/Schema"; +import { LanguageModel, McpServer } from "effect/unstable/ai"; +import * as McpSchema from "effect/unstable/ai/McpSchema"; +import { FetchHttpClient, HttpRouter } from "effect/unstable/http"; +import { RpcSerialization } from "effect/unstable/rpc"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; import { makeTrustGraphMcpStdioLayer, runStdio, + TrustGraphMcpConfig, TrustGraphMcpToolkit, + TrustGraphMcpToolkitLive, + TrustGraphSocket, } from "../server-effect.js"; -const clientMock = vi.hoisted(() => ({ - createTrustGraphSocket: vi.fn(() => ({ - close: vi.fn(), - })), -})); - -vi.mock("@trustgraph/client", () => ({ - createTrustGraphSocket: clientMock.createTrustGraphSocket, -})); - const expectedToolNames = [ "text_completion", "graph_rag", @@ -43,41 +42,290 @@ const expectedToolNames = [ "load_kg_core", ]; -const registeredToolNames = (value: unknown): Array<string> => { - if (!Predicate.isObject(value) || !Predicate.hasProperty(value, "_registeredTools")) { - return []; - } - return Predicate.isObject(value._registeredTools) - ? Object.keys(value._registeredTools) - : []; +interface FakeSocketCalls { + readonly flowIds: Array<string>; + readonly graphRag: Array<{ + readonly query: string; + readonly options: unknown; + readonly collection: string | undefined; + }>; +} + +interface NativeTestClientOptions { + readonly languageText?: string | undefined; + readonly graphRag?: (() => Promise<string>) | undefined; +} + +const decodeJsonText = S.decodeUnknownSync(S.UnknownFromJsonString); + +const makeFakeSocket = ( + options: { + readonly graphRag?: (() => Promise<string>) | undefined; + } = {}, +) => { + const calls: FakeSocketCalls = { + flowIds: [], + graphRag: [], + }; + + const socket = { + close: () => {}, + flow: (flowId: string) => { + calls.flowIds.push(flowId); + return { + textCompletion: () => Promise.resolve("legacy text completion should not be used"), + graphRag: (query: string, ragOptions: unknown, collection?: string) => { + calls.graphRag.push({ query, options: ragOptions, collection }); + return options.graphRag === undefined + ? Promise.resolve("graph rag answer") + : options.graphRag(); + }, + documentRag: () => Promise.resolve("document rag answer"), + agent: ( + _question: string, + _onThought: () => void, + _onObservation: () => void, + onAnswer: (chunk: string, complete: boolean) => void, + ) => onAnswer("agent answer", true), + embeddings: () => Promise.resolve([[0.25, 0.75]]), + triplesQuery: () => Promise.resolve([]), + graphEmbeddingsQuery: () => Promise.resolve([]), + }; + }, + config: () => ({ + getConfigAll: () => Promise.resolve({}), + getConfig: () => Promise.resolve({}), + putConfig: () => Promise.resolve({ ok: true }), + deleteConfig: () => Promise.resolve({ ok: true }), + getPrompts: () => Promise.resolve([]), + getPrompt: () => Promise.resolve({}), + }), + flows: () => ({ + getFlows: () => Promise.resolve(["default"]), + getFlow: () => Promise.resolve({}), + startFlow: () => Promise.resolve({ ok: true }), + stopFlow: () => Promise.resolve({ ok: true }), + }), + librarian: () => ({ + getDocuments: () => Promise.resolve([]), + loadDocument: () => Promise.resolve({ ok: true }), + removeDocument: () => Promise.resolve({ ok: true }), + }), + knowledge: () => ({ + getKnowledgeCores: () => Promise.resolve([]), + deleteKgCore: () => Promise.resolve({ ok: true }), + loadKgCore: () => Promise.resolve({ ok: true }), + }), + } as unknown as BaseApi; + + return { socket, calls }; +}; + +const makeLanguageModelLayer = (text: string) => + Layer.effect( + LanguageModel.LanguageModel, + LanguageModel.make({ + generateText: () => Effect.succeed([{ type: "text", text }]), + streamText: () => Stream.empty, + }), + ); + +const testConfig = TrustGraphMcpConfig.of({ + gatewayUrl: "ws://localhost:8088/api/v1/rpc", + user: "mcp-test", + token: undefined, + flowId: "default", + name: "trustgraph", + version: "0.1.0-test", + mcpPath: "/mcp", + openAiModel: "test-model", + openAiApiKey: undefined, + port: 3000, +}); + +const makeNativeTestClient = ( + options: NativeTestClientOptions = {}, +) => + makeNativeTestClientEffect(options); + +const makeNativeTestClientEffect = Effect.fn("makeNativeTestClient")(function*( + options: NativeTestClientOptions, +) { + const { socket, calls } = makeFakeSocket({ graphRag: options.graphRag }); + const serverLayer = McpServer.toolkit(TrustGraphMcpToolkit).pipe( + Layer.provide(TrustGraphMcpToolkitLive), + Layer.provide(makeLanguageModelLayer(options.languageText ?? "direct ai answer")), + Layer.provide(Layer.succeed(TrustGraphSocket, TrustGraphSocket.of(socket))), + Layer.provide(Layer.succeed(TrustGraphMcpConfig, testConfig)), + Layer.provide(McpServer.layerHttp({ + name: "trustgraph", + version: "0.1.0-test", + path: "/mcp", + })), + ); + + const { handler, dispose } = HttpRouter.toWebHandler(serverLayer, { disableLogger: true }); + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())); + + let sessionId: string | null = null; + const customFetch = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + if (sessionId !== null) { + request.headers.set("Mcp-Session-Id", sessionId); + } + return handler(request).then((response) => { + sessionId = response.headers.get("Mcp-Session-Id"); + return response; + }); + }, + { preconnect: fetch.preconnect }, + ) as typeof fetch; + + const clientLayer = RpcClient.layerProtocolHttp({ url: "http://localhost/mcp" }).pipe( + Layer.provideMerge([FetchHttpClient.layer, RpcSerialization.layerJsonRpc()]), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, customFetch)), + ); + const client = yield* RpcClient.make(McpSchema.ClientRpcs).pipe( + // @effect-diagnostics-next-line strictEffectProvide:off + Effect.provide(clientLayer), + ); + + yield* client.initialize({ + protocolVersion: "9999-01-01", + capabilities: {}, + clientInfo: { + name: "trustgraph-mcp-test-client", + version: "0.1.0-test", + }, + }); + + return { client, calls }; + }); + +const textContent = (result: McpSchema.CallToolResult): string => { + const [content] = result.content; + expect(content?.type).toBe("text"); + return "text" in content! ? content.text : ""; }; describe("Effect MCP server", () => { - it("keeps the canonical Effect toolkit names stable", () => { - expect(Object.keys(TrustGraphMcpToolkit.tools)).toEqual(expectedToolNames); - }); + it.effect( + "keeps the canonical Effect toolkit names stable", + Effect.fnUntraced(function*() { + expect(Object.keys(TrustGraphMcpToolkit.tools)).toEqual(expectedToolNames); + }), + ); - it("keeps legacy SDK stdio tools aligned with the Effect toolkit", () => { - const { server, socket } = createMcpServer({ - gatewayUrl: "ws://localhost:8088/api/v1/rpc", - user: "mcp-test", - flowId: "default", - }); + it.effect( + "exposes an Effect stdio layer and process entrypoint", + Effect.fnUntraced(function*() { + expect( + makeTrustGraphMcpStdioLayer({ + gatewayUrl: "ws://localhost:8088/api/v1/rpc", + user: "mcp-test", + flowId: "default", + openAiApiKey: "test-key", + }), + ).toBeDefined(); - expect(registeredToolNames(server)).toEqual(Object.keys(TrustGraphMcpToolkit.tools)); - expect(socket.close).toEqual(expect.any(Function)); - }); + expect(runStdio).toEqual(expect.any(Function)); + }), + ); - it("exposes an Effect stdio layer and process entrypoint", () => { - expect( - makeTrustGraphMcpStdioLayer({ - gatewayUrl: "ws://localhost:8088/api/v1/rpc", - user: "mcp-test", - flowId: "default", - openAiApiKey: "test-key", - }), - ).toBeDefined(); + it.effect( + "lists native MCP tools through the protocol bridge", + Effect.fnUntraced(function*() { + yield* Effect.scoped(Effect.gen(function*() { + const { client } = yield* makeNativeTestClient(); - expect(runStdio).toEqual(expect.any(Function)); - }); + const result = yield* client["tools/list"]({}); + expect(result.tools.map((tool) => tool.name)).toEqual(expectedToolNames); + expect(result.tools.find((tool) => tool.name === "graph_rag")?.annotations).toMatchObject({ + title: "Graph RAG", + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }); + })); + }), + ); + + it.effect( + "calls text_completion through the direct Effect language model", + Effect.fnUntraced(function*() { + yield* Effect.scoped(Effect.gen(function*() { + const { client, calls } = yield* makeNativeTestClient({ + languageText: "direct model response", + }); + + const result = yield* client["tools/call"]({ + name: "text_completion", + arguments: { + system: "You are concise.", + prompt: "Say hello.", + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toEqual({ text: "direct model response" }); + expect(decodeJsonText(textContent(result))).toEqual({ text: "direct model response" }); + expect(calls.flowIds).toEqual([]); + })); + }), + ); + + it.effect( + "calls gateway-backed tools through the native MCP bridge", + Effect.fnUntraced(function*() { + yield* Effect.scoped(Effect.gen(function*() { + const { client, calls } = yield* makeNativeTestClient(); + + const result = yield* client["tools/call"]({ + name: "graph_rag", + arguments: { + query: "Who knows Alice?", + entity_limit: 4, + triple_limit: 8, + collection: "qa", + }, + }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toEqual({ text: "graph rag answer" }); + expect(calls.graphRag).toEqual([ + { + query: "Who knows Alice?", + options: { entityLimit: 4, tripleLimit: 8 }, + collection: "qa", + }, + ]); + })); + }), + ); + + it.effect( + "returns JSON-safe structured failures for expected tool errors", + Effect.fnUntraced(function*() { + yield* Effect.scoped(Effect.gen(function*() { + const { client } = yield* makeNativeTestClient({ + graphRag: () => Promise.reject(new Error("gateway unavailable")), + }); + + const result = yield* client["tools/call"]({ + name: "graph_rag", + arguments: { + query: "Will this fail?", + }, + }); + + expect(result.structuredContent).toEqual({ + _tag: "GraphRagError", + message: "gateway unavailable", + }); + expect(result.structuredContent).not.toHaveProperty("cause"); + expect(decodeJsonText(textContent(result))).toEqual(result.structuredContent); + })); + }), + ); }); diff --git a/ts/packages/mcp/src/index.ts b/ts/packages/mcp/src/index.ts index c62b5840..0f9c38c9 100644 --- a/ts/packages/mcp/src/index.ts +++ b/ts/packages/mcp/src/index.ts @@ -1,2 +1,2 @@ -export { createMcpServer, run } from "./server.js"; +export { runStdio as run } from "./server-effect.js"; export * from "./server-effect.js"; diff --git a/ts/packages/mcp/src/server-effect.ts b/ts/packages/mcp/src/server-effect.ts index fe455491..51e4d25c 100644 --- a/ts/packages/mcp/src/server-effect.ts +++ b/ts/packages/mcp/src/server-effect.ts @@ -113,10 +113,6 @@ const TrustGraphJsonPayload = S.Json.annotateKey({ description: "JSON-safe payload returned by the TrustGraph gateway", }) -const ToolErrorCause = S.DefectWithStack.annotateKey({ - description: "Original exception, schema decoding failure, or gateway error that caused the tool call to fail", -}) - const ToolErrorMessage = S.String.annotateKey({ description: "Concise human-readable error message suitable for explaining the failure to a user", }) @@ -141,7 +137,6 @@ export class TextCompletionSuccess extends S.Class<TextCompletionSuccess>("TextC export class TextCompletionError extends S.TaggedErrorClass<TextCompletionError>()( "TextCompletionError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -165,6 +160,7 @@ export const TextCompletionTool = annotateTool( parameters: TextCompletionParameters, success: TextCompletionSuccess, failure: TextCompletionError, + failureMode: "return", }), { title: "Text Completion", @@ -185,7 +181,6 @@ export class GraphRagSuccess extends S.Class<GraphRagSuccess>("GraphRagSuccess") export class GraphRagError extends S.TaggedErrorClass<GraphRagError>()( "GraphRagError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -215,6 +210,7 @@ export const GraphRagTool = annotateTool( parameters: GraphRagParameters, success: GraphRagSuccess, failure: GraphRagError, + failureMode: "return", }), { title: "Graph RAG", @@ -235,7 +231,6 @@ export class DocumentRagSuccess extends S.Class<DocumentRagSuccess>("DocumentRag export class DocumentRagError extends S.TaggedErrorClass<DocumentRagError>()( "DocumentRagError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -263,6 +258,7 @@ export const DocumentRagTool = annotateTool( parameters: DocumentRagParameters, success: DocumentRagSuccess, failure: DocumentRagError, + failureMode: "return", }), { title: "Document RAG", @@ -283,7 +279,6 @@ export class AgentSuccess extends S.Class<AgentSuccess>("AgentSuccess")( export class AgentError extends S.TaggedErrorClass<AgentError>()( "AgentError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -303,6 +298,7 @@ export const AgentTool = annotateTool( parameters: AgentParameters, success: AgentSuccess, failure: AgentError, + failureMode: "return", description: "Ask the TrustGraph agent a question" }), { @@ -326,7 +322,6 @@ export class EmbeddingsSuccess extends S.Class<EmbeddingsSuccess>("EmbeddingsSuc export class EmbeddingsError extends S.TaggedErrorClass<EmbeddingsError>()( "EmbeddingsError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -346,6 +341,7 @@ export const EmbeddingsTool = annotateTool( parameters: EmbeddingsParameters, success: EmbeddingsSuccess, failure: EmbeddingsError, + failureMode: "return", description: "Generate text embeddings" }), { @@ -369,7 +365,6 @@ export class TriplesQuerySuccess extends S.Class<TriplesQuerySuccess>("TriplesQu export class TriplesQueryError extends S.TaggedErrorClass<TriplesQueryError>()( "TriplesQueryError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -402,6 +397,7 @@ export const TriplesQueryTool = annotateTool( parameters: TriplesQueryParameters, success: TriplesQuerySuccess, failure: TriplesQueryError, + failureMode: "return", description: "Query the knowledge graph for triples matching a pattern" }), { @@ -434,7 +430,6 @@ export class GraphEmbeddingsQuerySuccess extends S.Class<GraphEmbeddingsQuerySuc export class GraphEmbeddingsQueryError extends S.TaggedErrorClass<GraphEmbeddingsQueryError>()( "GraphEmbeddingsQueryError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -461,6 +456,7 @@ export const GraphEmbeddingsQueryTool = annotateTool( parameters: GraphEmbeddingsQueryParameters, success: GraphEmbeddingsQuerySuccess, failure: GraphEmbeddingsQueryError, + failureMode: "return", description: "Find entities similar to a text query using vector embeddings" }), { @@ -482,7 +478,6 @@ export class GetConfigAllSuccess extends S.Class<GetConfigAllSuccess>("GetConfig export class GetConfigAllError extends S.TaggedErrorClass<GetConfigAllError>()( "GetConfigAllError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -498,6 +493,7 @@ export const GetConfigAllTool = annotateTool( parameters: GetConfigAllParameters, success: GetConfigAllSuccess, failure: GetConfigAllError, + failureMode: "return", description: "Get all configuration values" }), { @@ -520,7 +516,6 @@ export class GetConfigSuccess extends S.Class<GetConfigSuccess>("GetConfigSucces export class GetConfigError extends S.TaggedErrorClass<GetConfigError>()( "GetConfigError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -549,6 +544,7 @@ export const GetConfigTool = annotateTool( parameters: GetConfigParameters, success: GetConfigSuccess, failure: GetConfigError, + failureMode: "return", description: "Get specific configuration values" }), { @@ -570,7 +566,6 @@ export class PutConfigSuccess extends S.Class<PutConfigSuccess>("PutConfigSucces export class PutConfigError extends S.TaggedErrorClass<PutConfigError>()( "PutConfigError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -602,6 +597,7 @@ export const PutConfigTool = annotateTool( parameters: PutConfigParameters, success: PutConfigSuccess, failure: PutConfigError, + failureMode: "return", description: "Set configuration values" }), { @@ -623,7 +619,6 @@ export class DeleteConfigSuccess extends S.Class<DeleteConfigSuccess>("DeleteCon export class DeleteConfigError extends S.TaggedErrorClass<DeleteConfigError>()( "DeleteConfigError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -646,6 +641,7 @@ export const DeleteConfigTool = annotateTool( parameters: DeleteConfigParameters, success: DeleteConfigSuccess, failure: DeleteConfigError, + failureMode: "return", description: "Delete a configuration entry" }), { @@ -667,7 +663,6 @@ export class GetFlowSuccess extends S.Class<GetFlowSuccess>("GetFlowSuccess")( export class GetFlowError extends S.TaggedErrorClass<GetFlowError>()( "GetFlowError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -687,6 +682,7 @@ export const GetFlowTool = annotateTool( parameters: GetFlowParameters, success: GetFlowSuccess, failure: GetFlowError, + failureMode: "return", description: "Get a specific flow definition" }), { @@ -710,7 +706,6 @@ export class GetFlowsSuccess extends S.Class<GetFlowsSuccess>("GetFlowsSuccess") export class GetFlowsError extends S.TaggedErrorClass<GetFlowsError>()( "GetFlowsError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -726,6 +721,7 @@ export const GetFlowsTool = annotateTool( parameters: GetFlowsParameters, success: GetFlowsSuccess, failure: GetFlowsError, + failureMode: "return", description: "List all available flows" }), { @@ -747,7 +743,6 @@ export class StartFlowSuccess extends S.Class<StartFlowSuccess>("StartFlowSucces export class StartFlowError extends S.TaggedErrorClass<StartFlowError>()( "StartFlowError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -776,6 +771,7 @@ export const StartFlowTool = annotateTool( parameters: StartFlowParameters, success: StartFlowSuccess, failure: StartFlowError, + failureMode: "return", description: "Start a flow instance" }), { @@ -797,7 +793,6 @@ export class StopFlowSuccess extends S.Class<StopFlowSuccess>("StopFlowSuccess") export class StopFlowError extends S.TaggedErrorClass<StopFlowError>()( "StopFlowError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -817,6 +812,7 @@ export const StopFlowTool = annotateTool( parameters: StopFlowParameters, success: StopFlowSuccess, failure: StopFlowError, + failureMode: "return", description: "Stop a running flow" }), { @@ -840,7 +836,6 @@ export class GetDocumentsSuccess extends S.Class<GetDocumentsSuccess>("GetDocume export class GetDocumentsError extends S.TaggedErrorClass<GetDocumentsError>()( "GetDocumentsError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -856,6 +851,7 @@ export const GetDocumentsTool = annotateTool( parameters: GetDocumentsParameters, success: GetDocumentsSuccess, failure: GetDocumentsError, + failureMode: "return", description: "List all documents in the library" }), { @@ -877,7 +873,6 @@ export class LoadDocumentSuccess extends S.Class<LoadDocumentSuccess>("LoadDocum export class LoadDocumentError extends S.TaggedErrorClass<LoadDocumentError>()( "LoadDocumentError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -912,6 +907,7 @@ export const LoadDocumentTool = annotateTool( parameters: LoadDocumentParameters, success: LoadDocumentSuccess, failure: LoadDocumentError, + failureMode: "return", description: "Upload a document to the library" }), { @@ -933,7 +929,6 @@ export class RemoveDocumentSuccess extends S.Class<RemoveDocumentSuccess>("Remov export class RemoveDocumentError extends S.TaggedErrorClass<RemoveDocumentError>()( "RemoveDocumentError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -956,6 +951,7 @@ export const RemoveDocumentTool = annotateTool( parameters: RemoveDocumentParameters, success: RemoveDocumentSuccess, failure: RemoveDocumentError, + failureMode: "return", description: "Remove a document from the library" }), { @@ -979,7 +975,6 @@ export class GetPromptsSuccess extends S.Class<GetPromptsSuccess>("GetPromptsSuc export class GetPromptsError extends S.TaggedErrorClass<GetPromptsError>()( "GetPromptsError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -995,6 +990,7 @@ export const GetPromptsTool = annotateTool( parameters: GetPromptsParameters, success: GetPromptsSuccess, failure: GetPromptsError, + failureMode: "return", description: "List available prompt templates" }), { @@ -1016,7 +1012,6 @@ export class GetPromptSuccess extends S.Class<GetPromptSuccess>("GetPromptSucces export class GetPromptError extends S.TaggedErrorClass<GetPromptError>()( "GetPromptError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -1036,6 +1031,7 @@ export const GetPromptTool = annotateTool( parameters: GetPromptParameters, success: GetPromptSuccess, failure: GetPromptError, + failureMode: "return", description: "Get a specific prompt template" }), { @@ -1059,7 +1055,6 @@ export class GetKnowledgeCoresSuccess extends S.Class<GetKnowledgeCoresSuccess>( export class GetKnowledgeCoresError extends S.TaggedErrorClass<GetKnowledgeCoresError>()( "GetKnowledgeCoresError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -1075,6 +1070,7 @@ export const GetKnowledgeCoresTool = annotateTool( parameters: GetKnowledgeCoresParameters, success: GetKnowledgeCoresSuccess, failure: GetKnowledgeCoresError, + failureMode: "return", description: "List available knowledge graph cores" }), { @@ -1096,7 +1092,6 @@ export class DeleteKgCoreSuccess extends S.Class<DeleteKgCoreSuccess>("DeleteKgC export class DeleteKgCoreError extends S.TaggedErrorClass<DeleteKgCoreError>()( "DeleteKgCoreError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -1119,6 +1114,7 @@ export const DeleteKgCoreTool = annotateTool( parameters: DeleteKgCoreParameters, success: DeleteKgCoreSuccess, failure: DeleteKgCoreError, + failureMode: "return", description: "Delete a knowledge graph core" }), { @@ -1140,7 +1136,6 @@ export class LoadKgCoreSuccess extends S.Class<LoadKgCoreSuccess>("LoadKgCoreSuc export class LoadKgCoreError extends S.TaggedErrorClass<LoadKgCoreError>()( "LoadKgCoreError", { - cause: ToolErrorCause, message: ToolErrorMessage, } ) { @@ -1166,6 +1161,7 @@ export const LoadKgCoreTool = annotateTool( parameters: LoadKgCoreParameters, success: LoadKgCoreSuccess, failure: LoadKgCoreError, + failureMode: "return", description: "Load a knowledge graph core" }), { @@ -1384,7 +1380,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( }, collection, ), - catch: (cause) => GraphRagError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GraphRagError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((text) => GraphRagSuccess.make({text})), ), @@ -1392,7 +1388,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( document_rag: ({query, doc_limit, collection}) => Effect.tryPromise({ try: () => socket.flow(config.flowId).documentRag(query, doc_limit, collection), - catch: (cause) => DocumentRagError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => DocumentRagError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((text) => DocumentRagSuccess.make({text})), ), @@ -1410,14 +1406,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( resume(Effect.succeed(AgentSuccess.make({text: fullAnswer}))) } }, - (cause) => resume(Effect.fail(AgentError.make({cause, message: toErrorMessage(cause)}))), + (cause) => resume(Effect.fail(AgentError.make({message: toErrorMessage(cause)}))), ) }), embeddings: ({text}) => Effect.tryPromise({ try: () => socket.flow(config.flowId).embeddings([...text]), - catch: (cause) => EmbeddingsError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => EmbeddingsError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((vectors) => EmbeddingsSuccess.make({vectors})), ), @@ -1432,7 +1428,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( limit, collection, ), - catch: (cause) => TriplesQueryError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => TriplesQueryError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((triples) => TriplesQuerySuccess.make({triples})), ), @@ -1440,7 +1436,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( graph_embeddings_query: ({query, limit, collection}) => Effect.tryPromise({ try: () => socket.flow(config.flowId).embeddings([query]), - catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GraphEmbeddingsQueryError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((vectors) => Effect.tryPromise({ @@ -1449,7 +1445,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( limit ?? 10, collection, ), - catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GraphEmbeddingsQueryError.make({message: toErrorMessage(cause)}), }) ), Effect.map((entities) => GraphEmbeddingsQuerySuccess.make({entities})), @@ -1458,12 +1454,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_config_all: () => Effect.tryPromise({ try: () => socket.config().getConfigAll(), - catch: (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetConfigAllError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}), + (cause) => GetConfigAllError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((config) => GetConfigAllSuccess.make({config})), ) @@ -1473,12 +1469,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_config: ({keys}) => Effect.tryPromise({ try: () => socket.config().getConfig(keys.map(({type, key}) => ({type, key}))), - catch: (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetConfigError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}), + (cause) => GetConfigError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((config) => GetConfigSuccess.make({config})), ) @@ -1488,12 +1484,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( put_config: ({values}) => Effect.tryPromise({ try: () => socket.config().putConfig(values.map(({type, key, value}) => ({type, key, value}))), - catch: (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => PutConfigError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}), + (cause) => PutConfigError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => PutConfigSuccess.make({response})), ) @@ -1503,12 +1499,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( delete_config: ({type, key}) => Effect.tryPromise({ try: () => socket.config().deleteConfig({type, key}), - catch: (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => DeleteConfigError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}), + (cause) => DeleteConfigError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => DeleteConfigSuccess.make({response})), ) @@ -1518,7 +1514,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_flows: () => Effect.tryPromise({ try: () => socket.flows().getFlows(), - catch: (cause) => GetFlowsError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetFlowsError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((flow_ids) => GetFlowsSuccess.make({flow_ids})), ), @@ -1526,12 +1522,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_flow: ({flow_id}) => Effect.tryPromise({ try: () => socket.flows().getFlow(flow_id), - catch: (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetFlowError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}), + (cause) => GetFlowError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((flow) => GetFlowSuccess.make({flow})), ) @@ -1547,12 +1543,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( description, parameters === undefined ? undefined : {...parameters}, ), - catch: (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => StartFlowError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}), + (cause) => StartFlowError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => StartFlowSuccess.make({response})), ) @@ -1562,12 +1558,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( stop_flow: ({flow_id}) => Effect.tryPromise({ try: () => socket.flows().stopFlow(flow_id), - catch: (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => StopFlowError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}), + (cause) => StopFlowError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => StopFlowSuccess.make({response})), ) @@ -1577,12 +1573,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_documents: () => Effect.tryPromise({ try: () => socket.librarian().getDocuments(), - catch: (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetDocumentsError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonArrayOrFail( value, - (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}), + (cause) => GetDocumentsError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((documents) => GetDocumentsSuccess.make({documents})), ) @@ -1600,12 +1596,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( tags === undefined ? [] : [...tags], id, ), - catch: (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => LoadDocumentError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}), + (cause) => LoadDocumentError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => LoadDocumentSuccess.make({response})), ) @@ -1615,12 +1611,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( remove_document: ({id, collection}) => Effect.tryPromise({ try: () => socket.librarian().removeDocument(id, collection), - catch: (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => RemoveDocumentError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}), + (cause) => RemoveDocumentError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => RemoveDocumentSuccess.make({response})), ) @@ -1630,7 +1626,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_prompts: () => Effect.tryPromise({ try: () => socket.config().getPrompts(), - catch: (cause) => GetPromptsError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetPromptsError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((prompts) => GetPromptsSuccess.make({prompts})), ), @@ -1638,12 +1634,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_prompt: ({id}) => Effect.tryPromise({ try: () => socket.config().getPrompt(id), - catch: (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetPromptError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}), + (cause) => GetPromptError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((prompt) => GetPromptSuccess.make({prompt})), ) @@ -1653,7 +1649,7 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_knowledge_cores: () => Effect.tryPromise({ try: () => socket.knowledge().getKnowledgeCores(), - catch: (cause) => GetKnowledgeCoresError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetKnowledgeCoresError.make({message: toErrorMessage(cause)}), }).pipe( Effect.map((ids) => GetKnowledgeCoresSuccess.make({ids})), ), @@ -1661,12 +1657,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( delete_kg_core: ({id, collection}) => Effect.tryPromise({ try: () => socket.knowledge().deleteKgCore(id, collection), - catch: (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => DeleteKgCoreError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}), + (cause) => DeleteKgCoreError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => DeleteKgCoreSuccess.make({response})), ) @@ -1676,12 +1672,12 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( load_kg_core: ({id, flow, collection}) => Effect.tryPromise({ try: () => socket.knowledge().loadKgCore(id, flow, collection), - catch: (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}), + catch: (cause) => LoadKgCoreError.make({message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}), + (cause) => LoadKgCoreError.make({message: toErrorMessage(cause)}), ).pipe( Effect.map((response) => LoadKgCoreSuccess.make({response})), ) diff --git a/ts/packages/mcp/src/server.ts b/ts/packages/mcp/src/server.ts deleted file mode 100644 index 5d2888c6..00000000 --- a/ts/packages/mcp/src/server.ts +++ /dev/null @@ -1,442 +0,0 @@ -/** - * TrustGraph MCP stdio compatibility server. - * - * This keeps the original @modelcontextprotocol/sdk entry points available, - * while moving gateway calls, callback bridging, JSON encoding, and config - * reads behind Effect values. - */ - -import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; -import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js"; -import {NodeRuntime} from "@effect/platform-node"; -import {createTrustGraphSocket, type BaseApi, type Term} from "@trustgraph/client"; -import {Effect, Layer, ManagedRuntime} from "effect"; -import * as Predicate from "effect/Predicate"; -import * as S from "effect/Schema"; -import * as z from "zod"; -import {loadTrustGraphMcpConfig} from "./server-effect.js"; - -interface ToolTextContent { - readonly type: "text" - readonly text: string -} - -interface ToolTextResult extends Record<string, unknown> { - readonly content: Array<ToolTextContent> -} - -class StdioMcpError extends S.TaggedErrorClass<StdioMcpError>()( - "StdioMcpError", - { - cause: S.DefectWithStack, - message: S.String, - }, -) { -} - -const encodeJsonText = S.encodeUnknownEffect(S.UnknownFromJsonString); - -const toErrorMessage = (cause: unknown): string => { - if (Predicate.isError(cause) && cause.message.length > 0) { - return cause.message; - } - if (Predicate.isString(cause) && cause.length > 0) { - return cause; - } - if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) { - return cause.message; - } - return "TrustGraph MCP stdio operation failed"; -}; - -const stdioMcpError = (cause: unknown) => - StdioMcpError.make({cause, message: toErrorMessage(cause)}); - -const textResult = (text: string): ToolTextResult => ({ - content: [{type: "text", text}], -}); - -const gatewayRequest = <A>(request: () => Promise<A>) => - Effect.tryPromise({ - try: request, - catch: stdioMcpError, - }); - -const jsonText = (value: unknown) => - encodeJsonText(value).pipe( - Effect.mapError(stdioMcpError), - ); - -const runTextTool = (effect: Effect.Effect<string, StdioMcpError>) => - Effect.runPromise(effect.pipe(Effect.map(textResult))); - -const runJsonTool = (effect: Effect.Effect<unknown, StdioMcpError>) => - Effect.runPromise(effect.pipe(Effect.flatMap(jsonText), Effect.map(textResult))); - -export function createMcpServer(config: { - gatewayUrl: string; - user?: string; - token?: string; - flowId?: string; -}) { - const server = new McpServer({ - name: "trustgraph", - version: "0.1.0", - }); - - const user = config.user ?? "mcp"; - const socket: BaseApi = createTrustGraphSocket( - user, - config.token, - config.gatewayUrl, - ); - - const flowId = config.flowId ?? "default"; - - // ===================== Flow-scoped tools ===================== - - server.tool( - "text_completion", - "Run a text completion using the configured LLM", - { - system: z.string().describe("System prompt"), - prompt: z.string().describe("User prompt"), - }, - ({system, prompt}) => - runTextTool(gatewayRequest(() => socket.flow(flowId).textCompletion(system, prompt))), - ); - - server.tool( - "graph_rag", - "Query the knowledge graph using RAG", - { - query: z.string().describe("Natural language query"), - entity_limit: z.number().optional().describe("Max entities to retrieve"), - triple_limit: z.number().optional().describe("Max triples per entity"), - collection: z.string().optional().describe("Collection name"), - }, - ({query, entity_limit, triple_limit, collection}) => - runTextTool( - gatewayRequest(() => - socket.flow(flowId).graphRag( - query, - { - ...(entity_limit !== undefined ? {entityLimit: entity_limit} : {}), - ...(triple_limit !== undefined ? {tripleLimit: triple_limit} : {}), - }, - collection, - ) - ), - ), - ); - - server.tool( - "document_rag", - "Query documents using RAG", - { - query: z.string().describe("Natural language query"), - doc_limit: z.number().optional().describe("Max documents to retrieve"), - collection: z.string().optional().describe("Collection name"), - }, - ({query, doc_limit, collection}) => - runTextTool(gatewayRequest(() => socket.flow(flowId).documentRag(query, doc_limit, collection))), - ); - - server.tool( - "agent", - "Ask the TrustGraph agent a question", - { - question: z.string().describe("Question for the agent"), - }, - ({question}) => - runTextTool( - Effect.callback<string, StdioMcpError>((resume) => { - let fullAnswer = ""; - socket.flow(flowId).agent( - question, - () => {}, - () => {}, - (chunk, complete) => { - fullAnswer += chunk; - if (complete) { - resume(Effect.succeed(fullAnswer)); - } - }, - (cause) => resume(Effect.fail(stdioMcpError(cause))), - ); - }), - ), - ); - - server.tool( - "embeddings", - "Generate text embeddings", - { - text: z.array(z.string()).describe("Texts to embed"), - }, - ({text}) => runJsonTool(gatewayRequest(() => socket.flow(flowId).embeddings(text))), - ); - - server.tool( - "triples_query", - "Query the knowledge graph for triples matching a pattern", - { - s: z.string().optional().describe("Subject IRI"), - p: z.string().optional().describe("Predicate IRI"), - o: z.string().optional().describe("Object IRI or literal"), - limit: z.number().optional().describe("Max results"), - collection: z.string().optional().describe("Collection name"), - }, - ({s, p, o, limit, collection}) => { - 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; - return runJsonTool( - gatewayRequest(() => socket.flow(flowId).triplesQuery(sTerm, pTerm, oTerm, limit, collection)), - ); - }, - ); - - server.tool( - "graph_embeddings_query", - "Find entities similar to a text query using vector embeddings", - { - query: z.string().describe("Text to find similar entities for"), - limit: z.number().optional().describe("Max results"), - collection: z.string().optional().describe("Collection name"), - }, - ({query, limit, collection}) => - runJsonTool( - gatewayRequest(() => socket.flow(flowId).embeddings([query])).pipe( - Effect.flatMap((vectors) => - gatewayRequest(() => - socket.flow(flowId).graphEmbeddingsQuery( - vectors[0] ?? [], - limit ?? 10, - collection, - ) - ) - ), - ), - ), - ); - - // ===================== Config tools ===================== - - server.tool( - "get_config_all", - "Get all configuration values", - {}, - () => runJsonTool(gatewayRequest(() => socket.config().getConfigAll())), - ); - - server.tool( - "get_config", - "Get specific configuration values", - { - keys: z.array( - z.object({ - type: z.string().describe("Config type"), - key: z.string().describe("Config key"), - }), - ).describe("Config keys to retrieve"), - }, - ({keys}) => runJsonTool(gatewayRequest(() => socket.config().getConfig(keys))), - ); - - server.tool( - "put_config", - "Set configuration values", - { - values: z.array( - z.object({ - type: z.string().describe("Config type"), - key: z.string().describe("Config key"), - value: z.string().describe("Config value (JSON-encoded)"), - }), - ).describe("Key-value entries to set"), - }, - ({values}) => runJsonTool(gatewayRequest(() => socket.config().putConfig(values))), - ); - - server.tool( - "delete_config", - "Delete a configuration entry", - { - type: z.string().describe("Config type"), - key: z.string().describe("Config key"), - }, - ({type, key}) => runJsonTool(gatewayRequest(() => socket.config().deleteConfig({type, key}))), - ); - - // ===================== Flow management tools ===================== - - server.tool( - "get_flows", - "List all available flows", - {}, - () => runJsonTool(gatewayRequest(() => socket.flows().getFlows())), - ); - - server.tool( - "get_flow", - "Get a specific flow definition", - { - flow_id: z.string().describe("Flow ID to retrieve"), - }, - ({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().getFlow(flow_id))), - ); - - server.tool( - "start_flow", - "Start a flow instance", - { - flow_id: z.string().describe("Flow ID"), - blueprint_name: z.string().describe("Blueprint name"), - description: z.string().describe("Flow description"), - parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"), - }, - ({flow_id, blueprint_name, description, parameters}) => - runJsonTool( - gatewayRequest(() => socket.flows().startFlow(flow_id, blueprint_name, description, parameters)), - ), - ); - - server.tool( - "stop_flow", - "Stop a running flow", - { - flow_id: z.string().describe("Flow ID to stop"), - }, - ({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().stopFlow(flow_id))), - ); - - // ===================== Library (document) tools ===================== - - server.tool( - "get_documents", - "List all documents in the library", - {}, - () => runJsonTool(gatewayRequest(() => socket.librarian().getDocuments())), - ); - - server.tool( - "load_document", - "Upload a document to the library", - { - document: z.string().describe("Base64-encoded document content"), - mime_type: z.string().describe("Document MIME type"), - title: z.string().describe("Document title"), - comments: z.string().optional().describe("Additional comments"), - tags: z.array(z.string()).optional().describe("Document tags"), - id: z.string().optional().describe("Optional document ID"), - }, - ({document, mime_type, title, comments, tags, id}) => - runJsonTool( - gatewayRequest(() => - socket.librarian().loadDocument( - document, - mime_type, - title, - comments ?? "", - tags ?? [], - id, - ) - ), - ), - ); - - server.tool( - "remove_document", - "Remove a document from the library", - { - id: z.string().describe("Document ID to remove"), - collection: z.string().optional().describe("Collection name"), - }, - ({id, collection}) => runJsonTool(gatewayRequest(() => socket.librarian().removeDocument(id, collection))), - ); - - // ===================== Prompt tools ===================== - - server.tool( - "get_prompts", - "List available prompt templates", - {}, - () => runJsonTool(gatewayRequest(() => socket.config().getPrompts())), - ); - - server.tool( - "get_prompt", - "Get a specific prompt template", - { - id: z.string().describe("Prompt template ID"), - }, - ({id}) => runJsonTool(gatewayRequest(() => socket.config().getPrompt(id))), - ); - - // ===================== Knowledge core tools ===================== - - server.tool( - "get_knowledge_cores", - "List available knowledge graph cores", - {}, - () => runJsonTool(gatewayRequest(() => socket.knowledge().getKnowledgeCores())), - ); - - server.tool( - "delete_kg_core", - "Delete a knowledge graph core", - { - id: z.string().describe("Knowledge core ID"), - collection: z.string().optional().describe("Collection name"), - }, - ({id, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().deleteKgCore(id, collection))), - ); - - server.tool( - "load_kg_core", - "Load a knowledge graph core", - { - id: z.string().describe("Knowledge core ID"), - flow: z.string().describe("Flow to use for loading"), - collection: z.string().optional().describe("Collection name"), - }, - ({id, flow, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().loadKgCore(id, flow, collection))), - ); - - return {server, socket}; -} - -export const runProgram = Effect.gen(function*() { - const config = yield* loadTrustGraphMcpConfig(); - const serverConfig = { - gatewayUrl: config.gatewayUrl, - user: config.user, - flowId: config.flowId, - ...(config.token === undefined ? {} : {token: config.token}), - }; - const {server, socket} = createMcpServer(serverConfig); - const transport = new StdioServerTransport(); - - yield* Effect.tryPromise({ - try: () => server.connect(transport), - catch: stdioMcpError, - }); - - yield* Effect.sync(() => { - process.on("SIGINT", () => { - socket.close(); - process.exit(0); - }); - }); -}); - -const stdioRuntime = ManagedRuntime.make(Layer.empty); - -export function run(): Promise<void> { - return stdioRuntime.runPromise(runProgram); -} - -export function runMain(): void { - NodeRuntime.runMain(runProgram); -} diff --git a/ts/packages/workbench/package.json b/ts/packages/workbench/package.json index 702256cf..af87d62e 100644 --- a/ts/packages/workbench/package.json +++ b/ts/packages/workbench/package.json @@ -10,18 +10,18 @@ "qa:browser": "playwright test" }, "dependencies": { - "@effect/ai-anthropic": "4.0.0-beta.75", - "@effect/ai-openai": "4.0.0-beta.75", - "@effect/ai-openrouter": "4.0.0-beta.75", - "@effect/atom-react": "4.0.0-beta.75", - "@effect/openapi-generator": "4.0.0-beta.75", - "@effect/opentelemetry": "4.0.0-beta.75", - "@effect/platform-browser": "4.0.0-beta.75", - "@effect/platform-bun": "4.0.0-beta.75", - "@effect/platform-node": "4.0.0-beta.75", - "@effect/platform-node-shared": "4.0.0-beta.75", - "@effect/tsgo": "0.13.0", - "@effect/vitest": "4.0.0-beta.75", + "@effect/ai-anthropic": "4.0.0-beta.78", + "@effect/ai-openai": "4.0.0-beta.78", + "@effect/ai-openrouter": "4.0.0-beta.78", + "@effect/atom-react": "4.0.0-beta.78", + "@effect/openapi-generator": "4.0.0-beta.78", + "@effect/opentelemetry": "4.0.0-beta.78", + "@effect/platform-browser": "4.0.0-beta.78", + "@effect/platform-bun": "4.0.0-beta.78", + "@effect/platform-node": "4.0.0-beta.78", + "@effect/platform-node-shared": "4.0.0-beta.78", + "@effect/tsgo": "0.14.0", + "@effect/vitest": "4.0.0-beta.78", "@tanstack/react-query": "^5.75.0", "@trustgraph/client": "workspace:*", "clsx": "^2.1.0", @@ -36,7 +36,7 @@ "zustand": "^5.0.0" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.75", + "@effect/vitest": "4.0.0-beta.78", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.0", "@types/react": "^19.1.0", diff --git a/ts/packages/workbench/playwright.config.ts b/ts/packages/workbench/playwright.config.ts index a8e35562..20cd3fe9 100644 --- a/ts/packages/workbench/playwright.config.ts +++ b/ts/packages/workbench/playwright.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ video: "retain-on-failure", }, webServer: { - command: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`, + command: `WORKBENCH_QA=1 bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`, cwd: ".", url: baseURL, reuseExistingServer: false, diff --git a/ts/packages/workbench/src/atoms/workbench.ts b/ts/packages/workbench/src/atoms/workbench.ts index 4b731f1a..c25f1edc 100644 --- a/ts/packages/workbench/src/atoms/workbench.ts +++ b/ts/packages/workbench/src/atoms/workbench.ts @@ -353,12 +353,12 @@ const ChatMessageSchema = S.Struct({ id: S.String, role: S.Union([S.Literal("user"), S.Literal("assistant"), S.Literal("system")]), content: S.String, - timestamp: S.Number, + timestamp: S.Finite, isStreaming: S.optionalKey(S.Boolean), metadata: S.optionalKey(S.Struct({ model: S.optionalKey(S.String), - inTokens: S.optionalKey(S.Number), - outTokens: S.optionalKey(S.Number), + inTokens: S.optionalKey(S.Finite), + outTokens: S.optionalKey(S.Finite), })), agentPhases: S.optionalKey(S.Struct({ think: S.String, diff --git a/ts/packages/workbench/src/components/chat/explain-graph.tsx b/ts/packages/workbench/src/components/chat/explain-graph.tsx index 328a7e00..9c8c84a9 100644 --- a/ts/packages/workbench/src/components/chat/explain-graph.tsx +++ b/ts/packages/workbench/src/components/chat/explain-graph.tsx @@ -14,6 +14,8 @@ import { localName, type GraphNode, type GraphLink, + directedGraphLinkProps, + DEFAULT_GRAPH_NODE_COLOR, } from "@/lib/graph-utils"; import type { ExplainEvent } from "@trustgraph/client"; import type { ForceGraphProps } from "react-force-graph-2d"; @@ -34,7 +36,7 @@ function paintNode(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: const y = node.y ?? 0; ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); - ctx.fillStyle = node.color ?? "#5b80ff"; + ctx.fillStyle = node.color ?? DEFAULT_GRAPH_NODE_COLOR; ctx.fill(); const fontSize = Math.max(9 / globalScale, 1.5); ctx.font = `${fontSize}px Inter, sans-serif`; @@ -115,7 +117,7 @@ export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { nodeCanvasObject={paintNode} linkCanvasObjectMode={() => "after"} linkCanvasObject={paintLink} - linkColor={() => "rgba(120,120,140,0.32)"} + {...directedGraphLinkProps} /> </Suspense> </div> diff --git a/ts/packages/workbench/src/components/layout/glow-background.tsx b/ts/packages/workbench/src/components/layout/glow-background.tsx index ac74c9ef..f8e44ef8 100644 --- a/ts/packages/workbench/src/components/layout/glow-background.tsx +++ b/ts/packages/workbench/src/components/layout/glow-background.tsx @@ -1,24 +1,34 @@ /** - * Ambient glow background — forest green radial blobs that drift and pulse. + * Ambient glow background -- forest green radial fields that drift and pulse. * * Ported from beep-effect4's GlowEffectPaper, adapted for plain CSS - * with multiple independent blobs for organic movement. + * with multiple independent fields for organic movement. */ +const primaryGlow = "radial-gradient(ellipse at center, var(--tg-glow-primary-start) 0%, var(--tg-glow-primary-mid) 40%, transparent 70%)"; +const secondaryGlow = "radial-gradient(ellipse at center, var(--tg-glow-secondary-start) 0%, var(--tg-glow-secondary-mid) 45%, transparent 70%)"; +const tertiaryGlow = "radial-gradient(ellipse at center, var(--tg-glow-tertiary-start) 0%, var(--tg-glow-tertiary-mid) 50%, transparent 70%)"; + export function GlowBackground() { return ( <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-0 overflow-hidden animate-[glow-fade-in_1.2s_ease-out_forwards] opacity-0" > - {/* Primary blob — large, centered, slow drift */} - <div className="absolute left-1/2 top-1/3 h-[70vh] w-[70vw] -translate-x-1/2 -translate-y-1/2 animate-[glow-drift-1_20s_ease-in-out_infinite] rounded-full bg-[radial-gradient(ellipse_at_center,rgba(61,125,61,0.35)_0%,rgba(45,99,45,0.15)_40%,transparent_70%)] blur-[80px]" /> + <div + className="absolute left-1/2 top-1/3 h-[70vh] w-[70vw] -translate-x-1/2 -translate-y-1/2 animate-[glow-drift-1_20s_ease-in-out_infinite] rounded-full blur-[80px]" + style={{ background: primaryGlow }} + /> - {/* Secondary blob — smaller, offset right, faster */} - <div className="absolute right-[10%] top-[20%] h-[50vh] w-[40vw] animate-[glow-drift-2_15s_ease-in-out_infinite] rounded-full bg-[radial-gradient(ellipse_at_center,rgba(92,154,92,0.28)_0%,rgba(61,125,61,0.12)_45%,transparent_70%)] blur-[60px]" /> + <div + className="absolute right-[10%] top-[20%] h-[50vh] w-[40vw] animate-[glow-drift-2_15s_ease-in-out_infinite] rounded-full blur-[60px]" + style={{ background: secondaryGlow }} + /> - {/* Tertiary blob — bottom left, subtle */} - <div className="absolute bottom-[5%] left-[15%] h-[45vh] w-[45vw] animate-[glow-drift-3_25s_ease-in-out_infinite] rounded-full bg-[radial-gradient(ellipse_at_center,rgba(33,78,33,0.30)_0%,rgba(26,58,26,0.12)_50%,transparent_70%)] blur-[70px]" /> + <div + className="absolute bottom-[5%] left-[15%] h-[45vh] w-[45vw] animate-[glow-drift-3_25s_ease-in-out_infinite] rounded-full blur-[70px]" + style={{ background: tertiaryGlow }} + /> </div> ); } diff --git a/ts/packages/workbench/src/index.css b/ts/packages/workbench/src/index.css index a4e1d609..050c18bf 100644 --- a/ts/packages/workbench/src/index.css +++ b/ts/packages/workbench/src/index.css @@ -51,6 +51,15 @@ --font-mono: "JetBrains Mono", ui-monospace, monospace; } +:root { + --tg-glow-primary-start: rgba(61, 125, 61, 0.35); + --tg-glow-primary-mid: rgba(45, 99, 45, 0.15); + --tg-glow-secondary-start: rgba(92, 154, 92, 0.28); + --tg-glow-secondary-mid: rgba(61, 125, 61, 0.12); + --tg-glow-tertiary-start: rgba(33, 78, 33, 0.30); + --tg-glow-tertiary-mid: rgba(26, 58, 26, 0.12); +} + /* Base layer: dark background, light text by default */ @layer base { *, @@ -182,4 +191,11 @@ html.light { --color-success: #16a34a; --color-warning: #854d0e; --color-error: #b91c1c; + + --tg-glow-primary-start: rgba(61, 125, 61, 0.28); + --tg-glow-primary-mid: rgba(92, 154, 92, 0.16); + --tg-glow-secondary-start: rgba(45, 99, 45, 0.22); + --tg-glow-secondary-mid: rgba(61, 125, 61, 0.12); + --tg-glow-tertiary-start: rgba(33, 78, 33, 0.18); + --tg-glow-tertiary-mid: rgba(92, 154, 92, 0.10); } diff --git a/ts/packages/workbench/src/lib/graph-utils.ts b/ts/packages/workbench/src/lib/graph-utils.ts index 73e8f87f..a2bdd9ea 100644 --- a/ts/packages/workbench/src/lib/graph-utils.ts +++ b/ts/packages/workbench/src/lib/graph-utils.ts @@ -1,6 +1,6 @@ import type { Triple, Term } from "@trustgraph/client"; import { Match } from "effect"; -import type { NodeObject, LinkObject } from "react-force-graph-2d"; +import type { ForceGraphProps, NodeObject, LinkObject } from "react-force-graph-2d"; // --------------------------------------------------------------------------- // Constants @@ -32,6 +32,32 @@ export interface GraphData { links: GraphLink[]; } +export const DEFAULT_GRAPH_NODE_COLOR = "#82b582"; + +const GRAPH_NODE_PALETTE = [ + DEFAULT_GRAPH_NODE_COLOR, + "#5c9a5c", + "#3d7d3d", + "#aed1ae", + "#22c55e", + "#eab308", + "#a1a1aa", + "#71717a", +]; + +export const directedGraphLinkProps = { + autoPauseRedraw: false, + linkColor: "rgba(161,161,170,0.55)", + linkWidth: 1.4, + linkDirectionalArrowLength: 9, + linkDirectionalArrowRelPos: 0.58, + linkDirectionalArrowColor: "rgba(174,209,174,0.98)", + linkDirectionalParticles: 1, + linkDirectionalParticleSpeed: 0.005, + linkDirectionalParticleWidth: 2.2, + linkDirectionalParticleColor: "rgba(92,154,92,0.95)", +} satisfies Partial<ForceGraphProps<GraphNode, GraphLink>>; + // --------------------------------------------------------------------------- // Term helpers // --------------------------------------------------------------------------- @@ -66,8 +92,8 @@ export function hashColor(s: string): string { for (let i = 0; i < s.length; i++) { hash = s.charCodeAt(i) + ((hash << 5) - hash); } - const hue = ((hash % 360) + 360) % 360; - return `hsl(${hue}, 60%, 55%)`; + const index = Math.abs(hash) % GRAPH_NODE_PALETTE.length; + return GRAPH_NODE_PALETTE[index]; } // --------------------------------------------------------------------------- @@ -103,7 +129,7 @@ export function triplesToGraph(triples: Triple[]): { nodeMap.set(uri, { id: uri, label: labelMap.get(uri) ?? localName(uri), - color: type !== undefined ? hashColor(localName(type)) : "#5b80ff", + color: hashColor(type !== undefined ? localName(type) : uri), degree: 0, }); } diff --git a/ts/packages/workbench/src/pages/graph.tsx b/ts/packages/workbench/src/pages/graph.tsx index ff7ff003..e587bbdb 100644 --- a/ts/packages/workbench/src/pages/graph.tsx +++ b/ts/packages/workbench/src/pages/graph.tsx @@ -27,6 +27,8 @@ import { termValue, type GraphNode, type GraphLink, + directedGraphLinkProps, + DEFAULT_GRAPH_NODE_COLOR, } from "@/lib/graph-utils"; import type { ForceGraphProps } from "react-force-graph-2d"; import { Badge } from "@/components/ui/badge"; @@ -120,7 +122,7 @@ function paintNode(showLabels: boolean) { const y = node.y ?? 0; ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); - ctx.fillStyle = node.color ?? "#5b80ff"; + ctx.fillStyle = node.color ?? DEFAULT_GRAPH_NODE_COLOR; ctx.fill(); if (!showLabels || globalScale < 0.7) return; const fontSize = Math.max(10 / globalScale, 2); @@ -257,7 +259,7 @@ export default function GraphPage() { nodeCanvasObject={paintNode(view.showLabels)} linkCanvasObjectMode={() => "after"} linkCanvasObject={paintLink} - linkColor={() => "rgba(120,120,140,0.32)"} + {...directedGraphLinkProps} nodePointerAreaPaint={(node, color, ctx) => { ctx.fillStyle = color; ctx.beginPath(); diff --git a/ts/packages/workbench/vite.config.ts b/ts/packages/workbench/vite.config.ts index f6f2dfe9..7e1ba64b 100644 --- a/ts/packages/workbench/vite.config.ts +++ b/ts/packages/workbench/vite.config.ts @@ -3,8 +3,23 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; +const isWorkbenchQa = process.env.WORKBENCH_QA === "1"; + export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), + { + name: "trustgraph-workbench-qa-otel", + configureServer(server) { + if (!isWorkbenchQa) return; + server.middlewares.use("/otel", (_request, response) => { + response.statusCode = 204; + response.end(); + }); + }, + }, + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"),