diff --git a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md index d8197aa1..21c36656 100644 --- a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md +++ b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md @@ -1,9 +1,8 @@ # TrustGraph Effect-Native Rewrite Opportunity Audit -This is the first ranked audit produced from the playbook in -`ts/EFFECT_NATIVE_REWRITE_PLAYBOOK.md`. It is an opportunity map, not a code -rewrite. The branch was `ts-port-effect-v4`; the only unrelated local file seen -during the audit was `.idea/effect.intellij.xml`. +This is the current backlog snapshot for the playbook in +`ts/EFFECT_NATIVE_REWRITE_PLAYBOOK.md`. The branch is `ts-port-effect-v4`. +The unrelated local file `.idea/effect.intellij.xml` must stay uncommitted. ## Inputs @@ -11,27 +10,38 @@ Verified source roots: - TrustGraph TS port: `/home/elpresidank/YeeBois/dev/trustgraph/ts` - Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4` -- Reactivity fallback: `ts/node_modules/effect/src/unstable/reactivity` -- Atom React fallback: `ts/packages/workbench/node_modules/@effect/atom-react` +- Installed Effect beta used by this workspace: `ts/node_modules/effect` -Signal counts from `ts/packages`: +Current signal counts from `ts/packages` after the 2026-06-02 strict tsgo +slice: | Signal | Count | | --- | ---: | -| `Effect.runPromise` | 71 | -| `Map<` | 54 | -| `JSON.stringify` | 50 | +| `Effect.runPromise` | 208 | +| `Map<` | 58 | | `WebSocket` | 45 | -| `process.env` | 44 | -| `new Map` | 42 | +| `new Map` | 45 | | `toPromiseRequestor` | 19 | | `makeAsyncProcessor` | 19 | -| `new Promise` | 18 | -| `JSON.parse` | 16 | -| `receive(` | 16 | -| `setTimeout` | 13 | -| `while (` | 10 | +| `receive(` | 18 | +| `while (` | 13 | +| `new Error` | 14 | +| `new Promise` | 10 | +| `JSON.parse` | 8 | | `localStorage` | 8 | +| `JSON.stringify` | 6 | +| `setTimeout` | 4 | +| `process.env` | 3 | + +Notes: + +- The remaining `process.env` hits are in `packages/workbench/playwright.config.ts`. +- In production `packages/base`, `packages/cli`, and `packages/mcp` sources, + the strict scans for `new Error`, `new Promise`, `setTimeout`, + `JSON.parse`, `JSON.stringify`, and direct `process.env` reads are clean. +- `Effect.runPromise` is expected at external Promise compatibility + boundaries, but each match should still be audited for avoidable internal + runtime ownership. ## Loop Passes @@ -39,356 +49,256 @@ Signal counts from `ts/packages`: - Status: migrated and verified. - Completed: - - `ts/packages/base/src/messaging/request-response.ts:50` now creates an - explicit `Scope.Closeable` and `:55` builds the existing - `EffectRequestResponse` runtime. - - `ts/packages/base/src/messaging/request-response.ts:91` rejects - not-started calls with `MessagingLifecycleError`, and `:108` maps - recipient callback failures into `MessagingDeliveryError`. It no longer - constructs normal `Error` values. - - `ts/packages/base/src/messaging/runtime.ts:427` now lets - request/response own its producer directly, `:442` runs the response - dispatcher with `Effect.forkScoped`, and `:445` makes shutdown idempotent. - - `ts/packages/base/src/__tests__/request-response.test.ts:115` covers the - Promise facade over the Effect runtime, `:143` asserts tagged timeout - errors, and `:164` asserts tagged lifecycle errors. + - Request/response startup now owns a scoped Effect runtime handle and maps + failures to TrustGraph tagged messaging errors. + - Runtime shutdown is idempotent and uses scoped fibers. + - Tests cover Promise compatibility, tagged timeout errors, and tagged + lifecycle errors. - Verification: - `bun run --cwd ts/packages/base test` - `bun run --cwd ts/packages/base build` - `bun run --cwd ts check:tsgo` - `bun run --cwd ts build` - `bun run --cwd ts test` -- Remaining base evidence: - - `makeSubscriber(` has no current `ts/packages` call sites after this slice, - but `ts/packages/base/src/messaging/index.ts` still exports - `makeAsyncQueue`, `makeSubscriber`, and related types. - - `ts/packages/base/src/messaging/consumer.ts` still has a Promise polling - loop and a normal `Error` constructor. - - `ts/packages/base/src/messaging/producer.ts` still throws a normal - not-started `Error`. -- Decision: - - Normal `Error` construction in library internals is migration evidence. - Prefer existing `S.TaggedErrorClass` errors from - `ts/packages/base/src/errors.ts`, adding new tagged errors when needed. - - `try`/`catch` blocks are also migration evidence. Prefer `Effect.try`, - `Effect.tryPromise`, or `Result.try` unless the block is a host/tool - boundary or test-only helper. ### 2026-06-02: Gateway Dispatcher Requestor Cache - Status: migrated and package-verified. - Completed: - - `ts/packages/flow/src/gateway/dispatch/manager.ts:121` centralizes - streaming completion detection as `dispatcherManagerIsCompleteResponse`. - - `ts/packages/flow/src/gateway/dispatch/manager.ts:137` stores requestors - as `EffectRequestResponse` handles, not `Promise` values. - - `ts/packages/flow/src/gateway/dispatch/manager.ts:152` starts the manager - through an Effect program, `:157` creates a `SynchronizedRef` cache, and - `:164` uses `Effect.onError` for scope cleanup instead of a `try`/`catch` - block. - - `ts/packages/flow/src/gateway/dispatch/manager.ts:200` uses - `SynchronizedRef.modifyEffect` so lazy requestor creation and caching are - serialized under the manager scope. - - `ts/packages/flow/src/gateway/dispatch/manager.ts:267` and `:312` keep - Fastify/RPC as Promise boundaries via `Effect.runPromise`; streaming - responder failures are mapped with `MessagingDeliveryError` at `:290` and - `:340`. - - `ts/packages/flow/src/gateway/server.ts:25` accepts an optional injected - `PubSubBackend` for tests without changing production NATS defaults. - - `ts/packages/flow/src/__tests__/gateway-dispatcher.test.ts:150` verifies - scoped requestor reuse and shutdown, `:172` verifies streaming through the - centralized completion predicate, and `:192` table-tests all final markers. + - Gateway dispatcher caches scoped `EffectRequestResponse` handles instead + of `Promise` values. + - Lazy requestor creation is serialized with `SynchronizedRef.modifyEffect`. + - Streaming final-marker detection is centralized. + - Dispatcher cleanup uses Effect scope/error handling instead of manual + `try`/`catch`. - Verification: - `bun run --cwd ts/packages/flow test` - `bun run --cwd ts/packages/flow build` - `bun run --cwd ts check:tsgo` -- Remaining gateway evidence: - - `ts/packages/flow/src/gateway/rpc-server.ts` still has Promise callbacks - around Effect RPC queues. - - `ts/packages/flow/src/gateway/server.ts` still has Fastify route - `try`/`catch` blocks. These are boundary code, but should still be audited - for `Effect.tryPromise` wrapping where it improves consistency. - - `ts/packages/client/src/socket/trustgraph-socket.ts` still duplicates some - streaming final-marker detection on the client side. + +### 2026-06-02: Strict Base, CLI, MCP, And tsgo Slice + +- Status: migrated, root-verified, ready to commit. +- Completed: + - Base messaging, NATS backend, producer, consumer, subscriber, + request/response, runtime factories, processor programs, flow specs, and + LLM service now use Effect-native boundaries, schema codecs, scoped + cleanup, and `S.TaggedErrorClass.make(...)` errors. + - CLI commands now run Effect programs at the command boundary, wrap socket + lifecycle with `Effect.acquireUseRelease`, encode JSON through Effect + Schema, and write output without `console.log`. + - MCP Effect server now loads env/config through `Config`, wraps gateway + calls with `Effect.tryPromise`, constructs schema classes with `.make`, and + uses tagged errors. + - MCP stdio compatibility server keeps `createMcpServer` and `run`, but uses + Effect callbacks/tryPromise/schema encoding internally. `run()` uses + `ManagedRuntime`; `runMain()` uses `NodeRuntime.runMain`. + - Flow stateful service launch sites now pass an explicit `Context.Context` + into the base processor runtime instead of hiding requirements behind + assertions. +- Verification: + - `cd ts && bun run check` + - `cd ts && bun run test` + - `cd ts && bun run build` + - `git diff --check` + +## Subagent Findings To Preserve + +- MCP/workbench: + - Make the Effect MCP server the canonical implementation. The old stdio + server should remain only as compatibility while parity is needed. + - Workbench BaseApi atoms can move toward `AtomRpc` or `AtomHttpApi` after + the client API is less Promise-first. + - MCP env is now Config-backed; continue that policy for future MCP settings. +- Flow stateful services: + - Config, librarian, cores, and flow-manager still have mutable poller + service objects. These remain good candidates for `Context` services, + scoped layers, `Ref`/`SynchronizedRef`, `Schedule`, and managed + persistence. + - Persistence IO should move toward `FileSystem` or `KeyValueStore` where + the installed beta has the needed provider surface. +- Base messaging/processors: + - Subscriber queues/maps, processor/flow Promise compatibility, and dynamic + flow state should continue moving toward `Queue`, `Deferred`, + `SynchronizedRef`, `Schedule`, and scoped layers. + - Existing constructor shims and typed registries in base processors still + use type assertions; they need a typed factory/registry redesign rather + than more assertions. +- Gateway/client: + - Knowledge streams still duplicate legacy end-of-stream handling. + - Effect RPC client remains Promise-first internally in places and should be + turned into a managed runtime or scoped layer. + - WebSocket adapter shims still contain host-boundary `try`/`catch` and + normal `Error` construction. +- RAG/providers/storage: + - RAG and agent helpers still adapt Effect requestors back to Promise + requestors. + - Provider SDKs and storage clients should become managed resources where + they have meaningful lifecycle. + - FalkorDB/Qdrant/Ollama/OpenAI-compatible surfaces still need config, + schema, and scope audits. ## Ranked Findings -### P0: Collapse Base Messaging Promise Facades - -- Impact: 5 -- Risk: 4 -- Confidence: 4 -- TrustGraph evidence: - - `ts/packages/base/src/messaging/runtime.ts` already defines Effect - producer, consumer, request/response factories, queues, fibers, and scopes. - - `ts/packages/base/src/messaging/consumer.ts` still has a manual - `while (running)` receive loop, `sleep`, and Promise delay helpers. - - `ts/packages/base/src/messaging/subscriber.ts` still manages resolver maps - and timeout promises. - - `ts/packages/base/src/processor/flow.ts` exposes compatibility scope - helpers and converts Effect handles back into Promise-style handles. -- Effect evidence: - - `effect/Queue`, `effect/PubSub`, `effect/Stream`, `effect/Scope`, - `effect/Layer`, `effect/Schedule`, `effect/Ref`. - - Sources: `packages/effect/src/Queue.ts`, `PubSub.ts`, `Stream.ts`, - `Scope.ts`, `Layer.ts`, `Schedule.ts`, `Ref.ts`. -- Rewrite shape: - - Make the Effect runtime factories the canonical internal surface. - - Keep Promise adapters only at external compatibility boundaries. Rejected - values at those boundaries should still be tagged TrustGraph errors. - - Replace polling sleep loops with scheduled scoped consumers where possible. - - Replace resolver maps with `Queue`, `Deferred`, or `PubSub`-backed routing. -- Tests: - - `cd ts && bun run --cwd packages/base test` - - Existing runtime tests around request/response, flow specs, and consumers - should be expanded before removing compatibility behavior. -- Blockers: - - Public package exports may still expect Promise-shaped producer, consumer, - and request/response handles. Inventory callers before changing exports. - - First slice completed request/response facade migration. Next base follow-up - is either an Effect-backed consumer facade or a public export decision for - `subscriber.ts`. - ### P0: Convert Stateful Flow Services To Scoped Effect Services -- Impact: 5 -- Risk: 4 -- Confidence: 4 - TrustGraph evidence: - - `ts/packages/flow/src/config/service.ts` uses `makeAsyncProcessor`, - mutable nested `Map` state, `while (this.running)`, `receive(2000)`, - `sleep`, JSON persistence, and direct `process.env`. - - `ts/packages/flow/src/librarian/service.ts`, `cores/service.ts`, and - `flow-manager/service.ts` repeat the same service-object pattern. -- Effect evidence: + - `ts/packages/flow/src/config/service.ts` + - `ts/packages/flow/src/librarian/service.ts` + - `ts/packages/flow/src/cores/service.ts` + - `ts/packages/flow/src/flow-manager/service.ts` +- Effect primitives: - `Context`, `Layer.scoped`, `Ref`, `SynchronizedRef`, `Schedule`, - `Effect.addFinalizer`, `Config`, `Schema`, `effect/FileSystem`, - `effect/unstable/persistence/KeyValueStore`. - - Sources: `packages/effect/src/Context.ts`, `Layer.ts`, `Ref.ts`, - `SynchronizedRef.ts`, `Schedule.ts`, `Config.ts`, `Schema.ts`, - `ts/node_modules/effect/src/FileSystem.ts`, - `ts/node_modules/effect/src/unstable/persistence/KeyValueStore.ts`. + `Effect.addFinalizer`, `Config`, `Schema`, `FileSystem`, + `KeyValueStore`. - Rewrite shape: - - Model each service as a `Context` service plus a scoped layer. - - Store service state in `Ref` or `SynchronizedRef`, not mutable object fields. - - Express persistence with `effect/FileSystem` or - `KeyValueStore.layerFileSystem` when the installed beta exposes the needed - provider. + - Model one service at a time as a `Context` service plus scoped layer. + - Store mutable service state in `Ref` or `SynchronizedRef`. + - Replace polling sleep loops with schedules where behavior allows. - Decode persisted payloads and config with schemas at boundaries. - Tests: - Service-specific tests plus `cd ts && bun run --cwd packages/flow test`. - - Add persistence round-trip tests before replacing file IO. -- Blockers: - - These services are behavior-heavy. Do one service per PR after the shared - runtime surface is stable. -### P0: Make Gateway Dispatcher Effect-Native +### P0: Remove RAG And Agent `toPromiseRequestor` Bridges -- Impact: 5 -- Risk: 3 -- Confidence: 4 -- TrustGraph evidence: - - `ts/packages/flow/src/gateway/server.ts` already builds RPC/WebSocket - pieces with Effect. - - `ts/packages/flow/src/gateway/rpc-server.ts` uses `Queue` and RPC layers. - - `ts/packages/flow/src/gateway/dispatch/manager.ts` still keeps - `Map>>`, manual - streaming completion checks, and per-publish producer construction. -- Effect evidence: - - `effect/unstable/rpc` `RpcClient`, `RpcServer`, `RpcSerialization`. - - `effect/unstable/socket` `Socket`. - - `effect/Queue`, `Stream`, `Scope`, `Layer`. - - Sources: `ts/node_modules/effect/src/unstable/rpc/RpcClient.ts`, - `RpcServer.ts`, `RpcSerialization.ts`, and - `ts/node_modules/effect/src/unstable/socket/Socket.ts`. -- Rewrite shape: - - Convert dispatcher manager methods to Effect-returning functions internally. - - Cache requestors as scoped resources instead of Promise values. - - Represent streaming dispatch as `Stream` or `Queue` instead of callback - completion detection where the wire protocol allows it. - - Keep Fastify route handlers as Promise boundaries. -- Tests: - - Gateway dispatch tests with fake pubsub. - - `cd ts && SKIP_LLM=1 bun run test:pipeline` after implementation. -- Blockers: - - The gateway is an integration boundary. Preserve current HTTP and WebSocket - wire behavior during the first rewrite. - - First dispatcher-cache slice is complete. Follow-up gateway work should - target RPC server Promise callbacks and client-side streaming completion - duplication, not recreate the requestor cache migration. - -### P1: Remove RAG And Agent `toPromiseRequestor` Bridges - -- Impact: 4 -- Risk: 3 -- Confidence: 5 - TrustGraph evidence: - `ts/packages/flow/src/retrieval/document-rag-service.ts` - `ts/packages/flow/src/retrieval/graph-rag-service.ts` - `ts/packages/flow/src/agent/react/service.ts` - - All define `toPromiseRequestor` and then immediately adapt Effect - requestors back to Promise-style clients. -- Effect evidence: - - Existing TrustGraph `EffectRequestResponse` in - `ts/packages/base/src/messaging/runtime.ts`. - - `effect/Stream`, `Effect.fn`, `Effect.runPromiseWith` for boundary-only - execution. +- Effect primitives: + - Existing `EffectRequestResponse`, `Effect.fn`, `Stream`, + `Effect.runPromiseWith` at true external boundaries only. - Rewrite shape: - - Update RAG engines and agent helpers to accept Effect requestors or - functions returning `Effect`. - - Keep Promise wrappers only for old public APIs or tests that explicitly + - Update engines and agent helpers to accept Effect requestors or + Effect-returning functions directly. + - Preserve Promise wrappers only for old public APIs or tests that explicitly verify compatibility. - - Convert streaming agent flows to `Stream` where possible. - Tests: - Existing RAG and agent service tests. - Add tests that assert requestor errors stay typed through the Effect path. -- Blockers: - - Engine call signatures need a small design pass so RAG and agent rewrite in - the same direction. ### P1: Finish Client RPC Boundary Modernization -- Impact: 4 -- Risk: 3 -- Confidence: 4 - TrustGraph evidence: - - `ts/packages/client/src/socket/effect-rpc-client.ts` already uses - `Socket.makeWebSocket`, `RpcClient.layerProtocolSocket`, and - `RpcSerialization.layerNdjson`. - - The same file still owns `scopePromise`, `clientPromise`, repeated - `Effect.runPromise`, listener sets, a WebSocket constructor shim, and a - Promise facade. - - `ts/packages/client/src/socket/trustgraph-socket.ts` is mostly a - compatibility API over the Effect RPC client. -- Effect evidence: - - `effect/unstable/socket/Socket`: `makeWebSocket`, `fromWebSocket`, + - `ts/packages/client/src/socket/effect-rpc-client.ts` + - `ts/packages/client/src/socket/trustgraph-socket.ts` + - `ts/packages/client/src/socket/websocket-adapter.ts` +- Effect primitives: + - `effect/unstable/socket` `Socket.makeWebSocket`, `fromWebSocket`, `toChannel`, `layerWebSocket`. - - `effect/unstable/rpc/RpcClient`: `layerProtocolSocket`. - - `effect/unstable/rpc/RpcSerialization`: `layerNdjson`, `layerNdJsonRpc`. + - `effect/unstable/rpc/RpcClient.layerProtocolSocket`. + - `effect/unstable/rpc/RpcSerialization.layerNdjson` or `layerNdJsonRpc`. + - `ManagedRuntime` for compatibility facades when a Promise API must remain. - Rewrite shape: - Treat `EffectRpcClient` as an internal managed runtime or scoped layer. - - Expose Promise-returning methods only through a thin compatibility adapter. - - Move browser vs Node WebSocket constructor selection into platform layers. + - Expose Promise-returning methods through a thin adapter. + - Replace normal client `Error` constructors with tagged errors before they + cross into shared Effect code. - Tests: - `cd ts && bun run --cwd packages/client test` - - Keep timeout/retry tests around `withDispatchRequestPolicy`. -- Blockers: - - Workbench and CLI still consume Promise-shaped client APIs. + +### P1: Base Processor Registry And Constructor Shims + +- TrustGraph evidence: + - `ts/packages/base/src/processor/async-processor.ts` + - `ts/packages/base/src/processor/flow.ts` + - `ts/packages/base/src/processor/flow-processor.ts` +- Effect primitives: + - Schema-backed registries, `Context`, `Layer`, `Effect.fn`, `Option`, + `Predicate`. +- Rewrite shape: + - Replace constructor `as unknown as` shims with typed factory exports. + - Replace resource lookup casts with schema-backed typed registry helpers. + - Do not add assertions to quiet Effect channel inference problems. +- Tests: + - `cd ts && bun run --cwd packages/base test` + - Root `cd ts && bun run check` because this surface easily pollutes Effect + error and requirement channels. ### P1: Make SDK, Storage, And Provider Layers Managed Resources -- Impact: 4 -- Risk: 3 -- Confidence: 3 - TrustGraph evidence: - `ts/packages/flow/src/storage/triples/falkordb.ts` - `ts/packages/flow/src/storage/embeddings/qdrant-graph.ts` - `ts/packages/flow/src/storage/embeddings/qdrant-doc.ts` - `ts/packages/flow/src/model/text-completion/*.ts` - - These files create direct SDK clients and read `process.env` in live - constructors. -- Effect evidence: + - `ts/packages/flow/src/embeddings/ollama.ts` +- Effect primitives: - `Effect.acquireRelease`, `Layer.scoped`, `Config`, `ConfigProvider`, - `effect/FileSystem`, `effect/unstable/persistence/KeyValueStore`, - `Metric`, `Logger`. - - AI provider modules from installed provider packages, with subtree source - proof under `packages/ai/*/src`, including `OpenAiLanguageModel.ts`, - `AnthropicLanguageModel.ts`, and `OpenRouterLanguageModel.ts`. + `Metric`, `Logger`, Effect AI provider layers. - Rewrite shape: - - Move env reading into `Config` loaders and provider-specific layers. + - Move env/config reading into `Config` loaders and provider-specific layers. - Scope SDK clients that need explicit close/disconnect. - - Replace `console` or ad hoc logging with `Effect.log*` and metrics where - useful. + - Remove `Effect.void as Effect.Effect` stream assertions by + letting branch return types infer or by restructuring the stream parser. - Tests: - Provider config tests with `ConfigProvider.fromMap`. - Storage tests with fake clients before changing real resource lifetimes. -- Blockers: - - Some third-party SDK clients may not have meaningful finalizers. Mark those - no-op after proof instead of forcing fake lifecycle code. ### P2: Canonicalize MCP Around The Effect Server -- Impact: 3 -- Risk: 2 -- Confidence: 5 -- TrustGraph evidence: - - `ts/packages/mcp/src/server.ts` is the old SDK/Zod server. - - `ts/packages/mcp/src/server-effect.ts` has Effect AI tools, schemas, - `McpServer`, HTTP API integration, and provider layers. -- Effect evidence: - - `effect/unstable/ai` `Tool`, `Toolkit`, `McpServer`, `McpSchema`, - `LanguageModel`. - - Sources: `ts/node_modules/effect/src/unstable/ai/Tool.ts`, - `Toolkit.ts`, `McpServer.ts`, `McpSchema.ts`, `LanguageModel.ts`. -- Rewrite shape: - - Do not rewrite the Effect server from scratch. - - Make the Effect server canonical after parity checks. - - Keep the old server only as compatibility or delete it once entrypoints and - tests prove the Effect path is complete. +- Status: + - First blocker slice complete: MCP now builds under strict tsgo and the + stdio server has an Effect-backed compatibility implementation. +- Remaining shape: + - Decide whether the old SDK/Zod stdio compatibility surface should stay as + a wrapper or be removed. + - Add parity tests before deleting any public entry point. - Tests: - - MCP package build/test. - - Tool parity diff against `server.ts` before removal. -- Blockers: - - Needs a policy decision about old SDK server lifetime. + - `cd ts && bun run --cwd packages/mcp build` + - Root `cd ts && bun run check` ### P2: Tighten Workbench Platform And Reactivity Usage -- Impact: 3 -- Risk: 2 -- Confidence: 4 - TrustGraph evidence: - - `ts/packages/workbench/src/atoms/workbench.ts` already uses Atom, - AsyncResult, Reactivity, browser layers, and metrics. - - Remaining direct browser state includes `localStorage` reads/writes and DOM - theme inspection. -- Effect evidence: + - `ts/packages/workbench/src/atoms/workbench.ts` + - Remaining direct browser state includes `localStorage` and DOM theme + inspection. +- Effect primitives: - `BrowserKeyValueStore.layerLocalStorage`, `BrowserKeyValueStore.layerSessionStorage`, `BrowserHttpClient`, - `Clipboard`. - - `AtomRpc`, `AtomHttpApi`, `AtomRegistry`, `AsyncResult`, `Reactivity`. + `Clipboard`, `AtomRpc`, `AtomHttpApi`, `AtomRegistry`, `AsyncResult`, + `Reactivity`. - Rewrite shape: - - Leave the workbench out of the first rewrite wave. - - Later, move persistent UI state through `BrowserKeyValueStore` and keep - remote state in Atom RPC/HTTP API families if the client API becomes fully - typed Effect RPC. + - Leave workbench out of the next backend/runtime rewrite wave. + - Move persistent UI state through browser platform services later. - Tests: - - `cd ts && bun run workbench:qa`. -- Blockers: - - Workbench is already the most modern surface. Backend/runtime wins should - happen first. + - `cd ts && bun run workbench:qa` ## Recommended PR Order 1. Config service scoped state migration. 2. RAG and agent requestor bridge removal. -3. Base consumer facade and subscriber export cleanup. -4. Client compatibility facade tightening. +3. Client RPC managed runtime/scoped layer cleanup. +4. Base processor registry and constructor shim redesign. 5. Gateway RPC callback and client streaming completion cleanup. 6. Storage/provider managed resource cleanup. -7. MCP canonicalization and Workbench polish. +7. MCP parity/deletion decision and workbench platform polish. ## No-Op Rules Do not flag these as rewrite blockers without additional proof: -- Promise-returning CLI actions and Fastify route handlers at external - boundaries. This does not exempt normal `Error` construction inside shared - library code. +- Promise-returning CLI actions, MCP SDK callbacks, client compatibility + methods, and Fastify route handlers at true external boundaries. Boundary + code still must map failures into typed errors or wire errors. - `try`/`catch` blocks at host/tool boundaries only when the catch maps into a - typed error or wire error. Internal exception capture should use `Effect.try`, - `Effect.tryPromise`, or `Result.try`. + typed error or a wire-contract error. Internal exception capture should use + `Effect.try`, `Effect.tryPromise`, or `Result.try`. - `S.Class`, `S.TaggedErrorClass`, `Context.Service`, `Rpc.make`, and `HttpApi.make` when they are required or idiomatic for the Effect API. - Plain `Map` usage for local pure transformations, such as graph utility - construction, unless the state is long-lived, mutable service state. -- JSON stringification that is part of the TrustGraph wire contract, unless a - schema codec can preserve the exact encoded form. + construction, unless the state is long-lived mutable service state. +- JSON stringification in tests or wire-contract fixtures. Production JSON + encode/decode should prefer schema codecs when the encoded form can be + preserved. -## Acceptance +## Acceptance For Final Loop Completion -This audit is complete when: +The overall playbook loop is complete only when: -- `ts/EFFECT_NATIVE_REWRITE_PLAYBOOK.md` exists. -- This ranked audit exists and cites concrete TrustGraph and Effect surfaces. -- `git diff --check` passes for both files. -- No code rewrite is mixed into this audit. +- All remaining playbook signal matches are migrated or documented as no-op + external-boundary cases with concrete evidence. +- No P0/P1/P2 migration item remains in this audit. +- `cd ts && bun run check`, `cd ts && bun run build`, `cd ts && bun run test`, + and `git diff --check` pass after the final migration slice. diff --git a/ts/packages/base/src/__tests__/runtime-services.test.ts b/ts/packages/base/src/__tests__/runtime-services.test.ts index 49e6aaf0..98207779 100644 --- a/ts/packages/base/src/__tests__/runtime-services.test.ts +++ b/ts/packages/base/src/__tests__/runtime-services.test.ts @@ -115,11 +115,10 @@ const makeNativeRecordingProcessor = ( events.push(`pubsub:${pubsub.backend.constructor.name}`); }), }); - const stopEffect = processor.stopEffect; - processor.stopEffect = () => { + processor.onShutdown(() => { events.push("native-stop"); - return stopEffect(); - }; + return Promise.resolve(); + }); return processor; }; diff --git a/ts/packages/base/src/backend/nats.ts b/ts/packages/base/src/backend/nats.ts index ef85fec3..44ccce86 100644 --- a/ts/packages/base/src/backend/nats.ts +++ b/ts/packages/base/src/backend/nats.ts @@ -19,6 +19,8 @@ import { AckPolicy, DeliverPolicy, } from "nats"; +import { Effect } from "effect"; +import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; import type { @@ -29,6 +31,7 @@ import type { CreateConsumerOptions, Message, } from "./types.js"; +import { pubSubError } from "../errors.js"; const sc = StringCodec(); @@ -57,36 +60,61 @@ function makeNatsMessage(msg: JsMsg, decoded: T): NatsMessage { }; } +const hasJsMsg = Predicate.hasProperty("_jsMsg"); + +function isAckableJsMsg(value: unknown): value is Pick { + if (!Predicate.isObject(value)) return false; + if (!Predicate.hasProperty(value, "ack")) return false; + if (!Predicate.hasProperty(value, "nak")) return false; + return typeof value.ack === "function" && typeof value.nak === "function"; +} + +function isNatsMessage(message: Message): message is NatsMessage { + return hasJsMsg(message) && isAckableJsMsg(message._jsMsg); +} + function makeNatsProducer( js: JetStreamClient, subject: string, - schema?: S.Top, + schema?: S.Codec, ): BackendProducer { return { - send: async (message, properties) => { - const encoded = schema !== undefined - ? S.encodeUnknownSync(schema as S.Codec)(message) - : message; - const data = sc.encode(JSON.stringify(encoded)); - const opts: Record = {}; + 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: Record = {}; - if (properties !== undefined && Object.keys(properties).length > 0) { - const { headers } = await import("nats"); - const hdrs = headers(); - for (const [key, val] of Object.entries(properties)) { - hdrs.append(key, val); - } - opts.headers = hdrs; - } + if (properties !== undefined && Object.keys(properties).length > 0) { + const { headers } = yield* Effect.tryPromise({ + try: () => import("nats"), + catch: (error) => pubSubError("import:nats-headers", error), + }); + const hdrs = headers(); + for (const [key, val] of Object.entries(properties)) { + hdrs.append(key, val); + } + opts.headers = hdrs; + } - await js.publish(subject, data, opts); - }, - flush: async () => { - // NATS publishes are flushed on the connection level. - }, - close: async () => { - // No per-producer cleanup needed for NATS. - }, + 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(), + // No per-producer cleanup needed for NATS. + close: () => Promise.resolve(), }; } @@ -101,60 +129,109 @@ function makeNatsConsumer( subscription: string, initialPosition: "latest" | "earliest", streamName: string, - schema?: S.Top, + schema?: S.Codec, ): InitializableBackendConsumer { let consumer: NatsJsConsumer | null = null; return { - init: async () => { - // Stream is already ensured by makeNatsBackend(). Create or bind to a durable consumer. - try { - consumer = await js.consumers.get(streamName, subscription); - } catch { - const deliverPolicy = - initialPosition === "earliest" - ? DeliverPolicy.All - : DeliverPolicy.New; + init: () => + Effect.runPromise( + Effect.gen(function* () { + const existing = yield* Effect.tryPromise({ + try: () => js.consumers.get(streamName, subscription), + catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error), + }).pipe( + Effect.catch(() => + Effect.gen(function* () { + const deliverPolicy = + initialPosition === "earliest" + ? DeliverPolicy.All + : DeliverPolicy.New; - await jsm.consumers.add(streamName, { - durable_name: subscription, - ack_policy: AckPolicy.Explicit, - deliver_policy: deliverPolicy, - filter_subject: subject, - }); + 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), + }); - consumer = await js.consumers.get(streamName, subscription); - } - }, - receive: async (timeoutMs = 2000) => { - if (consumer === null) throw new Error("Consumer not initialized"); + return yield* Effect.tryPromise({ + try: () => js.consumers.get(streamName, subscription), + catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, 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 = await consumer.next({ expires: timeoutMs }); - if (msg === null) return null; + // 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 = JSON.parse(sc.decode(msg.data)); - const decoded = schema !== undefined - ? S.decodeUnknownSync(schema as S.Codec)(parsed) as T - : parsed as T; - return makeNatsMessage(msg, decoded); - }, - acknowledge: async (message) => { - const natsMsg = message as NatsMessage; - natsMsg._jsMsg.ack(); - }, - negativeAcknowledge: async (message) => { - const natsMsg = message as NatsMessage; - natsMsg._jsMsg.nak(); - }, - unsubscribe: async () => { + 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.sync(() => { + message._jsMsg.ack(); + }); + }), + ), + 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.sync(() => { + message._jsMsg.nak(); + }); + }), + ), + unsubscribe: () => { // The pull-based consumer does not have a persistent subscription to drain. // Clearing the reference is sufficient; the durable consumer persists server-side. consumer = null; + return Promise.resolve(); }, - close: async () => { + close: () => { consumer = null; + return Promise.resolve(); }, }; } @@ -165,19 +242,26 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend { let jsm: JetStreamManager | null = null; const initializedStreams = new Set(); - const ensureConnected = async (): Promise => { + const ensureConnected = Effect.fn("NatsBackend.ensureConnected")(function* () { if (connection === null) { - connection = await connect({ servers: url }); - js = connection.jetstream(); - jsm = await connection.jetstreamManager(); + const conn = yield* Effect.tryPromise({ + try: () => connect({ servers: url }), + catch: (error) => pubSubError("connect", error), + }); + connection = conn; + js = conn.jetstream(); + jsm = yield* Effect.tryPromise({ + try: () => conn.jetstreamManager(), + catch: (error) => pubSubError("jetstream-manager", error), + }); } - }; + }); /** * Ensure the stream for a given subject exists with a wildcard filter. * E.g. subject "tg.flow.config-request" → stream "tg_flow" with subjects ["tg.flow.>"] */ - const ensureStream = async (subject: string): Promise => { + const ensureStream = Effect.fn("NatsBackend.ensureStream")(function* (subject: string) { const parts = subject.split("."); const streamName = parts.slice(0, 2).join("_"); @@ -186,53 +270,78 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend { const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`; const manager = jsm; - if (manager === null) throw new Error("NATS backend not connected"); + if (manager === null) return yield* pubSubError("ensure-stream", "NATS backend not connected"); - try { - await manager.streams.info(streamName); - } catch { - await manager.streams.add({ - name: streamName, - subjects: [wildcardSubject], - }); - } + yield* Effect.tryPromise({ + try: () => manager.streams.info(streamName), + catch: (error) => pubSubError(`stream-info:${streamName}`, error), + }).pipe( + Effect.catch(() => + Effect.tryPromise({ + try: () => + manager.streams.add({ + name: streamName, + subjects: [wildcardSubject], + }), + catch: (error) => pubSubError(`stream-add:${streamName}`, error), + }), + ), + ); initializedStreams.add(streamName); return streamName; - }; + }); return { - createProducer: async (options: CreateProducerOptions) => { - await ensureConnected(); - await ensureStream(options.topic); - const client = js; - if (client === null) throw new Error("NATS backend not connected"); - return makeNatsProducer(client, options.topic, options.schema); - }, - createConsumer: async (options: CreateConsumerOptions) => { - await ensureConnected(); - const streamName = await ensureStream(options.topic); - const client = js; - const manager = jsm; - if (client === null || manager === null) throw new Error("NATS backend not connected"); - const consumer = makeNatsConsumer( - client, - manager, - options.topic, - options.subscription, - options.initialPosition ?? "latest", - streamName, - options.schema, - ); - await consumer.init(); - return consumer; - }, - close: async () => { - if (connection !== null) { - await connection.drain(); - connection = null; - js = null; - jsm = null; - } - }, + 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; + } + }), + ), }; } diff --git a/ts/packages/base/src/backend/pubsub.ts b/ts/packages/base/src/backend/pubsub.ts index d24f9de4..5ec4855a 100644 --- a/ts/packages/base/src/backend/pubsub.ts +++ b/ts/packages/base/src/backend/pubsub.ts @@ -20,10 +20,10 @@ import { pubSubError } from "../errors.js"; export interface PubSubService { readonly backend: PubSubBackend; readonly createProducer: ( - options: CreateProducerOptions, + options: CreateProducerOptions, ) => Effect.Effect, ReturnType>; readonly createConsumer: ( - options: CreateConsumerOptions, + options: CreateConsumerOptions, ) => Effect.Effect, ReturnType>; readonly close: Effect.Effect>; } @@ -41,12 +41,12 @@ export class PubSub extends Context.Service()("@trustgrap export function makePubSubService(backend: PubSubBackend): PubSubService { return { backend, - createProducer: (options: CreateProducerOptions) => + createProducer: (options: CreateProducerOptions) => Effect.tryPromise({ try: () => backend.createProducer(options), catch: (error) => pubSubError(`createProducer:${options.topic}`, error), }), - createConsumer: (options: CreateConsumerOptions) => + createConsumer: (options: CreateConsumerOptions) => Effect.tryPromise({ try: () => backend.createConsumer(options), catch: (error) => pubSubError(`createConsumer:${options.topic}`, error), diff --git a/ts/packages/base/src/backend/types.ts b/ts/packages/base/src/backend/types.ts index f68883b2..8f541b8a 100644 --- a/ts/packages/base/src/backend/types.ts +++ b/ts/packages/base/src/backend/types.ts @@ -29,21 +29,21 @@ export interface BackendConsumer { export type ConsumerType = "shared" | "exclusive" | "failover"; export type InitialPosition = "latest" | "earliest"; -export interface CreateProducerOptions { +export interface CreateProducerOptions { topic: string; - schema?: S.Top; + schema?: S.Codec; } -export interface CreateConsumerOptions { +export interface CreateConsumerOptions { topic: string; subscription: string; initialPosition?: InitialPosition; consumerType?: ConsumerType; - schema?: S.Top; + schema?: S.Codec; } export interface PubSubBackend { - createProducer(options: CreateProducerOptions): Promise>; - createConsumer(options: CreateConsumerOptions): Promise>; + createProducer(options: CreateProducerOptions): Promise>; + createConsumer(options: CreateConsumerOptions): Promise>; close(): Promise; } diff --git a/ts/packages/base/src/errors.ts b/ts/packages/base/src/errors.ts index 658be3de..5582fc8c 100644 --- a/ts/packages/base/src/errors.ts +++ b/ts/packages/base/src/errors.ts @@ -168,11 +168,11 @@ export type MessagingRuntimeError = | FlowResourceNotFoundError; export function tooManyRequestsError(message = "Rate limit exceeded"): TooManyRequestsError { - return new TooManyRequestsError({ message }); + return TooManyRequestsError.make({ message }); } export function llmError(message: string, errorType = "llm-error"): LlmError { - return new LlmError({ message, errorType }); + return LlmError.make({ message, errorType }); } export function embeddingsError( @@ -180,7 +180,7 @@ export function embeddingsError( error: unknown, provider?: string, ): EmbeddingsError { - return new EmbeddingsError({ + return EmbeddingsError.make({ operation, message: errorMessage(error), ...(provider === undefined ? {} : { provider }), @@ -188,11 +188,11 @@ export function embeddingsError( } export function parseError(message: string): ParseError { - return new ParseError({ message }); + return ParseError.make({ message }); } export function pubSubError(operation: string, error: unknown): PubSubError { - return new PubSubError({ operation, message: errorMessage(error) }); + return PubSubError.make({ operation, message: errorMessage(error) }); } export function processorLifecycleError( @@ -200,7 +200,7 @@ export function processorLifecycleError( operation: string, error: unknown, ): ProcessorLifecycleError { - return new ProcessorLifecycleError({ + return ProcessorLifecycleError.make({ processorId, operation, message: errorMessage(error), @@ -212,7 +212,7 @@ export function messagingLifecycleError( operation: string, error: unknown, ): MessagingLifecycleError { - return new MessagingLifecycleError({ + return MessagingLifecycleError.make({ resource, operation, message: errorMessage(error), @@ -224,7 +224,7 @@ export function messagingDeliveryError( operation: string, error: unknown, ): MessagingDeliveryError { - return new MessagingDeliveryError({ + return MessagingDeliveryError.make({ topic, operation, message: errorMessage(error), @@ -236,7 +236,7 @@ export function messagingDecodeError( error: unknown, topic?: string, ): MessagingDecodeError { - return new MessagingDecodeError({ + return MessagingDecodeError.make({ operation, message: errorMessage(error), ...(topic === undefined ? {} : { topic }), @@ -247,7 +247,7 @@ export function messagingTimeoutError( operation: string, timeoutMs: number, ): MessagingTimeoutError { - return new MessagingTimeoutError({ + return MessagingTimeoutError.make({ operation, timeoutMs, message: `${operation} timed out after ${timeoutMs}ms`, @@ -259,7 +259,7 @@ export function messagingHandlerError( subscription: string, error: unknown, ): MessagingHandlerError { - return new MessagingHandlerError({ + return MessagingHandlerError.make({ topic, subscription, message: errorMessage(error), @@ -271,7 +271,7 @@ export function flowRuntimeError( operation: string, error: unknown, ): FlowRuntimeError { - return new FlowRuntimeError({ + return FlowRuntimeError.make({ flowName, operation, message: errorMessage(error), @@ -283,7 +283,7 @@ export function flowResourceNotFoundError( resourceType: FlowResourceNotFoundError["resourceType"], resourceName: string, ): FlowResourceNotFoundError { - return new FlowResourceNotFoundError({ + return FlowResourceNotFoundError.make({ flowName, resourceType, resourceName, diff --git a/ts/packages/base/src/messaging/consumer.ts b/ts/packages/base/src/messaging/consumer.ts index 07e1e369..365fd00f 100644 --- a/ts/packages/base/src/messaging/consumer.ts +++ b/ts/packages/base/src/messaging/consumer.ts @@ -4,9 +4,16 @@ * Python reference: trustgraph-base/trustgraph/base/consumer.py */ -import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js"; +import type { BackendConsumer, Message, PubSubBackend } from "../backend/types.js"; import type { Flow } from "../processor/flow.js"; -import { TooManyRequestsError } from "../errors.js"; +import { + MessagingHandlerError, + TooManyRequestsError, + messagingDeliveryError, + messagingHandlerError, + messagingLifecycleError, +} from "../errors.js"; +import { Duration, Effect } from "effect"; import * as S from "effect/Schema"; export type MessageHandler = ( @@ -44,83 +51,140 @@ export interface Consumer { export function makeConsumer(options: ConsumerOptions): Consumer { let backend: BackendConsumer | null = null; let running = false; - let abortController = new AbortController(); + const isTooManyRequestsError = S.is(TooManyRequestsError); const concurrency = options.concurrency ?? 1; const rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000; - const handleWithRetry = async (msg: Message, flow: FlowContext): Promise => { - try { - await options.handler(msg.value(), msg.properties(), flow); - } catch (err) { - if (S.is(TooManyRequestsError)(err)) { - console.warn(`[Consumer] Rate limited, retrying in ${rateLimitRetryMs}ms`); - await sleep(rateLimitRetryMs); - await options.handler(msg.value(), msg.properties(), flow); - } else { - throw err; - } + const runHandler = ( + message: T, + properties: Record, + flow: FlowContext, + ): Effect.Effect => + Effect.tryPromise({ + try: () => options.handler(message, properties, flow), + catch: (error) => + isTooManyRequestsError(error) + ? error + : messagingHandlerError(options.topic, options.subscription, error), + }); + + const handleWithRetry = Effect.fn("Consumer.handleWithRetry")(function* ( + message: Message, + flow: FlowContext, + ) { + const callHandler = runHandler(message.value(), message.properties(), flow); + yield* callHandler.pipe( + Effect.catchTag("TooManyRequestsError", () => + Effect.logWarning("[Consumer] Rate limited, retrying", { + topic: options.topic, + subscription: options.subscription, + retryMs: rateLimitRetryMs, + }).pipe( + Effect.flatMap(() => Effect.sleep(Duration.millis(rateLimitRetryMs))), + Effect.flatMap(() => callHandler), + ), + ), + ); + }); + + const consumeOnce = Effect.fn("Consumer.consumeOnce")(function* (flow: FlowContext) { + const currentBackend = backend; + if (currentBackend === null) { + return yield* messagingLifecycleError( + `${options.topic}:${options.subscription}`, + "receive", + "Consumer backend not started", + ); } - }; - const consumeLoop = async (flow: FlowContext): Promise => { - while (running) { - let msg: Message | null = null; - try { - const currentBackend = backend; - if (currentBackend === null) throw new Error("Consumer backend not started"); + const message = yield* Effect.tryPromise({ + try: () => currentBackend.receive(2000), + catch: (error) => messagingDeliveryError(options.topic, "receive", error), + }); + if (message === null) return; - msg = await currentBackend.receive(2000); - if (msg === null) continue; + yield* handleWithRetry(message, flow).pipe( + Effect.flatMap(() => + Effect.tryPromise({ + try: () => currentBackend.acknowledge(message), + catch: (error) => messagingDeliveryError(options.topic, "acknowledge", error), + }), + ), + Effect.catch((error) => + Effect.tryPromise({ + try: () => currentBackend.negativeAcknowledge(message), + catch: (nakError) => messagingDeliveryError(options.topic, "negative-acknowledge", nakError), + }).pipe( + Effect.catch((nakError) => + Effect.logError("[Consumer] Failed to negative-acknowledge message", { + error: nakError.message, + topic: nakError.topic, + }), + ), + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + }); - await handleWithRetry(msg, flow); - await currentBackend.acknowledge(msg); - } catch (err) { - if (!running) break; - console.error("[Consumer] Error in consume loop:", err); - if (msg !== null) { - try { - const currentBackend = backend; - if (currentBackend !== null) { - await currentBackend.negativeAcknowledge(msg); - } - } catch (nakErr) { - console.error("[Consumer] Failed to nak message:", nakErr); - } - } - await sleep(1000); - } - } - }; + const consumeLoop = Effect.fn("Consumer.consumeLoop")(function* (flow: FlowContext) { + yield* Effect.whileLoop({ + while: () => running, + body: () => + consumeOnce(flow).pipe( + Effect.catch((error) => { + if (!running) return Effect.void; + return Effect.logError("[Consumer] Error in consume loop", { + error: error.message, + topic: options.topic, + subscription: options.subscription, + }).pipe( + Effect.flatMap(() => Effect.sleep(Duration.millis(1000))), + ); + }), + ), + step: () => undefined, + }); + }); return { - start: async (flow) => { - backend = await options.pubsub.createConsumer({ - topic: options.topic, - subscription: options.subscription, - initialPosition: options.initialPosition ?? "latest", - }); + start: (flow) => + Effect.runPromise( + Effect.gen(function* () { + backend = yield* Effect.tryPromise({ + try: () => + options.pubsub.createConsumer({ + topic: options.topic, + subscription: options.subscription, + initialPosition: options.initialPosition ?? "latest", + }), + catch: (error) => + messagingLifecycleError(`${options.topic}:${options.subscription}`, "create-consumer", error), + }); - running = true; + running = true; - // Spawn concurrent consumer tasks. - const tasks = Array.from({ length: concurrency }, () => - consumeLoop(flow), - ); - // Run all concurrently: first rejection stops all. - await Promise.all(tasks); - }, - stop: async () => { - running = false; - abortController.abort(); - abortController = new AbortController(); - if (backend !== null) { - await backend.close(); - backend = null; - } - }, + const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index); + yield* Effect.forEach(workerIndexes, () => consumeLoop(flow), { + concurrency: "unbounded", + discard: true, + }); + }), + ), + stop: () => + Effect.runPromise( + Effect.gen(function* () { + running = false; + const currentBackend = backend; + backend = null; + if (currentBackend !== null) { + yield* Effect.tryPromise({ + try: () => currentBackend.close(), + catch: (error) => + messagingLifecycleError(`${options.topic}:${options.subscription}`, "close-consumer", error), + }); + } + }), + ), }; } - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/ts/packages/base/src/messaging/producer.ts b/ts/packages/base/src/messaging/producer.ts index 2c7da1a7..2253c89e 100644 --- a/ts/packages/base/src/messaging/producer.ts +++ b/ts/packages/base/src/messaging/producer.ts @@ -8,6 +8,7 @@ import type { PubSubBackend } from "../backend/types.js"; import type { ProducerMetrics } from "../metrics/prometheus.js"; import { Effect } from "effect"; import { makeEffectProducerHandle, type EffectProducer } from "./runtime.js"; +import { messagingLifecycleError } from "../errors.js"; export interface Producer { readonly start: () => Promise; @@ -23,28 +24,38 @@ export function makeProducer( let effectProducer: EffectProducer | null = null; return { - start: async () => { - const backend = await pubsub.createProducer({ topic }); - effectProducer = makeEffectProducerHandle(backend, { - topic, - ...(metrics === undefined ? {} : { metrics }), - }); - }, - send: async (id, message) => { - if (effectProducer === null) throw new Error("Producer not started"); - - await Effect.runPromise(effectProducer.send(id, message)); - }, - stop: async () => { - if (effectProducer !== null) { - const producer = effectProducer; - await Effect.runPromise( - producer.flush.pipe( - Effect.flatMap(() => producer.close), - ), - ); - effectProducer = null; - } - }, + start: () => + Effect.runPromise( + Effect.gen(function* () { + const backend = yield* Effect.tryPromise({ + try: () => pubsub.createProducer({ topic }), + catch: (error) => messagingLifecycleError(topic, "create-producer", error), + }); + effectProducer = makeEffectProducerHandle(backend, { + topic, + ...(metrics === undefined ? {} : { metrics }), + }); + }), + ), + send: (id, message) => + effectProducer === null + ? Effect.runPromise(Effect.fail(messagingLifecycleError(topic, "send", "Producer not started"))) + : Effect.runPromise(effectProducer.send(id, message)), + stop: () => + Effect.runPromise( + Effect.gen(function* () { + if (effectProducer !== null) { + const producer = effectProducer; + yield* producer.flush.pipe( + Effect.flatMap(() => producer.close), + Effect.ensuring( + Effect.sync(() => { + effectProducer = null; + }), + ), + ); + } + }), + ), }; } diff --git a/ts/packages/base/src/messaging/request-response.ts b/ts/packages/base/src/messaging/request-response.ts index 92906683..0c263716 100644 --- a/ts/packages/base/src/messaging/request-response.ts +++ b/ts/packages/base/src/messaging/request-response.ts @@ -44,37 +44,42 @@ export function makeRequestResponse( let runtime: RequestResponseRuntime | null = null; return { - start: async () => { - if (runtime !== null) 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)); - const scope = await Effect.runPromise(Scope.make()); + runtime = { scope, requestor }; + }); - try { - const config = await Effect.runPromise(loadMessagingRuntimeConfig()); - const requestor = await Effect.runPromise( - makeEffectRequestResponseFromPubSub( - PubSub.fromBackend(options.pubsub), - config, - { - requestTopic: options.requestTopic, - responseTopic: options.responseTopic, - subscription: options.subscription, - }, - ).pipe(Scope.provide(scope)), - ); - - runtime = { scope, requestor }; - } catch (error) { - await Effect.runPromise(Scope.close(scope, Exit.fail(error))).catch(() => undefined); - throw error; - } - }, - stop: async () => { + yield* startRuntime.pipe( + Effect.catch((error) => + Scope.close(scope, Exit.fail(error)).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + }), + ), + stop: () => { const current = runtime; runtime = null; - if (current === null) return; - - await Effect.runPromise(Scope.close(current.scope, Exit.void)); + return current === null + ? Promise.resolve() + : Effect.runPromise(Scope.close(current.scope, Exit.void)); }, /** * Send a request and wait for responses. @@ -85,20 +90,24 @@ export function makeRequestResponse( * Return `true` to indicate the final response has been received. * If omitted, returns the first response. */ - request: async (request, requestOptions) => { + request: (request, requestOptions) => { const current = runtime; if (current === null) { - throw messagingLifecycleError( - `${options.requestTopic}:${options.responseTopic}`, - "request", - "RequestResponse not started", + return Effect.runPromise( + Effect.fail( + messagingLifecycleError( + `${options.requestTopic}:${options.responseTopic}`, + "request", + "RequestResponse not started", + ), + ), ); } const timeoutMs = requestOptions?.timeoutMs ?? 300_000; const recipient = requestOptions?.recipient; - return await Effect.runPromise( + return Effect.runPromise( current.requestor.request(request, { timeoutMs, ...(recipient === undefined diff --git a/ts/packages/base/src/messaging/runtime.ts b/ts/packages/base/src/messaging/runtime.ts index 5c85378f..ea2bb090 100644 --- a/ts/packages/base/src/messaging/runtime.ts +++ b/ts/packages/base/src/messaging/runtime.ts @@ -44,9 +44,9 @@ export type EffectMessageHandler = ( flow: FlowContext, ) => Effect.Effect; -export interface EffectProducerOptions { +export interface EffectProducerOptions { readonly topic: string; - readonly schema?: S.Top; + readonly schema?: S.Codec; readonly metrics?: ProducerMetrics; } @@ -62,7 +62,7 @@ export interface EffectConsumerOptions { readonly handler: EffectMessageHandler; readonly concurrency?: number; readonly initialPosition?: "latest" | "earliest"; - readonly schema?: S.Top; + readonly schema?: S.Codec; readonly receiveTimeoutMs?: number; readonly errorBackoffMs?: number; readonly rateLimitRetryMs?: number; @@ -73,12 +73,12 @@ export interface EffectConsumer { readonly fibers: ReadonlyArray>; } -export interface EffectRequestResponseOptions { +export interface EffectRequestResponseOptions { readonly requestTopic: string; readonly responseTopic: string; readonly subscription: string; - readonly requestSchema?: S.Top; - readonly responseSchema?: S.Top; + readonly requestSchema?: S.Codec; + readonly responseSchema?: S.Codec; } export interface EffectRequestOptions { @@ -96,7 +96,7 @@ export interface EffectRequestResponse { export interface ProducerFactoryService { readonly make: ( - options: EffectProducerOptions, + options: EffectProducerOptions, ) => Effect.Effect, PubSubError, Scope.Scope>; } @@ -109,7 +109,7 @@ export interface ConsumerFactoryService { export interface RequestResponseFactoryService { readonly make: ( - options: EffectRequestResponseOptions, + options: EffectRequestResponseOptions, ) => Effect.Effect, PubSubError, Scope.Scope>; } @@ -138,7 +138,7 @@ export class FlowRuntime extends Context.Service( backend: BackendProducer, - options: EffectProducerOptions, + options: EffectProducerOptions, ): EffectProducer { return { send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) => @@ -168,9 +168,9 @@ export function makeEffectProducerHandle( export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* ( pubsub: PubSubService, - options: EffectProducerOptions, + options: EffectProducerOptions, ) { - const createOptions: CreateProducerOptions = options.schema === undefined + const createOptions: CreateProducerOptions = options.schema === undefined ? { topic: options.topic } : { topic: options.topic, schema: options.schema }; const backend = yield* pubsub.createProducer(createOptions); @@ -326,7 +326,7 @@ export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPub options: EffectConsumerOptions, flow: FlowContext, ) { - const createOptions: CreateConsumerOptions = { + const createOptions: CreateConsumerOptions = { topic: options.topic, subscription: options.subscription, ...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }), @@ -422,9 +422,9 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR >( pubsub: PubSubService, config: MessagingRuntimeConfig, - options: EffectRequestResponseOptions, + options: EffectRequestResponseOptions, ) { - const producerOptions: CreateProducerOptions = options.requestSchema === undefined + const producerOptions: CreateProducerOptions = options.requestSchema === undefined ? { topic: options.requestTopic } : { topic: options.requestTopic, schema: options.requestSchema }; const producerBackend = yield* pubsub.createProducer(producerOptions); @@ -432,7 +432,7 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR topic: options.requestTopic, ...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }), }); - const createOptions: CreateConsumerOptions = { + const createOptions: CreateConsumerOptions = { topic: options.responseTopic, subscription: options.subscription, ...(options.responseSchema === undefined ? {} : { schema: options.responseSchema }), @@ -502,7 +502,7 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR export function makeProducerFactoryService(pubsub: PubSubService): ProducerFactoryService { return { - make: Effect.fn("ProducerFactory.make")((options: EffectProducerOptions) => + make: Effect.fn("ProducerFactory.make")((options: EffectProducerOptions) => makeEffectProducerFromPubSub(pubsub, options), ), }; @@ -526,13 +526,11 @@ export function makeRequestResponseFactoryService( pubsub: PubSubService, config: MessagingRuntimeConfig, ): RequestResponseFactoryService { - const make = Effect.fn("RequestResponseFactory.make")(function* ( - options: EffectRequestResponseOptions, - ) { - return yield* makeEffectRequestResponseFromPubSub(pubsub, config, options); - }) as RequestResponseFactoryService["make"]; - - return { make }; + return { + make: Effect.fn("RequestResponseFactory.make")(( + options: EffectRequestResponseOptions, + ) => makeEffectRequestResponseFromPubSub(pubsub, config, options)), + }; } export const ProducerFactoryLive = Layer.effect( @@ -589,7 +587,7 @@ export const MessagingRuntimeLive = Layer.mergeAll( ); export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* ( - options: EffectProducerOptions, + options: EffectProducerOptions, ) { const pubsub = yield* PubSub; return yield* makeEffectProducerFromPubSub(pubsub, options); @@ -605,7 +603,7 @@ export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(func }); export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* ( - options: EffectRequestResponseOptions, + options: EffectRequestResponseOptions, ) { const pubsub = yield* PubSub; const config = yield* loadMessagingRuntimeConfig(); diff --git a/ts/packages/base/src/messaging/subscriber.ts b/ts/packages/base/src/messaging/subscriber.ts index f15c4a5e..8287d0ff 100644 --- a/ts/packages/base/src/messaging/subscriber.ts +++ b/ts/packages/base/src/messaging/subscriber.ts @@ -5,6 +5,8 @@ */ import type { PubSubBackend, BackendConsumer } from "../backend/types.js"; +import { Duration, Effect, Fiber } from "effect"; +import { messagingDeliveryError, messagingLifecycleError, messagingTimeoutError } from "../errors.js"; type Resolver = { queue: AsyncQueue; @@ -32,28 +34,33 @@ export function makeAsyncQueue(): AsyncQueue { buffer.push(item); } }, - pop: async (timeoutMs) => { + pop: (timeoutMs) => { const buffered = buffer.shift(); - if (buffered !== undefined) return buffered; - - return new Promise((resolve, reject) => { - let timer: ReturnType | undefined; + if (buffered !== undefined) return Promise.resolve(buffered); + const take = Effect.callback((resume) => { const waiter = (value: T) => { - if (timer !== undefined) clearTimeout(timer); - resolve(value); + resume(Effect.succeed(value)); }; waiters.push(waiter); - if (timeoutMs !== undefined) { - timer = setTimeout(() => { - const idx = waiters.indexOf(waiter); - if (idx !== -1) waiters.splice(idx, 1); - reject(new Error(`Queue.pop timed out after ${timeoutMs}ms`)); - }, timeoutMs); - } + return Effect.sync(() => { + const idx = waiters.indexOf(waiter); + if (idx !== -1) waiters.splice(idx, 1); + }); }); + + return Effect.runPromise( + timeoutMs === undefined + ? take + : take.pipe( + Effect.timeout(Duration.millis(timeoutMs)), + Effect.catchTag("TimeoutError", () => + Effect.fail(messagingTimeoutError("queue.pop", timeoutMs)), + ), + ), + ); }, get length() { return buffer.length; @@ -77,76 +84,113 @@ export function makeSubscriber( ): Subscriber { let backend: BackendConsumer | null = null; let running = false; + let fiber: Fiber.Fiber | null = null; // ID-specific subscriptions (request/response correlation) const idSubscribers = new Map>(); // Wildcard subscribers (receive all messages) const allSubscribers = new Map>(); - const dispatchLoop = async (): Promise => { + const dispatchLoop = Effect.fn("Subscriber.dispatchLoop")(function* () { let consecutiveErrors = 0; - while (running) { - try { - const currentBackend = backend; - if (currentBackend === null) throw new Error("Subscriber backend not started"); + const dispatchOnce = Effect.fn("Subscriber.dispatchOnce")(function* () { + const currentBackend = backend; + if (currentBackend === null) { + return yield* messagingLifecycleError( + `${topic}:${subscription}`, + "dispatch", + "Subscriber backend not started", + ); + } - const msg = await currentBackend.receive(2000); - if (msg === null) continue; + const msg = yield* Effect.tryPromise({ + try: () => currentBackend.receive(2000), + catch: (error) => messagingDeliveryError(topic, "receive", error), + }); + if (msg === null) return; - consecutiveErrors = 0; + consecutiveErrors = 0; - const props = msg.properties(); - const id = props.id; - const value = msg.value(); + const props = msg.properties(); + const id = props.id; + const value = msg.value(); - // Route to ID-specific subscriber - if (id !== undefined && id.length > 0) { - const sub = idSubscribers.get(id); - if (sub !== undefined) { + // Route to ID-specific subscriber + if (id !== undefined && id.length > 0) { + const sub = idSubscribers.get(id); + if (sub !== undefined) { + sub.queue.push(value); + } + } + + // Broadcast to all-subscribers + for (const sub of allSubscribers.values()) { sub.queue.push(value); } - } - // Broadcast to all-subscribers - for (const sub of allSubscribers.values()) { - sub.queue.push(value); - } + yield* Effect.tryPromise({ + try: () => currentBackend.acknowledge(msg), + catch: (error) => messagingDeliveryError(topic, "acknowledge", error), + }); + }); - await currentBackend.acknowledge(msg); - } catch (err) { - if (!running) break; - consecutiveErrors++; - if (consecutiveErrors <= 3) { - console.error("[Subscriber] Error:", err); - } else if (consecutiveErrors === 4) { - console.error("[Subscriber] Suppressing further errors (will retry with backoff)"); - } - // Exponential backoff: 1s, 2s, 4s, max 10s - const delay = Math.min(1000 * Math.pow(2, consecutiveErrors - 1), 10_000); - await new Promise((r) => setTimeout(r, delay)); - } - } - }; + yield* Effect.whileLoop({ + while: () => running, + body: () => + dispatchOnce().pipe( + Effect.catch((error) => { + if (!running) return Effect.void; + consecutiveErrors++; + const logEffect = consecutiveErrors <= 3 + ? Effect.logError("[Subscriber] Error", { error }) + : consecutiveErrors === 4 + ? Effect.logError("[Subscriber] Suppressing further errors (will retry with backoff)", { error }) + : Effect.void; + const delay = Math.min(1000 * 2 ** (consecutiveErrors - 1), 10_000); + return logEffect.pipe(Effect.flatMap(() => Effect.sleep(Duration.millis(delay)))); + }), + ), + step: () => undefined, + }); + }); return { - start: async () => { - backend = await pubsub.createConsumer({ - topic, - subscription, - }); - running = true; - // Start the dispatch loop (fire and forget; runs until stop). - dispatchLoop().catch((err) => { - if (running === true) console.error("[Subscriber] dispatch loop error:", err); - }); - }, - stop: async () => { - running = false; - if (backend !== null) { - await backend.close(); - backend = null; - } - }, + start: () => + Effect.runPromise( + Effect.gen(function* () { + backend = yield* Effect.tryPromise({ + try: () => + pubsub.createConsumer({ + topic, + subscription, + }), + catch: (error) => + messagingLifecycleError(`${topic}:${subscription}`, "create-consumer", error), + }); + running = true; + fiber = yield* dispatchLoop().pipe(Effect.forkDetach); + }), + ), + stop: () => + Effect.runPromise( + Effect.gen(function* () { + running = false; + const activeFiber = fiber; + fiber = null; + if (activeFiber !== null) { + yield* Fiber.interrupt(activeFiber); + } + const currentBackend = backend; + if (currentBackend !== null) { + backend = null; + yield* Effect.tryPromise({ + try: () => currentBackend.close(), + catch: (error) => + messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error), + }); + } + }), + ), subscribe: (id) => { const queue = makeAsyncQueue(); idSubscribers.set(id, { queue }); diff --git a/ts/packages/base/src/processor/async-processor.ts b/ts/packages/base/src/processor/async-processor.ts index 0b7adfb1..4b811eb6 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 { Effect } from "effect"; +import { Context, Effect } from "effect"; import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js"; import { loadProcessorRuntimeConfig } from "../runtime/config.js"; @@ -36,10 +36,10 @@ declare const processorRunRequirementsType: unique symbol; export interface ProcessorRuntime { readonly [processorRunErrorType]?: RunError; readonly [processorRunRequirementsType]?: RunRequirements; - readonly start: () => Promise; + readonly start: (context: Context.Context) => Promise; readonly stop: () => Promise; - startEffect(): unknown; - stopEffect(): unknown; + startEffect: Effect.Effect; + stopEffect: Effect.Effect; } export interface AsyncProcessorRuntime< @@ -53,8 +53,8 @@ export interface AsyncProcessorRuntime< readonly isRunning: () => boolean; readonly registerConfigHandler: (handler: ConfigHandler) => void; readonly onShutdown: (callback: () => Promise) => void; - readonly run: () => Promise; - runEffect(): unknown; + readonly run: (context: Context.Context) => Promise; + runEffect: Effect.Effect; } export interface AsyncProcessorRuntimeOptions< @@ -94,8 +94,16 @@ export function makeAsyncProcessor< } const shutdown = () => { - console.log(`[${config.id}] Shutting down...`); - void processor.stop().then(() => process.exit(0)); + void Effect.runPromise( + Effect.log(`[${config.id}] Shutting down...`).pipe( + Effect.flatMap(() => + Effect.tryPromise({ + try: () => processor.stop(), + catch: (error) => processorLifecycleError(config.id, "signal-shutdown", error), + }), + ), + ), + ).then(() => process.exit(0), () => process.exit(1)); }; const handlers: RegisteredSignalHandler[] = [ { signal: "SIGINT", handler: shutdown }, @@ -125,29 +133,19 @@ export function makeAsyncProcessor< registerConfigHandler: (handler) => { configHandlers.push(handler); }, - start: async () => { - await Effect.runPromise( - processor.startEffect() as Effect.Effect, - ); - }, - stop: async () => { - await Effect.runPromise( - processor.stopEffect() as Effect.Effect, - ); - }, + start: (context) => Effect.runPromiseWith(context)(processor.startEffect), + stop: () => Effect.runPromise(processor.stopEffect), onShutdown: (callback) => { shutdownCallbacks.push(callback); }, - startEffect() { + get startEffect() { const startProcessor = Effect.fn("trustgraph.processor.start")(function* () { yield* Effect.sync(() => { running = true; registerProcessSignalHandlers(); }); - yield* ( - processor.runEffect() as Effect.Effect - ); + yield* processor.runEffect; }); return startProcessor().pipe( Effect.withSpan("trustgraph.processor.start", { @@ -157,7 +155,7 @@ export function makeAsyncProcessor< }), ); }, - stopEffect() { + get stopEffect() { const stopProcessor = Effect.fn("trustgraph.processor.stop")(function* () { yield* Effect.sync(() => { running = false; @@ -180,18 +178,15 @@ export function makeAsyncProcessor< }); return stopProcessor(); }, - run: () => - Effect.runPromise( - processor.runEffect() as unknown as Effect.Effect, - ), - runEffect: () => { + run: (context) => Effect.runPromiseWith(context)(processor.runEffect), + 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), - }) as unknown as Effect.Effect; + }); }, }; @@ -208,13 +203,21 @@ export const AsyncProcessor = Object.assign( return makeAsyncProcessor(config); }, { - async launch>( + launch>( this: new (config: ProcessorConfig) => T, id: string, ): Promise { - const config = await Effect.runPromise(loadProcessorRuntimeConfig(id)); - const processor = new this(config); - await processor.start(); + const ProcessorCtor = this; + return Effect.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), + }); + }), + ); }, }, ) as unknown as { @@ -224,7 +227,7 @@ export const AsyncProcessor = Object.assign( ( config: ProcessorConfig, ): AsyncProcessor; - launch>( + launch>( this: new (config: ProcessorConfig) => T, id: string, ): Promise; diff --git a/ts/packages/base/src/processor/flow-processor.ts b/ts/packages/base/src/processor/flow-processor.ts index 1a28103a..15cadadf 100644 --- a/ts/packages/base/src/processor/flow-processor.ts +++ b/ts/packages/base/src/processor/flow-processor.ts @@ -38,6 +38,7 @@ import { import { makePubSubService, PubSub } from "../backend/pubsub.js"; import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js"; import { Duration, Effect, Exit, Scope } from "effect"; +import * as Predicate from "effect/Predicate"; import * as S from "effect/Schema"; interface ConfigPush { @@ -88,9 +89,7 @@ export interface FlowProcessorRuntime readonly configHandlers: ConfigHandler[]; readonly isRunning: () => boolean; readonly registerConfigHandler: (handler: ConfigHandler) => void; - readonly registerSpecification: ( - spec: Spec, - ) => void; + readonly registerSpecification: (spec: Spec) => void; readonly specifications: ReadonlyArray>; } @@ -106,6 +105,20 @@ const ConfigPushSchema = S.Struct({ config: S.Record(S.String, S.Unknown), }); +const isStringRecord = (value: unknown): value is Record => + Predicate.isObject(value) && !Array.isArray(value); + +const isTopicsRecord = (value: unknown): value is Record => + isStringRecord(value) && Object.values(value).every((item) => typeof item === "string"); + +const isFlowDefinition = (value: unknown): value is FlowDefinition => { + if (!isStringRecord(value)) return false; + const topics = value.topics; + const parameters = value.parameters; + return (topics === undefined || isTopicsRecord(topics)) && + (parameters === undefined || isStringRecord(parameters)); +}; + export function runFlowProcessorDefinitionScoped< FlowRequirements = never, ConfigHandlerError = never, @@ -202,11 +215,15 @@ export function runFlowProcessorDefinitionScoped< FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements > => Effect.gen(function* () { - const flowDefs = config.flows as Record | undefined; + const flowDefs = config.flows; if (flowDefs === undefined) { yield* Effect.log(`[${options.id}] No flows in config push, skipping`); return; } + if (!isStringRecord(flowDefs)) { + yield* Effect.logWarning(`[${options.id}] Skipping config push: flows is not an object`); + return; + } const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe( Effect.catch((error) => Effect.succeed(String(error))), @@ -226,7 +243,7 @@ export function runFlowProcessorDefinitionScoped< } for (const [name, defn] of Object.entries(flowDefs)) { - if (typeof defn !== "object" || defn === null) { + if (!isFlowDefinition(defn)) { yield* Effect.logWarning(`[${options.id}] Skipping flow "${name}": definition is not an object`); continue; } @@ -353,8 +370,8 @@ export function makeFlowProcessor( }, }); - const startEffect = (): FlowProcessorStartEffect => { - const effect = base.startEffect() as FlowProcessorStartEffect; + const makeStartEffect = (): FlowProcessorStartEffect => { + const effect = base.startEffect; return options.provide?.(effect) ?? effect; }; @@ -362,24 +379,29 @@ export function makeFlowProcessor( ...base, specifications, registerSpecification: (spec) => { - specifications.push(spec as Spec); + specifications.push(spec); }, - startEffect, - start: async () => { - const pubsub = makePubSubService(base.pubsub); - const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig()); - const start = startEffect().pipe( - Effect.provideService(PubSub, pubsub), - Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))), - Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))), - Effect.provideService( - RequestResponseFactory, - RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)), - ), - Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })), - ) as Effect.Effect; - await Effect.runPromise(Effect.scoped(start)); + get startEffect() { + return makeStartEffect(); }, + start: (context) => + Effect.runPromiseWith(context)( + Effect.gen(function* () { + const pubsub = makePubSubService(base.pubsub); + const messagingConfig = yield* loadMessagingRuntimeConfig(); + const start = processor.startEffect.pipe( + Effect.provideService(PubSub, pubsub), + Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))), + Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))), + Effect.provideService( + RequestResponseFactory, + RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)), + ), + Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })), + ); + yield* Effect.scoped(start); + }), + ), }; return processor; diff --git a/ts/packages/base/src/processor/flow.ts b/ts/packages/base/src/processor/flow.ts index db11c93f..e8d5bb55 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 { Effect, Exit, Scope } from "effect"; +import { Context, Effect, Exit, Scope } from "effect"; import type { PubSubBackend } from "../backend/types.js"; import { makePubSubService } from "../backend/pubsub.js"; import { @@ -64,19 +64,20 @@ export function makeFlow( definition: FlowDefinition, specifications: ReadonlyArray>, ) { - const producers = new Map>(); + const producers = new Map>(); const consumers = new Map(); - const requestors = new Map>(); + const requestors = new Map>(); const parameters = new Map(); let compatibilityScope: Scope.Closeable | null = null; - const ensureCompatibilityScope = async (): Promise => { + const ensureCompatibilityScopeEffect = Effect.fn("Flow.ensureCompatibilityScope")(function* () { if (compatibilityScope !== null) { return compatibilityScope; } - compatibilityScope = await Effect.runPromise(Scope.make()); - return compatibilityScope; - }; + const scope = yield* Scope.make(); + compatibilityScope = scope; + return scope; + }); const toEffectRequestOptions = ( options: FlowRequestOptions | undefined, @@ -105,41 +106,58 @@ export function makeFlow( } }); }, - async start(): Promise { - if (compatibilityScope !== null) { - await flow.stop(); - } - await flow.runInCompatibilityScope( - flow.startEffect() as Effect.Effect, - pubsub, + start(context: Context.Context): Promise { + return Effect.runPromise( + Effect.gen(function* () { + if (compatibilityScope !== null) { + yield* flow.stopEffect(); + } + yield* flow.runInCompatibilityScopeEffect(flow.startEffect(), pubsub, context); + }), ); }, - async stop(): Promise { - const scope = compatibilityScope; - compatibilityScope = null; - if (scope !== null) { - await Effect.runPromise(Scope.close(scope, Exit.void)); - } - flow.clearResources(); + stop(): Promise { + return Effect.runPromise(flow.stopEffect()); }, - async runInCompatibilityScope( - effect: Effect.Effect, + stopEffect(): Effect.Effect { + return Effect.gen(function* () { + const scope = compatibilityScope; + compatibilityScope = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void); + } + flow.clearResources(); + }); + }, + runInCompatibilityScopeEffect( + effect: Effect.Effect, runtimePubsub: PubSubBackend, - ): Promise { - const scope = await ensureCompatibilityScope(); - const pubsubService = makePubSubService(runtimePubsub); - const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig()); - return await Effect.runPromise( - effect.pipe( - Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))), - Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))), - Effect.provideService( - RequestResponseFactory, - RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)), + context: Context.Context, + ) { + return Effect.gen(function* () { + const scope = yield* ensureCompatibilityScopeEffect(); + const pubsubService = makePubSubService(runtimePubsub); + const messagingConfig = yield* loadMessagingRuntimeConfig(); + return yield* Effect.provide( + effect.pipe( + Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))), + Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))), + Effect.provideService( + RequestResponseFactory, + RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)), + ), + Scope.provide(scope), ), - Scope.provide(scope), - ), - ); + context, + ); + }); + }, + runInCompatibilityScope( + effect: Effect.Effect, + runtimePubsub: PubSubBackend, + context: Context.Context, + ): Promise { + return Effect.runPromise(flow.runInCompatibilityScopeEffect(effect, runtimePubsub, context)); }, clearResources(): void { producers.clear(); @@ -147,13 +165,13 @@ export function makeFlow( requestors.clear(); parameters.clear(); }, - registerProducer(registerName: string, producer: EffectProducer): void { + registerProducer(registerName: string, producer: EffectProducer): void { producers.set(registerName, producer); }, registerConsumer(registerName: string, consumer: EffectConsumer): void { consumers.set(registerName, consumer); }, - registerRequestor(registerName: string, rr: EffectRequestResponse): void { + registerRequestor(registerName: string, rr: EffectRequestResponse): void { requestors.set(registerName, rr); }, setParameter(parameterName: string, value: unknown): void { diff --git a/ts/packages/base/src/processor/program.ts b/ts/packages/base/src/processor/program.ts index 61565cdd..83bbe351 100644 --- a/ts/packages/base/src/processor/program.ts +++ b/ts/packages/base/src/processor/program.ts @@ -5,7 +5,7 @@ * executable path while the processor internals remain Promise-based. */ -import { Config as EffectConfig, Effect, Layer, Scope } from "effect"; +import { Config as EffectConfig, Effect, Layer } from "effect"; import { processorLifecycleError, type FlowRuntimeError, @@ -37,18 +37,16 @@ import type { import { runFlowProcessorDefinitionScoped } from "./flow-processor.js"; import type { Spec } from "../spec/types.js"; -type ProcessorRunError = Processor extends ProcessorRuntime ? Error : never; -type ProcessorRunRequirements = Processor extends ProcessorRuntime ? Requirements : never; - export interface ProcessorProgramOptions< Config extends ProcessorConfig, - Error, - Requirements, - Processor extends ProcessorRuntime, + LoadError, + LoadRequirements, + RunError, + RunRequirements, > { readonly id: string; - readonly make: (config: Config) => Processor; - readonly loadConfig?: Effect.Effect; + readonly make: (config: Config) => ProcessorRuntime; + readonly loadConfig?: Effect.Effect; } export interface FlowProcessorProgramOptions< @@ -68,18 +66,14 @@ export interface FlowProcessorProgramOptions< ) => Layer.Layer; } -export function runProcessorScoped< +export const runProcessorScoped = Effect.fn("runProcessorScoped")(function* < Config extends ProcessorConfig, - Processor extends ProcessorRuntime, + RunError, + RunRequirements, >( config: Config, - make: (config: Config) => Processor, -): Effect.Effect< - void, - ProcessorRunError | ProcessorLifecycleError, - PubSub | Scope.Scope | ProcessorRunRequirements -> { - return Effect.gen(function* () { + make: (config: Config) => ProcessorRuntime, +) { const pubsub = yield* PubSub; const runtimeConfig = { ...config, @@ -103,23 +97,17 @@ export function runProcessorScoped< ), ); - yield* ( - processor.startEffect() as Effect.Effect< - void, - ProcessorRunError | ProcessorLifecycleError, - ProcessorRunRequirements - > - ); - }); -} + yield* processor.startEffect; +}); export function makeProcessorProgram< Config extends ProcessorConfig, - Error = never, - Requirements = never, - Processor extends ProcessorRuntime = ProcessorRuntime, + LoadError = never, + LoadRequirements = never, + RunError = ProcessorLifecycleError, + RunRequirements = never, >( - options: ProcessorProgramOptions, + options: ProcessorProgramOptions, ) { return Effect.scoped( Effect.gen(function* () { @@ -147,7 +135,7 @@ export function makeProcessorProgram< ), ), ); - const processorEffect = runProcessorScoped( + const processorEffect = runProcessorScoped( runtimeConfig, options.make, ); @@ -173,12 +161,37 @@ export function makeFlowProcessorProgram< FlowRequirements = never, LayerRequirements = never, >( - options: FlowProcessorProgramOptions, + options: FlowProcessorProgramOptions & { + readonly layer: (config: Config) => Layer.Layer; + }, ): Effect.Effect< - void, + never, Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError, LayerRequirements -> { +>; + +export function makeFlowProcessorProgram< + Config extends ProcessorConfig, + Error = never, + FlowRequirements = never, +>( + options: FlowProcessorProgramOptions & { + readonly layer?: undefined; + }, +): Effect.Effect< + never, + Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError, + FlowRequirements +>; + +export function makeFlowProcessorProgram< + Config extends ProcessorConfig, + Error = never, + FlowRequirements = never, + LayerRequirements = never, +>( + options: FlowProcessorProgramOptions, +) { return Effect.scoped( Effect.gen(function* () { const config = yield* ( @@ -226,14 +239,16 @@ export function makeFlowProcessorProgram< ), Layer.succeed(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })), ); - const dependencyLayer = options.layer?.(runtimeConfig) ?? - (Layer.empty as unknown as Layer.Layer); - const providedProcessorLayer = processorLayer.pipe( - Layer.provide(dependencyLayer), - Layer.provide(runtimeLayer), - ); + if (options.layer !== undefined) { + return yield* Layer.launch( + processorLayer.pipe( + Layer.provide(options.layer(runtimeConfig)), + Layer.provide(runtimeLayer), + ), + ); + } - return yield* Layer.launch(providedProcessorLayer); + return yield* Layer.launch(processorLayer.pipe(Layer.provide(runtimeLayer))); }), ); } diff --git a/ts/packages/base/src/services/llm-service.ts b/ts/packages/base/src/services/llm-service.ts index 9de4f820..555c0159 100644 --- a/ts/packages/base/src/services/llm-service.ts +++ b/ts/packages/base/src/services/llm-service.ts @@ -4,7 +4,7 @@ * Python reference: trustgraph-base/trustgraph/base/llm_service.py */ -import { Context, Effect } from "effect"; +import { Context, Effect, Stream } from "effect"; import * as S from "effect/Schema"; import { errorMessage, @@ -69,7 +69,7 @@ export class Llm extends Context.Service()( ) {} const llmServiceError = (operation: string, cause: unknown) => - new LlmServiceError({ + LlmServiceError.make({ operation, message: errorMessage(cause), }); @@ -135,22 +135,19 @@ const sendStreamingResponse = Effect.fn("LlmService.sendStreamingResponse")(func ) => Effect.Effect; }, ) { - const context = yield* Effect.context(); - yield* Effect.tryPromise({ - try: async () => { - for await (const chunk of llm.generateContentStream( - msg.system, - msg.prompt, - msg.model, - msg.temperature, - )) { - await Effect.runPromiseWith(context)( - responseProducer.send(requestId, chunkToResponse(chunk)), - ); - } - }, - catch: (cause) => llmServiceError("generate-content-stream", cause), - }); + yield* Stream.fromAsyncIterable( + llm.generateContentStream( + msg.system, + msg.prompt, + msg.model, + msg.temperature, + ), + (cause) => llmServiceError("generate-content-stream", cause), + ).pipe( + Stream.runForEach((chunk) => + responseProducer.send(requestId, chunkToResponse(chunk)), + ), + ); }); const onLlmRequest = Effect.fn("LlmService.onRequest")(function* ( @@ -168,16 +165,22 @@ const onLlmRequest = Effect.fn("LlmService.onRequest")(function* ( if (msg.streaming === true && llm.supportsStreaming()) { yield* sendStreamingResponse(llm, requestId, msg, responseProducer).pipe( - Effect.catch((error) => - Effect.logError("[LlmService] Error processing streaming request", { - error: error.message, - operation: error.operation, - }).pipe( - Effect.flatMap(() => - responseProducer.send(requestId, llmErrorResponse(error)), + Effect.catchTags({ + LlmServiceError: (error) => + Effect.logError("[LlmService] Error processing streaming request", { + error: error.message, + operation: error.operation, + }).pipe( + Effect.flatMap(() => + responseProducer.send(requestId, llmErrorResponse(error)), + ), ), - ), - ), + MessagingDeliveryError: (error) => + Effect.logError("[LlmService] Error sending streaming response", { + error: error.message, + operation: error.operation, + }), + }), ); return; } diff --git a/ts/packages/base/src/spec/consumer-spec.ts b/ts/packages/base/src/spec/consumer-spec.ts index 2f9473ad..27bab751 100644 --- a/ts/packages/base/src/spec/consumer-spec.ts +++ b/ts/packages/base/src/spec/consumer-spec.ts @@ -62,14 +62,8 @@ export function makeConsumerSpec( return { name, addEffect, - add: async (flow, pubsub, definition) => { - const effect = addEffect(flow as Flow, definition) as Effect.Effect< - void, - PubSubError, - SpecRuntimeRequirements - >; - await flow.runInCompatibilityScope(effect, pubsub); - }, + add: (flow, pubsub, definition, context) => + flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context), }; } diff --git a/ts/packages/base/src/spec/parameter-spec.ts b/ts/packages/base/src/spec/parameter-spec.ts index 9b20a152..c09bc9b2 100644 --- a/ts/packages/base/src/spec/parameter-spec.ts +++ b/ts/packages/base/src/spec/parameter-spec.ts @@ -20,8 +20,6 @@ export function makeParameterSpec(name: string): ParameterSpec { return { name, addEffect, - add: async (flow, _pubsub, definition) => { - await Effect.runPromise(addEffect(flow, definition)); - }, + add: (flow, _pubsub, definition) => Effect.runPromise(addEffect(flow, definition)), }; } diff --git a/ts/packages/base/src/spec/producer-spec.ts b/ts/packages/base/src/spec/producer-spec.ts index 955846b4..5b6e4a63 100644 --- a/ts/packages/base/src/spec/producer-spec.ts +++ b/ts/packages/base/src/spec/producer-spec.ts @@ -9,7 +9,6 @@ import type { Spec } from "./types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; import { ProducerFactory, - type EffectProducer, } from "../messaging/runtime.js"; declare const ProducerSpecType: unique symbol; @@ -26,14 +25,13 @@ export function makeProducerSpec(name: string): ProducerSpec { const topic = definition.topics?.[name] ?? name; const factory = yield* ProducerFactory; const producer = yield* factory.make({ topic }); - flow.registerProducer(name, producer as EffectProducer); + flow.registerProducer(name, producer); }); return { name, addEffect, - add: async (flow, pubsub, definition) => { - await flow.runInCompatibilityScope(addEffect(flow, definition), pubsub); - }, + 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 b840c8be..c7f78bf7 100644 --- a/ts/packages/base/src/spec/request-response-spec.ts +++ b/ts/packages/base/src/spec/request-response-spec.ts @@ -12,7 +12,6 @@ import type { Spec } from "./types.js"; import type { Flow, FlowDefinition } from "../processor/flow.js"; import { RequestResponseFactory, - type EffectRequestResponse, } from "../messaging/runtime.js"; declare const RequestResponseSpecType: unique symbol; @@ -41,14 +40,13 @@ export function makeRequestResponseSpec( responseTopic, subscription: `${flow.processorId}-${flow.name}-${name}`, }); - flow.registerRequestor(name, requestor as EffectRequestResponse); + flow.registerRequestor(name, requestor); }); return { name, addEffect, - add: async (flow, pubsub, definition) => { - await flow.runInCompatibilityScope(addEffect(flow, definition), pubsub); - }, + 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 5f9c157f..65b5e8bc 100644 --- a/ts/packages/base/src/spec/types.ts +++ b/ts/packages/base/src/spec/types.ts @@ -4,7 +4,7 @@ * Python reference: trustgraph-base/trustgraph/base/spec.py and siblings */ -import type { Effect, Scope } from "effect"; +import type { Context, Effect, Scope } from "effect"; import type { PubSubBackend } from "../backend/types.js"; import type { ConsumerFactory, @@ -28,5 +28,10 @@ export interface Spec { flow: Flow, definition: FlowDefinition, ): Effect.Effect; - add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise; + add( + flow: Flow, + pubsub: PubSubBackend, + definition: FlowDefinition, + context: Context.Context, + ): Promise; } diff --git a/ts/packages/cli/src/commands/agent.ts b/ts/packages/cli/src/commands/agent.ts index 5a40ef2c..9b51e473 100644 --- a/ts/packages/cli/src/commands/agent.ts +++ b/ts/packages/cli/src/commands/agent.ts @@ -5,21 +5,20 @@ */ import type { Command } from "commander"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +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(async (question: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((question: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - await new Promise((resolve, reject) => { + yield* Effect.callback>((resume) => { flow.agent( question, (chunk) => { @@ -35,14 +34,13 @@ export function registerAgentCommands(program: Command): void { if (chunk.length > 0) process.stdout.write(chunk); if (complete) { process.stdout.write("\n"); - resolve(); + resume(Effect.void); } }, - (err) => reject(new Error(err)), + (err) => resume(Effect.fail(cliCommandError("agent", err))), ); }); - } finally { - socket.close(); - } - }); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/config.ts b/ts/packages/cli/src/commands/config.ts index 2ab8c6cf..2a462bb7 100644 --- a/ts/packages/cli/src/commands/config.ts +++ b/ts/packages/cli/src/commands/config.ts @@ -5,7 +5,8 @@ */ import type { Command } from "commander"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +import { cliCommandError, withSocket, writeJson } from "./util.js"; export function registerConfigCommands(program: Command): void { const config = program @@ -15,28 +16,26 @@ export function registerConfigCommands(program: Command): void { config .command("show") .description("Show current configuration") - .action(async (_opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((_opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const cfg = socket.config(); - const resp = await cfg.getConfigAll(); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => cfg.getConfigAll(), + catch: (error) => cliCommandError("config.show", error), + }); + yield* writeJson(resp); + }), + )), + ); config .command("get") .description("Get a configuration value") .argument("", "Config key (format: type/key)") - .action(async (key: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((key: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (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("/"); @@ -44,72 +43,74 @@ export function registerConfigCommands(program: Command): void { parts.length >= 2 ? { type: parts[0], key: parts.slice(1).join("/") } : { type: "config", key }; - const resp = await cfg.getConfig([configKey]); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => cfg.getConfig([configKey]), + catch: (error) => cliCommandError("config.get", error), + }); + yield* writeJson(resp); + }), + )), + ); config .command("set") .description("Set a configuration value") .argument("", "Config key (format: type/key)") .argument("", "Config value (JSON)") - .action(async (key: string, value: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((key: string, value: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (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 = await cfg.putConfig([configEntry]); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => cfg.putConfig([configEntry]), + catch: (error) => cliCommandError("config.set", error), + }); + yield* writeJson(resp); + }), + )), + ); config .command("list") .description("List configuration keys for a type") .argument("[type]", "Config type to list", "config") - .action(async (type: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((type: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const cfg = socket.config(); - const resp = await cfg.list(type); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => cfg.list(type), + catch: (error) => cliCommandError("config.list", error), + }); + yield* writeJson(resp); + }), + )), + ); config .command("delete") .description("Delete a configuration entry") .argument("", "Config key (format: type/key)") - .action(async (key: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((key: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (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 = await cfg.deleteConfig(configKey); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => cfg.deleteConfig(configKey), + catch: (error) => cliCommandError("config.delete", error), + }); + yield* writeJson(resp); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/embeddings.ts b/ts/packages/cli/src/commands/embeddings.ts index 153f8418..78a3be86 100644 --- a/ts/packages/cli/src/commands/embeddings.ts +++ b/ts/packages/cli/src/commands/embeddings.ts @@ -5,23 +5,24 @@ */ import type { Command } from "commander"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +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(async (texts: string[], _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((texts: string[], _opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); - const vectors = await flow.embeddings(texts); - console.log(JSON.stringify(vectors, null, 2)); - } finally { - socket.close(); - } - }); + const vectors = yield* Effect.tryPromise({ + try: () => flow.embeddings(texts), + catch: (error) => cliCommandError("embeddings", error), + }); + yield* writeJson(vectors); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/flow.ts b/ts/packages/cli/src/commands/flow.ts index 62add47e..a2631213 100644 --- a/ts/packages/cli/src/commands/flow.ts +++ b/ts/packages/cli/src/commands/flow.ts @@ -5,7 +5,9 @@ */ import type { Command } from "commander"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +import * as S from "effect/Schema"; +import { cliCommandError, withSocket, writeJson } from "./util.js"; export function registerFlowCommands(program: Command): void { const flow = program @@ -15,35 +17,35 @@ export function registerFlowCommands(program: Command): void { flow .command("list") .description("List active flows") - .action(async (_opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((_opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const flows = socket.flows(); - const ids = await flows.getFlows(); - console.log(JSON.stringify(ids, null, 2)); - } finally { - socket.close(); - } - }); + const ids = yield* Effect.tryPromise({ + try: () => flows.getFlows(), + catch: (error) => cliCommandError("flow.list", error), + }); + yield* writeJson(ids); + }), + )), + ); flow .command("get") .description("Get a flow definition") .argument("", "Flow ID") - .action(async (id: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((id: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const flows = socket.flows(); - const def = await flows.getFlow(id); - console.log(JSON.stringify(def, null, 2)); - } finally { - socket.close(); - } - }); + const def = yield* Effect.tryPromise({ + try: () => flows.getFlow(id), + catch: (error) => cliCommandError("flow.get", error), + }); + yield* writeJson(def); + }), + )), + ); flow .command("start") @@ -52,42 +54,46 @@ export function registerFlowCommands(program: Command): void { .requiredOption("-b, --blueprint ", "Blueprint name") .option("-d, --description ", "Flow description", "") .option("-p, --parameters ", "Parameters as JSON") - .action(async (id: string, cmdOpts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((id: string, cmdOpts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const flows = socket.flows(); const rawParameters = cmdOpts.parameters as string | undefined; const params = rawParameters !== undefined && rawParameters.length > 0 - ? JSON.parse(rawParameters) + ? 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 = await flows.startFlow( - id, - cmdOpts.blueprint as string, - cmdOpts.description as string, - params as Record | undefined, - ); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + 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); + }), + )), + ); flow .command("stop") .description("Stop a flow") .argument("", "Flow ID") - .action(async (id: string, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((id: string, _opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const flows = socket.flows(); - const resp = await flows.stopFlow(id); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => flows.stopFlow(id), + catch: (error) => cliCommandError("flow.stop", error), + }); + yield* writeJson(resp); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/graph-rag.ts b/ts/packages/cli/src/commands/graph-rag.ts index cbede535..32ac5ff8 100644 --- a/ts/packages/cli/src/commands/graph-rag.ts +++ b/ts/packages/cli/src/commands/graph-rag.ts @@ -5,7 +5,8 @@ */ import type { Command } from "commander"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +import { cliCommandError, withSocket, writeLine } from "./util.js"; export function registerGraphRagCommands(program: Command): void { program @@ -15,26 +16,27 @@ export function registerGraphRagCommands(program: Command): void { .option("--entity-limit ", "Max entities", "50") .option("--triple-limit ", "Max triples per entity", "30") .option("--collection ", "Collection name") - .action(async (query: string, cmdOpts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((query: string, cmdOpts, cmd) => + Effect.runPromise(withSocket(cmd, (socket, opts) => + Effect.gen(function* () { const flow = socket.flow(opts.flow); const collection = cmdOpts.collection as string | undefined; - const response = await flow.graphRag( - query, - { - entityLimit: parseInt(cmdOpts.entityLimit, 10), - tripleLimit: parseInt(cmdOpts.tripleLimit, 10), - }, - collection, - ); - console.log(response); - } finally { - socket.close(); - } - }); + 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); + }), + )), + ); program .command("document-rag") @@ -42,24 +44,25 @@ export function registerGraphRagCommands(program: Command): void { .argument("", "Natural language query") .option("--doc-limit ", "Max documents", "20") .option("--collection ", "Collection name") - .action(async (query: string, cmdOpts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((query: string, cmdOpts, cmd) => + Effect.runPromise(withSocket(cmd, (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 = await flow.documentRag( - query, - docLimit !== undefined && docLimit.length > 0 - ? parseInt(docLimit, 10) - : undefined, - collection, - ); - console.log(response); - } finally { - socket.close(); - } - }); + 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); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/library.ts b/ts/packages/cli/src/commands/library.ts index 273fc7dc..11ef23f1 100644 --- a/ts/packages/cli/src/commands/library.ts +++ b/ts/packages/cli/src/commands/library.ts @@ -5,7 +5,8 @@ */ import type { Command } from "commander"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +import { cliCommandError, withSocket, writeJson } from "./util.js"; function basenamePath(filepath: string): string { const normalized = filepath.replace(/\/+$/, ""); @@ -45,18 +46,18 @@ export function registerLibraryCommands(program: Command): void { library .command("list") .description("List documents in the library") - .action(async (_opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((_opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const docs = await lib.getDocuments(); - console.log(JSON.stringify(docs, null, 2)); - } finally { - socket.close(); - } - }); + const docs = yield* Effect.tryPromise({ + try: () => lib.getDocuments(), + catch: (error) => cliCommandError("library.list", error), + }); + yield* writeJson(docs); + }), + )), + ); library .command("load") @@ -67,64 +68,68 @@ export function registerLibraryCommands(program: Command): void { .option("-c, --comments ", "Comments", "") .option("--tags ", "Document tags") .option("--id ", "Optional document ID") - .action(async (file: string, cmdOpts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((file: string, cmdOpts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const data = new Uint8Array(await Bun.file(file).arrayBuffer()); + 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 resp = await lib.loadDocument( - b64, - mimeType, - title, - comments, - tags, - cmdOpts.id as string | undefined, - ); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + 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); + }), + )), + ); library .command("remove") .description("Remove a document from the library") .argument("", "Document ID to remove") .option("--collection ", "Collection name") - .action(async (id: string, cmdOpts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((id: string, cmdOpts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const resp = await lib.removeDocument(id, cmdOpts.collection as string | undefined); - console.log(JSON.stringify(resp, null, 2)); - } finally { - socket.close(); - } - }); + const resp = yield* Effect.tryPromise({ + try: () => lib.removeDocument(id, cmdOpts.collection as string | undefined), + catch: (error) => cliCommandError("library.remove", error), + }); + yield* writeJson(resp); + }), + )), + ); library .command("processing") .description("List documents currently being processed") - .action(async (_opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((_opts, cmd) => + Effect.runPromise(withSocket(cmd, (socket) => + Effect.gen(function* () { const lib = socket.librarian(); - const items = await lib.getProcessing(); - console.log(JSON.stringify(items, null, 2)); - } finally { - socket.close(); - } - }); + const items = yield* Effect.tryPromise({ + try: () => lib.getProcessing(), + catch: (error) => cliCommandError("library.processing", error), + }); + yield* writeJson(items); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/triples.ts b/ts/packages/cli/src/commands/triples.ts index 6d439c6a..ab28b966 100644 --- a/ts/packages/cli/src/commands/triples.ts +++ b/ts/packages/cli/src/commands/triples.ts @@ -6,7 +6,8 @@ import type { Command } from "commander"; import type { Term } from "@trustgraph/client"; -import { createSocket, getOpts } from "./util.js"; +import { Effect } from "effect"; +import { cliCommandError, withSocket, writeJson } from "./util.js"; export function registerTriplesCommands(program: Command): void { program @@ -17,11 +18,9 @@ export function registerTriplesCommands(program: Command): void { .option("-o, --object ", "Object IRI or literal") .option("-l, --limit ", "Max results", "20") .option("--collection ", "Collection name") - .action(async (cmdOpts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { + .action((cmdOpts, cmd) => + Effect.runPromise(withSocket(cmd, (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; @@ -36,16 +35,19 @@ export function registerTriplesCommands(program: Command): void { ? { t: "i", i: object } : undefined; - const triples = await flow.triplesQuery( - s, - p, - o, - parseInt(cmdOpts.limit as string, 10), - cmdOpts.collection as string | undefined, - ); - console.log(JSON.stringify(triples, null, 2)); - } finally { - socket.close(); - } - }); + 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); + }), + )), + ); } diff --git a/ts/packages/cli/src/commands/util.ts b/ts/packages/cli/src/commands/util.ts index 200f9981..fd459ef5 100644 --- a/ts/packages/cli/src/commands/util.ts +++ b/ts/packages/cli/src/commands/util.ts @@ -4,6 +4,8 @@ import type { Command } from "commander"; import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client"; +import { Duration, Effect } from "effect"; +import * as S from "effect/Schema"; export interface CliOpts { gateway: string; @@ -19,36 +21,76 @@ export function getOpts(cmd: Command): CliOpts { return root.opts() as CliOpts; } +export class CliCommandError extends S.TaggedErrorClass()( + "CliCommandError", + { + message: S.String, + operation: S.String, + }, +) {} + +export function cliCommandError(operation: string, error: unknown): CliCommandError { + const message = typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : String(error); + return CliCommandError.make({ operation, message }); +} + +export const writeLine = (line: string) => + Effect.sync(() => { + process.stdout.write(`${line}\n`); + }); + +export const writeJson = (value: unknown) => + S.encodeUnknownEffect(S.UnknownFromJsonString)(value).pipe( + Effect.mapError((error) => cliCommandError("write-json", error)), + Effect.flatMap(writeLine), + ); + /** * Create a BaseApi socket client and wait for the connection to be established. * The client auto-connects; we listen for the first "connected/authenticated" * state before handing it back to the caller. */ -export async function createSocket(opts: CliOpts): Promise { +export function createSocketEffect(opts: CliOpts): Effect.Effect { const socket = createTrustGraphSocket(opts.user, opts.token, opts.gateway); - // Wait for the socket to reach an open state - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - unsub(); - reject(new Error("Timed out waiting for WebSocket connection")); - }, 15_000); - + return Effect.callback((resume) => { const unsub = socket.onConnectionStateChange((state) => { - if ( - state.status === "authenticated" || - state.status === "unauthenticated" - ) { - clearTimeout(timeout); + if (state.status === "authenticated" || state.status === "unauthenticated") { unsub(); - resolve(); + resume(Effect.void); } else if (state.status === "failed") { - clearTimeout(timeout); unsub(); - reject(new Error(state.lastError ?? "WebSocket connection failed")); + resume(Effect.fail(cliCommandError("connect", state.lastError ?? "WebSocket connection failed"))); } }); - }); - return socket; + return Effect.sync(() => { + unsub(); + }); + }).pipe( + Effect.timeout(Duration.seconds(15)), + Effect.catchTag("TimeoutError", () => + Effect.fail(cliCommandError("connect", "Timed out waiting for WebSocket connection")), + ), + Effect.as(socket), + ); } + +export function createSocket(opts: CliOpts): Promise { + return Effect.runPromise(createSocketEffect(opts)); +} + +export const withSocket = ( + cmd: Command, + use: (socket: BaseApi, opts: CliOpts) => Effect.Effect, +) => + Effect.acquireUseRelease( + createSocketEffect(getOpts(cmd)), + (socket) => use(socket, getOpts(cmd)), + (socket) => + Effect.sync(() => { + socket.close(); + }), + ); diff --git a/ts/packages/flow/src/config/service.ts b/ts/packages/flow/src/config/service.ts index e94163c2..3c61ffd9 100644 --- a/ts/packages/flow/src/config/service.ts +++ b/ts/packages/flow/src/config/service.ts @@ -11,7 +11,7 @@ * Python reference: trustgraph-flow/trustgraph/config/service/service.py */ -import { Duration, Effect } from "effect"; +import { Context, Duration, Effect } from "effect"; import * as S from "effect/Schema"; import { makeAsyncProcessor, @@ -148,7 +148,7 @@ export type ConfigService = AsyncProcessorRuntime & Record; export function makeConfigService(config: ConfigServiceConfig): ConfigService { const service = makeAsyncProcessor(config, { - run: () => service.run(), + run: () => service.run(Context.empty()), }) as ConfigService; const baseStop = service.stop; service.store = new Map(); diff --git a/ts/packages/flow/src/cores/service.ts b/ts/packages/flow/src/cores/service.ts index e87431c8..cf725ab9 100644 --- a/ts/packages/flow/src/cores/service.ts +++ b/ts/packages/flow/src/cores/service.ts @@ -23,7 +23,7 @@ import { errorMessage, } from "@trustgraph/base"; import type { Message } from "@trustgraph/base"; -import { Config, Duration, Effect } from "effect"; +import { Config, Context, Duration, Effect } from "effect"; import * as S from "effect/Schema"; import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js"; @@ -118,7 +118,7 @@ const closeResource = ( export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): KnowledgeCoreService { const service = makeAsyncProcessor(config, { - run: () => service.run(), + run: () => service.run(Context.empty()), }) as KnowledgeCoreService; const baseStop = service.stop; service.cores = new Map(); diff --git a/ts/packages/flow/src/flow-manager/service.ts b/ts/packages/flow/src/flow-manager/service.ts index 8d6084aa..ff99ed58 100644 --- a/ts/packages/flow/src/flow-manager/service.ts +++ b/ts/packages/flow/src/flow-manager/service.ts @@ -26,7 +26,7 @@ import { } from "@trustgraph/base"; import { makeProcessorProgram } from "@trustgraph/base"; import type { Message } from "@trustgraph/base"; -import { Duration, Effect, Option } from "effect"; +import { Context, Duration, Effect, Option } from "effect"; import * as S from "effect/Schema"; // ---------- Internal state types ---------- @@ -158,7 +158,7 @@ export type FlowManagerService = AsyncProcessorRuntime & Record; export function makeFlowManagerService(config: ProcessorConfig): FlowManagerService { const service = makeAsyncProcessor(config, { - run: () => service.run(), + run: () => service.run(Context.empty()), }) as FlowManagerService; const baseStop = service.stop; service.flows = new Map(); diff --git a/ts/packages/flow/src/librarian/service.ts b/ts/packages/flow/src/librarian/service.ts index 1403d39a..1877c293 100644 --- a/ts/packages/flow/src/librarian/service.ts +++ b/ts/packages/flow/src/librarian/service.ts @@ -25,7 +25,7 @@ import { type ProcessingMetadata, } from "@trustgraph/base"; import type { Message } from "@trustgraph/base"; -import { Clock, Config, DateTime, Duration, Effect, Random } from "effect"; +import { Clock, Config, Context, DateTime, Duration, Effect, Random } from "effect"; import * as S from "effect/Schema"; import { makeCollectionManager } from "./collection-manager.js"; import { @@ -139,7 +139,7 @@ export type LibrarianService = AsyncProcessorRuntime & Record; export function makeLibrarianService(config: LibrarianServiceConfig): LibrarianService { const service = makeAsyncProcessor(config, { - run: () => service.run(), + run: () => service.run(Context.empty()), }) as LibrarianService; const baseStop = service.stop; service.documents = new Map(); diff --git a/ts/packages/mcp/src/server-effect.ts b/ts/packages/mcp/src/server-effect.ts index 0b34ab53..68e0a185 100644 --- a/ts/packages/mcp/src/server-effect.ts +++ b/ts/packages/mcp/src/server-effect.ts @@ -1,14 +1,21 @@ import {OpenAiClient, OpenAiLanguageModel} from "@effect/ai-openai"; import {BunHttpServer, BunRuntime} from "@effect/platform-bun"; import {createTrustGraphSocket, type BaseApi, type Term as ClientTerm} from "@trustgraph/client"; -import {Context, Effect, Layer, Redacted} from "effect"; +import {Config, Context, Effect, Layer, Redacted} from "effect"; +import * as O from "effect/Option"; +import * as Predicate from "effect/Predicate"; import {LanguageModel, McpServer, Prompt, Tool, Toolkit} from "effect/unstable/ai"; import {FetchHttpClient, HttpRouter} from "effect/unstable/http"; import {HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi} from "effect/unstable/httpapi"; import * as S from "effect/Schema"; -const annotateTool = ( - tool: T, +const annotateTool = ( + tool: Tool.Tool, annotations: { readonly title: string readonly readOnly: boolean @@ -17,14 +24,14 @@ const annotateTool = ( readonly openWorld: boolean readonly strict?: boolean }, -): T => +): Tool.Tool => tool .annotate(Tool.Title, annotations.title) .annotate(Tool.Readonly, annotations.readOnly) .annotate(Tool.Destructive, annotations.destructive) .annotate(Tool.Idempotent, annotations.idempotent) .annotate(Tool.OpenWorld, annotations.openWorld) - .annotate(Tool.Strict, annotations.strict ?? true) as T + .annotate(Tool.Strict, annotations.strict ?? true) class PromptSummary extends S.Class("PromptSummary")( { @@ -1217,18 +1224,14 @@ export interface TrustGraphMcpConfigShape { readonly version: string readonly mcpPath: HttpRouter.PathInput readonly openAiModel: string - readonly openAiApiKey: string | undefined + readonly openAiApiKey: Redacted.Redacted | undefined readonly port: number } const readNonEmpty = (value: string | undefined): string | undefined => value !== undefined && value.length > 0 ? value : undefined -const resolvePort = (value: number | undefined): number => { - if (value !== undefined) { - return value - } - const raw = readNonEmpty(process.env.PORT) +const parsePort = (raw: string | undefined): number => { if (raw === undefined) { return 3000 } @@ -1236,28 +1239,46 @@ const resolvePort = (value: number | undefined): number => { return Number.isFinite(parsed) ? parsed : 3000 } +export const loadTrustGraphMcpConfig = Effect.fn("loadTrustGraphMcpConfig")(function*( + options: TrustGraphMcpOptions = {}, +) { + const gatewayUrl = O.getOrUndefined(yield* Config.string("GATEWAY_URL").pipe(Config.option)) + const user = O.getOrUndefined(yield* Config.string("USER_ID").pipe(Config.option)) + const gatewaySecret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option)) + const token = readNonEmpty(gatewaySecret) + const flowId = O.getOrUndefined(yield* Config.string("FLOW_ID").pipe(Config.option)) + const openAiModel = O.getOrUndefined(yield* Config.string("OPENAI_MODEL").pipe(Config.option)) + const openAiApiKey = O.getOrUndefined(yield* Config.redacted("OPENAI_API_KEY").pipe(Config.option)) + const openAiToken = O.getOrUndefined(yield* Config.redacted("OPENAI_TOKEN").pipe(Config.option)) + const port = O.getOrUndefined(yield* Config.string("PORT").pipe(Config.option)) + + return { + gatewayUrl: options.gatewayUrl ?? gatewayUrl ?? "ws://localhost:8088/api/v1/rpc", + user: options.user ?? user ?? "mcp", + token: options.token ?? token, + flowId: options.flowId ?? flowId ?? "default", + name: options.name ?? "trustgraph", + version: options.version ?? "0.1.0", + mcpPath: options.mcpPath ?? "/mcp", + openAiModel: options.openAiModel ?? openAiModel ?? "gpt-4.1", + openAiApiKey: options.openAiApiKey === undefined + ? openAiApiKey ?? openAiToken + : Redacted.make(options.openAiApiKey), + port: options.port ?? parsePort(readNonEmpty(port)), + } +}) + export const resolveTrustGraphMcpConfig = ( options: TrustGraphMcpOptions = {}, -): TrustGraphMcpConfigShape => ({ - gatewayUrl: options.gatewayUrl ?? process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc", - user: options.user ?? process.env.USER_ID ?? "mcp", - token: options.token ?? readNonEmpty(process.env.GATEWAY_SECRET), - flowId: options.flowId ?? process.env.FLOW_ID ?? "default", - name: options.name ?? "trustgraph", - version: options.version ?? "0.1.0", - mcpPath: options.mcpPath ?? "/mcp", - openAiModel: options.openAiModel ?? process.env.OPENAI_MODEL ?? "gpt-4.1", - openAiApiKey: options.openAiApiKey ?? readNonEmpty(process.env.OPENAI_API_KEY) ?? readNonEmpty(process.env.OPENAI_TOKEN), - port: resolvePort(options.port), -}) +): TrustGraphMcpConfigShape => Effect.runSync(loadTrustGraphMcpConfig(options)) export class TrustGraphMcpConfig extends Context.Service()( "@trustgraph/mcp/server-effect/TrustGraphMcpConfig", ) { static readonly layer = (options: TrustGraphMcpOptions = {}) => - Layer.succeed( + Layer.effect( TrustGraphMcpConfig, - TrustGraphMcpConfig.of(resolveTrustGraphMcpConfig(options)), + loadTrustGraphMcpConfig(options).pipe(Effect.map(TrustGraphMcpConfig.of)), ) } @@ -1278,17 +1299,14 @@ export class TrustGraphSocket extends Context.Service } const toErrorMessage = (cause: unknown): string => { - if (cause instanceof Error && cause.message.length > 0) { + if (Predicate.isError(cause) && cause.message.length > 0) { return cause.message } if (typeof cause === "string" && cause.length > 0) { return cause } - if (cause !== null && typeof cause === "object" && "message" in cause) { - const message = (cause as { readonly message?: unknown }).message - if (typeof message === "string" && message.length > 0) { - return message - } + if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) { + return cause.message } return "TrustGraph MCP tool failed" } @@ -1313,16 +1331,15 @@ const decodeJsonArrayOrFail = ( const asIriTerm = (value: string | undefined): ClientTerm | undefined => value !== undefined && value.length > 0 ? {t: "i", i: value} : undefined -const openAiApiKeyOptions = (apiKey: string | undefined) => +const openAiApiKeyOptions = (apiKey: Redacted.Redacted | undefined) => apiKey === undefined ? {} - : {apiKey: Redacted.make(apiKey)} + : {apiKey} -export const makeOpenAiProviderLayer = ( - options: TrustGraphMcpOptions = {}, -) => { - const config = resolveTrustGraphMcpConfig(options) - return OpenAiLanguageModel.layer({ +const makeOpenAiProviderLayerFromConfig = ( + config: TrustGraphMcpConfigShape, +) => + OpenAiLanguageModel.layer({ model: config.openAiModel, config: { strictJsonSchema: true, @@ -1331,7 +1348,15 @@ export const makeOpenAiProviderLayer = ( Layer.provide(OpenAiClient.layer(openAiApiKeyOptions(config.openAiApiKey))), Layer.provide(FetchHttpClient.layer), ) -} + +export const makeOpenAiProviderLayer = ( + options: TrustGraphMcpOptions = {}, +) => + Layer.unwrap( + loadTrustGraphMcpConfig(options).pipe( + Effect.map(makeOpenAiProviderLayerFromConfig), + ), + ) export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( Effect.gen(function*() { @@ -1344,33 +1369,32 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( const response = yield* model.generateText({ prompt: Prompt.make(prompt).pipe(Prompt.setSystem(system)), }) - return new TextCompletionSuccess({text: response.text}) + return TextCompletionSuccess.make({text: response.text}) }), graph_rag: ({query, entity_limit, triple_limit, collection}) => Effect.tryPromise({ - try: async () => { - const response = await socket.flow(config.flowId).graphRag( + try: () => + socket.flow(config.flowId).graphRag( query, { ...(entity_limit !== undefined ? {entityLimit: entity_limit} : {}), ...(triple_limit !== undefined ? {tripleLimit: triple_limit} : {}), }, collection, - ) - return new GraphRagSuccess({text: response}) - }, - catch: (cause) => new GraphRagError({cause, message: toErrorMessage(cause)}), - }), + ), + catch: (cause) => GraphRagError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((text) => GraphRagSuccess.make({text})), + ), document_rag: ({query, doc_limit, collection}) => Effect.tryPromise({ - try: async () => { - const response = await socket.flow(config.flowId).documentRag(query, doc_limit, collection) - return new DocumentRagSuccess({text: response}) - }, - catch: (cause) => new DocumentRagError({cause, message: toErrorMessage(cause)}), - }), + try: () => socket.flow(config.flowId).documentRag(query, doc_limit, collection), + catch: (cause) => DocumentRagError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((text) => DocumentRagSuccess.make({text})), + ), agent: ({question}) => Effect.callback((resume) => { @@ -1382,62 +1406,65 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( (chunk, complete) => { fullAnswer += chunk if (complete) { - resume(Effect.succeed(new AgentSuccess({text: fullAnswer}))) + resume(Effect.succeed(AgentSuccess.make({text: fullAnswer}))) } }, - (cause) => resume(Effect.fail(new AgentError({cause, message: toErrorMessage(cause)}))), + (cause) => resume(Effect.fail(AgentError.make({cause, message: toErrorMessage(cause)}))), ) }), embeddings: ({text}) => Effect.tryPromise({ - try: async () => { - const vectors = await socket.flow(config.flowId).embeddings([...text]) - return new EmbeddingsSuccess({vectors}) - }, - catch: (cause) => new EmbeddingsError({cause, message: toErrorMessage(cause)}), - }), + try: () => socket.flow(config.flowId).embeddings([...text]), + catch: (cause) => EmbeddingsError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((vectors) => EmbeddingsSuccess.make({vectors})), + ), triples_query: ({s, p, o, limit, collection}) => Effect.tryPromise({ - try: async () => { - const triples = await socket.flow(config.flowId).triplesQuery( + try: () => + socket.flow(config.flowId).triplesQuery( asIriTerm(s), asIriTerm(p), asIriTerm(o), limit, collection, - ) - return new TriplesQuerySuccess({triples}) - }, - catch: (cause) => new TriplesQueryError({cause, message: toErrorMessage(cause)}), - }), + ), + catch: (cause) => TriplesQueryError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((triples) => TriplesQuerySuccess.make({triples})), + ), graph_embeddings_query: ({query, limit, collection}) => Effect.tryPromise({ - try: async () => { - const vectors = await socket.flow(config.flowId).embeddings([query]) - const entities = await socket.flow(config.flowId).graphEmbeddingsQuery( - vectors[0] ?? [], - limit ?? 10, - collection, - ) - return new GraphEmbeddingsQuerySuccess({entities}) - }, - catch: (cause) => new GraphEmbeddingsQueryError({cause, message: toErrorMessage(cause)}), - }), + try: () => socket.flow(config.flowId).embeddings([query]), + catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.flatMap((vectors) => + Effect.tryPromise({ + try: () => socket.flow(config.flowId).graphEmbeddingsQuery( + vectors[0] ?? [], + limit ?? 10, + collection, + ), + catch: (cause) => GraphEmbeddingsQueryError.make({cause, message: toErrorMessage(cause)}), + }) + ), + Effect.map((entities) => GraphEmbeddingsQuerySuccess.make({entities})), + ), get_config_all: () => Effect.tryPromise({ try: () => socket.config().getConfigAll(), - catch: (cause) => new GetConfigAllError({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new GetConfigAllError({cause, message: toErrorMessage(cause)}), + (cause) => GetConfigAllError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((config) => new GetConfigAllSuccess({config})), + Effect.map((config) => GetConfigAllSuccess.make({config})), ) ), ), @@ -1445,14 +1472,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_config: ({keys}) => Effect.tryPromise({ try: () => socket.config().getConfig(keys.map(({type, key}) => ({type, key}))), - catch: (cause) => new GetConfigError({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new GetConfigError({cause, message: toErrorMessage(cause)}), + (cause) => GetConfigError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((config) => new GetConfigSuccess({config})), + Effect.map((config) => GetConfigSuccess.make({config})), ) ), ), @@ -1460,14 +1487,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( put_config: ({values}) => Effect.tryPromise({ try: () => socket.config().putConfig(values.map(({type, key, value}) => ({type, key, value}))), - catch: (cause) => new PutConfigError({cause, message: toErrorMessage(cause)}), + catch: (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new PutConfigError({cause, message: toErrorMessage(cause)}), + (cause) => PutConfigError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new PutConfigSuccess({response})), + Effect.map((response) => PutConfigSuccess.make({response})), ) ), ), @@ -1475,56 +1502,58 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( delete_config: ({type, key}) => Effect.tryPromise({ try: () => socket.config().deleteConfig({type, key}), - catch: (cause) => new DeleteConfigError({cause, message: toErrorMessage(cause)}), + catch: (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new DeleteConfigError({cause, message: toErrorMessage(cause)}), + (cause) => DeleteConfigError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new DeleteConfigSuccess({response})), + Effect.map((response) => DeleteConfigSuccess.make({response})), ) ), ), get_flows: () => Effect.tryPromise({ - try: async () => new GetFlowsSuccess({flow_ids: await socket.flows().getFlows()}), - catch: (cause) => new GetFlowsError({cause, message: toErrorMessage(cause)}), - }), + try: () => socket.flows().getFlows(), + catch: (cause) => GetFlowsError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((flow_ids) => GetFlowsSuccess.make({flow_ids})), + ), get_flow: ({flow_id}) => Effect.tryPromise({ try: () => socket.flows().getFlow(flow_id), - catch: (cause) => new GetFlowError({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new GetFlowError({cause, message: toErrorMessage(cause)}), + (cause) => GetFlowError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((flow) => new GetFlowSuccess({flow})), + Effect.map((flow) => GetFlowSuccess.make({flow})), ) ), ), start_flow: ({flow_id, blueprint_name, description, parameters}) => Effect.tryPromise({ - try: async () => + try: () => socket.flows().startFlow( flow_id, blueprint_name, description, parameters === undefined ? undefined : {...parameters}, ), - catch: (cause) => new StartFlowError({cause, message: toErrorMessage(cause)}), + catch: (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new StartFlowError({cause, message: toErrorMessage(cause)}), + (cause) => StartFlowError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new StartFlowSuccess({response})), + Effect.map((response) => StartFlowSuccess.make({response})), ) ), ), @@ -1532,14 +1561,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( stop_flow: ({flow_id}) => Effect.tryPromise({ try: () => socket.flows().stopFlow(flow_id), - catch: (cause) => new StopFlowError({cause, message: toErrorMessage(cause)}), + catch: (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new StopFlowError({cause, message: toErrorMessage(cause)}), + (cause) => StopFlowError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new StopFlowSuccess({response})), + Effect.map((response) => StopFlowSuccess.make({response})), ) ), ), @@ -1547,21 +1576,21 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( get_documents: () => Effect.tryPromise({ try: () => socket.librarian().getDocuments(), - catch: (cause) => new GetDocumentsError({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonArrayOrFail( value, - (cause) => new GetDocumentsError({cause, message: toErrorMessage(cause)}), + (cause) => GetDocumentsError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((documents) => new GetDocumentsSuccess({documents})), + Effect.map((documents) => GetDocumentsSuccess.make({documents})), ) ), ), load_document: ({document, mime_type, title, comments, tags, id}) => Effect.tryPromise({ - try: async () => + try: () => socket.librarian().loadDocument( document, mime_type, @@ -1570,14 +1599,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( tags === undefined ? [] : [...tags], id, ), - catch: (cause) => new LoadDocumentError({cause, message: toErrorMessage(cause)}), + catch: (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new LoadDocumentError({cause, message: toErrorMessage(cause)}), + (cause) => LoadDocumentError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new LoadDocumentSuccess({response})), + Effect.map((response) => LoadDocumentSuccess.make({response})), ) ), ), @@ -1585,56 +1614,60 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( remove_document: ({id, collection}) => Effect.tryPromise({ try: () => socket.librarian().removeDocument(id, collection), - catch: (cause) => new RemoveDocumentError({cause, message: toErrorMessage(cause)}), + catch: (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new RemoveDocumentError({cause, message: toErrorMessage(cause)}), + (cause) => RemoveDocumentError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new RemoveDocumentSuccess({response})), + Effect.map((response) => RemoveDocumentSuccess.make({response})), ) ), ), get_prompts: () => Effect.tryPromise({ - try: async () => new GetPromptsSuccess({prompts: await socket.config().getPrompts()}), - catch: (cause) => new GetPromptsError({cause, message: toErrorMessage(cause)}), - }), + try: () => socket.config().getPrompts(), + catch: (cause) => GetPromptsError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((prompts) => GetPromptsSuccess.make({prompts})), + ), get_prompt: ({id}) => Effect.tryPromise({ try: () => socket.config().getPrompt(id), - catch: (cause) => new GetPromptError({cause, message: toErrorMessage(cause)}), + catch: (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new GetPromptError({cause, message: toErrorMessage(cause)}), + (cause) => GetPromptError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((prompt) => new GetPromptSuccess({prompt})), + Effect.map((prompt) => GetPromptSuccess.make({prompt})), ) ), ), get_knowledge_cores: () => Effect.tryPromise({ - try: async () => new GetKnowledgeCoresSuccess({ids: await socket.knowledge().getKnowledgeCores()}), - catch: (cause) => new GetKnowledgeCoresError({cause, message: toErrorMessage(cause)}), - }), + try: () => socket.knowledge().getKnowledgeCores(), + catch: (cause) => GetKnowledgeCoresError.make({cause, message: toErrorMessage(cause)}), + }).pipe( + Effect.map((ids) => GetKnowledgeCoresSuccess.make({ids})), + ), delete_kg_core: ({id, collection}) => Effect.tryPromise({ try: () => socket.knowledge().deleteKgCore(id, collection), - catch: (cause) => new DeleteKgCoreError({cause, message: toErrorMessage(cause)}), + catch: (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new DeleteKgCoreError({cause, message: toErrorMessage(cause)}), + (cause) => DeleteKgCoreError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new DeleteKgCoreSuccess({response})), + Effect.map((response) => DeleteKgCoreSuccess.make({response})), ) ), ), @@ -1642,14 +1675,14 @@ export const TrustGraphMcpToolkitLive = TrustGraphMcpToolkit.toLayer( load_kg_core: ({id, flow, collection}) => Effect.tryPromise({ try: () => socket.knowledge().loadKgCore(id, flow, collection), - catch: (cause) => new LoadKgCoreError({cause, message: toErrorMessage(cause)}), + catch: (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}), }).pipe( Effect.flatMap((value) => decodeJsonOrFail( value, - (cause) => new LoadKgCoreError({cause, message: toErrorMessage(cause)}), + (cause) => LoadKgCoreError.make({cause, message: toErrorMessage(cause)}), ).pipe( - Effect.map((response) => new LoadKgCoreSuccess({response})), + Effect.map((response) => LoadKgCoreSuccess.make({response})), ) ), ), @@ -1689,13 +1722,12 @@ export const TrustGraphMcpHttpApiRoutes = HttpApiBuilder.layer( Layer.provide(TrustGraphMcpHttpApiHandlers), ) -export const makeTrustGraphMcpHttpLayer = ( - options: TrustGraphMcpOptions = {}, +const makeTrustGraphMcpHttpLayerFromConfig = ( + config: TrustGraphMcpConfigShape, ) => { - const config = resolveTrustGraphMcpConfig(options) const tools = McpServer.toolkit(TrustGraphMcpToolkit).pipe( Layer.provide(TrustGraphMcpToolkitLive), - Layer.provide(makeOpenAiProviderLayer(config)), + Layer.provide(makeOpenAiProviderLayerFromConfig(config)), ) return Layer.mergeAll( @@ -1708,18 +1740,31 @@ export const makeTrustGraphMcpHttpLayer = ( path: config.mcpPath, })), Layer.provide(TrustGraphSocket.layer), - Layer.provide(TrustGraphMcpConfig.layer(config)), + Layer.provide(Layer.succeed(TrustGraphMcpConfig, TrustGraphMcpConfig.of(config))), ) } export const makeTrustGraphMcpHttpServerLayer = ( options: TrustGraphMcpOptions = {}, -) => { - const config = resolveTrustGraphMcpConfig(options) - return HttpRouter.serve(makeTrustGraphMcpHttpLayer(config)).pipe( - Layer.provide(BunHttpServer.layer({port: config.port})), +) => + Layer.unwrap( + loadTrustGraphMcpConfig(options).pipe( + Effect.map((config) => + HttpRouter.serve(makeTrustGraphMcpHttpLayerFromConfig(config)).pipe( + Layer.provide(BunHttpServer.layer({port: config.port})), + ) + ), + ), + ) + +export const makeTrustGraphMcpHttpLayer = ( + options: TrustGraphMcpOptions = {}, +) => + Layer.unwrap( + loadTrustGraphMcpConfig(options).pipe( + Effect.map(makeTrustGraphMcpHttpLayerFromConfig), + ), ) -} export const runHttp = (options: TrustGraphMcpOptions = {}): void => { Layer.launch(makeTrustGraphMcpHttpServerLayer(options)).pipe(BunRuntime.runMain) diff --git a/ts/packages/mcp/src/server.ts b/ts/packages/mcp/src/server.ts index ae9cc64c..0f523f2d 100644 --- a/ts/packages/mcp/src/server.ts +++ b/ts/packages/mcp/src/server.ts @@ -1,16 +1,77 @@ /** - * TrustGraph MCP server. + * TrustGraph MCP stdio compatibility server. * - * Exposes TrustGraph capabilities as MCP tools for AI assistants. - * Uses the vendored @trustgraph/client for all gateway communication. - * - * Python reference: trustgraph-mcp/trustgraph/mcp_server/mcp.py + * 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 { z } from "zod"; -import { createTrustGraphSocket, type BaseApi, type Term } from "@trustgraph/client"; +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 {z} from "zod"; +import {loadTrustGraphMcpConfig} from "./server-effect.js"; + +interface ToolTextContent { + readonly type: "text" + readonly text: string +} + +interface ToolTextResult extends Record { + readonly content: Array +} + +class StdioMcpError extends S.TaggedErrorClass()( + "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 = (request: () => Promise) => + Effect.tryPromise({ + try: request, + catch: stdioMcpError, + }); + +const jsonText = (value: unknown) => + encodeJsonText(value).pipe( + Effect.mapError(stdioMcpError), + ); + +const runTextTool = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.map(textResult))); + +const runJsonTool = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.flatMap(jsonText), Effect.map(textResult))); export function createMcpServer(config: { gatewayUrl: string; @@ -34,7 +95,6 @@ export function createMcpServer(config: { // ===================== Flow-scoped tools ===================== - // --- Text Completion --- server.tool( "text_completion", "Run a text completion using the configured LLM", @@ -42,14 +102,10 @@ export function createMcpServer(config: { system: z.string().describe("System prompt"), prompt: z.string().describe("User prompt"), }, - async ({ system, prompt }) => { - const flow = socket.flow(flowId); - const response = await flow.textCompletion(system, prompt); - return { content: [{ type: "text" as const, text: response }] }; - }, + ({system, prompt}) => + runTextTool(gatewayRequest(() => socket.flow(flowId).textCompletion(system, prompt))), ); - // --- Graph RAG --- server.tool( "graph_rag", "Query the knowledge graph using RAG", @@ -59,21 +115,21 @@ export function createMcpServer(config: { triple_limit: z.number().optional().describe("Max triples per entity"), collection: z.string().optional().describe("Collection name"), }, - async ({ query, entity_limit, triple_limit, collection }) => { - const flow = socket.flow(flowId); - const response = await flow.graphRag( - query, - { - ...(entity_limit !== undefined ? { entityLimit: entity_limit } : {}), - ...(triple_limit !== undefined ? { tripleLimit: triple_limit } : {}), - }, - collection, - ); - return { content: [{ type: "text" as const, text: response }] }; - }, + ({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, + ) + ), + ), ); - // --- Document RAG --- server.tool( "document_rag", "Query documents using RAG", @@ -82,56 +138,45 @@ export function createMcpServer(config: { doc_limit: z.number().optional().describe("Max documents to retrieve"), collection: z.string().optional().describe("Collection name"), }, - async ({ query, doc_limit, collection }) => { - const flow = socket.flow(flowId); - const response = await flow.documentRag(query, doc_limit, collection); - return { content: [{ type: "text" as const, text: response }] }; - }, + ({query, doc_limit, collection}) => + runTextTool(gatewayRequest(() => socket.flow(flowId).documentRag(query, doc_limit, collection))), ); - // --- Agent --- server.tool( "agent", "Ask the TrustGraph agent a question", { question: z.string().describe("Question for the agent"), }, - async ({ question }) => { - const flow = socket.flow(flowId); - let fullAnswer = ""; - - await new Promise((resolve, reject) => { - flow.agent( - question, - () => {}, // think — ignore for MCP - () => {}, // observe — ignore for MCP - (chunk, complete) => { - fullAnswer += chunk; - if (complete) resolve(); - }, - (err) => reject(new Error(err)), - ); - }); - - return { content: [{ type: "text" as const, text: fullAnswer }] }; - }, + ({question}) => + runTextTool( + Effect.callback((resume) => { + let fullAnswer = ""; + socket.flow(flowId).agent( + question, + () => {}, + () => {}, + (chunk, complete) => { + fullAnswer += chunk; + if (complete) { + resume(Effect.succeed(fullAnswer)); + } + }, + (cause) => resume(Effect.fail(stdioMcpError(cause))), + ); + }), + ), ); - // --- Embeddings --- server.tool( "embeddings", "Generate text embeddings", { text: z.array(z.string()).describe("Texts to embed"), }, - async ({ text }) => { - const flow = socket.flow(flowId); - const vectors = await flow.embeddings(text); - return { content: [{ type: "text" as const, text: JSON.stringify(vectors) }] }; - }, + ({text}) => runJsonTool(gatewayRequest(() => socket.flow(flowId).embeddings(text))), ); - // --- Triples Query --- server.tool( "triples_query", "Query the knowledge graph for triples matching a pattern", @@ -142,17 +187,16 @@ export function createMcpServer(config: { limit: z.number().optional().describe("Max results"), collection: z.string().optional().describe("Collection name"), }, - async ({ s, p, o, limit, collection }) => { - const flow = socket.flow(flowId); - const sTerm: Term | undefined = s !== undefined && s.length > 0 ? { t: "i", i: s } : undefined; - const pTerm: Term | undefined = p !== undefined && p.length > 0 ? { t: "i", i: p } : undefined; - const oTerm: Term | undefined = o !== undefined && o.length > 0 ? { t: "i", i: o } : undefined; - const triples = await flow.triplesQuery(sTerm, pTerm, oTerm, limit, collection); - return { content: [{ type: "text" as const, text: JSON.stringify(triples, null, 2) }] }; + ({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)), + ); }, ); - // --- Graph Embeddings Query --- server.tool( "graph_embeddings_query", "Find entities similar to a text query using vector embeddings", @@ -161,17 +205,20 @@ export function createMcpServer(config: { limit: z.number().optional().describe("Max results"), collection: z.string().optional().describe("Collection name"), }, - async ({ query, limit, collection }) => { - const flow = socket.flow(flowId); - // First embed the query, then search - const vectors = await flow.embeddings([query]); - const entities = await flow.graphEmbeddingsQuery( - vectors[0], - limit ?? 10, - collection, - ); - return { content: [{ type: "text" as const, text: JSON.stringify(entities, null, 2) }] }; - }, + ({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 ===================== @@ -180,11 +227,7 @@ export function createMcpServer(config: { "get_config_all", "Get all configuration values", {}, - async () => { - const cfg = socket.config(); - const resp = await cfg.getConfigAll(); - return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; - }, + () => runJsonTool(gatewayRequest(() => socket.config().getConfigAll())), ); server.tool( @@ -198,11 +241,7 @@ export function createMcpServer(config: { }), ).describe("Config keys to retrieve"), }, - async ({ keys }) => { - const cfg = socket.config(); - const resp = await cfg.getConfig(keys); - return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; - }, + ({keys}) => runJsonTool(gatewayRequest(() => socket.config().getConfig(keys))), ); server.tool( @@ -217,11 +256,7 @@ export function createMcpServer(config: { }), ).describe("Key-value entries to set"), }, - async ({ values }) => { - const cfg = socket.config(); - const resp = await cfg.putConfig(values); - return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; - }, + ({values}) => runJsonTool(gatewayRequest(() => socket.config().putConfig(values))), ); server.tool( @@ -231,11 +266,7 @@ export function createMcpServer(config: { type: z.string().describe("Config type"), key: z.string().describe("Config key"), }, - async ({ type, key }) => { - const cfg = socket.config(); - const resp = await cfg.deleteConfig({ type, key }); - return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; - }, + ({type, key}) => runJsonTool(gatewayRequest(() => socket.config().deleteConfig({type, key}))), ); // ===================== Flow management tools ===================== @@ -244,11 +275,7 @@ export function createMcpServer(config: { "get_flows", "List all available flows", {}, - async () => { - const flows = socket.flows(); - const ids = await flows.getFlows(); - return { content: [{ type: "text" as const, text: JSON.stringify(ids, null, 2) }] }; - }, + () => runJsonTool(gatewayRequest(() => socket.flows().getFlows())), ); server.tool( @@ -257,11 +284,7 @@ export function createMcpServer(config: { { flow_id: z.string().describe("Flow ID to retrieve"), }, - async ({ flow_id }) => { - const flows = socket.flows(); - const def = await flows.getFlow(flow_id); - return { content: [{ type: "text" as const, text: JSON.stringify(def, null, 2) }] }; - }, + ({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().getFlow(flow_id))), ); server.tool( @@ -273,11 +296,10 @@ export function createMcpServer(config: { description: z.string().describe("Flow description"), parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"), }, - async ({ flow_id, blueprint_name, description, parameters }) => { - const flows = socket.flows(); - const resp = await flows.startFlow(flow_id, blueprint_name, description, parameters); - return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; - }, + ({flow_id, blueprint_name, description, parameters}) => + runJsonTool( + gatewayRequest(() => socket.flows().startFlow(flow_id, blueprint_name, description, parameters)), + ), ); server.tool( @@ -286,11 +308,7 @@ export function createMcpServer(config: { { flow_id: z.string().describe("Flow ID to stop"), }, - async ({ flow_id }) => { - const flows = socket.flows(); - const resp = await flows.stopFlow(flow_id); - return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; - }, + ({flow_id}) => runJsonTool(gatewayRequest(() => socket.flows().stopFlow(flow_id))), ); // ===================== Library (document) tools ===================== @@ -299,11 +317,7 @@ export function createMcpServer(config: { "get_documents", "List all documents in the library", {}, - async () => { - const lib = socket.librarian(); - const docs = await lib.getDocuments(); - return { content: [{ type: "text" as const, text: JSON.stringify(docs, null, 2) }] }; - }, + () => runJsonTool(gatewayRequest(() => socket.librarian().getDocuments())), ); server.tool( @@ -317,18 +331,19 @@ export function createMcpServer(config: { tags: z.array(z.string()).optional().describe("Document tags"), id: z.string().optional().describe("Optional document ID"), }, - async ({ document, mime_type, title, comments, tags, id }) => { - const lib = socket.librarian(); - const resp = await lib.loadDocument( - document, - mime_type, - title, - comments ?? "", - tags ?? [], - id, - ); - return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; - }, + ({document, mime_type, title, comments, tags, id}) => + runJsonTool( + gatewayRequest(() => + socket.librarian().loadDocument( + document, + mime_type, + title, + comments ?? "", + tags ?? [], + id, + ) + ), + ), ); server.tool( @@ -338,11 +353,7 @@ export function createMcpServer(config: { id: z.string().describe("Document ID to remove"), collection: z.string().optional().describe("Collection name"), }, - async ({ id, collection }) => { - const lib = socket.librarian(); - const resp = await lib.removeDocument(id, collection); - return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; - }, + ({id, collection}) => runJsonTool(gatewayRequest(() => socket.librarian().removeDocument(id, collection))), ); // ===================== Prompt tools ===================== @@ -351,11 +362,7 @@ export function createMcpServer(config: { "get_prompts", "List available prompt templates", {}, - async () => { - const cfg = socket.config(); - const prompts = await cfg.getPrompts(); - return { content: [{ type: "text" as const, text: JSON.stringify(prompts, null, 2) }] }; - }, + () => runJsonTool(gatewayRequest(() => socket.config().getPrompts())), ); server.tool( @@ -364,11 +371,7 @@ export function createMcpServer(config: { { id: z.string().describe("Prompt template ID"), }, - async ({ id }) => { - const cfg = socket.config(); - const prompt = await cfg.getPrompt(id); - return { content: [{ type: "text" as const, text: JSON.stringify(prompt, null, 2) }] }; - }, + ({id}) => runJsonTool(gatewayRequest(() => socket.config().getPrompt(id))), ); // ===================== Knowledge core tools ===================== @@ -377,11 +380,7 @@ export function createMcpServer(config: { "get_knowledge_cores", "List available knowledge graph cores", {}, - async () => { - const knowledge = socket.knowledge(); - const cores = await knowledge.getKnowledgeCores(); - return { content: [{ type: "text" as const, text: JSON.stringify(cores, null, 2) }] }; - }, + () => runJsonTool(gatewayRequest(() => socket.knowledge().getKnowledgeCores())), ); server.tool( @@ -391,11 +390,7 @@ export function createMcpServer(config: { id: z.string().describe("Knowledge core ID"), collection: z.string().optional().describe("Collection name"), }, - async ({ id, collection }) => { - const knowledge = socket.knowledge(); - const resp = await knowledge.deleteKgCore(id, collection); - return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; - }, + ({id, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().deleteKgCore(id, collection))), ); server.tool( @@ -406,31 +401,42 @@ export function createMcpServer(config: { flow: z.string().describe("Flow to use for loading"), collection: z.string().optional().describe("Collection name"), }, - async ({ id, flow, collection }) => { - const knowledge = socket.knowledge(); - const resp = await knowledge.loadKgCore(id, flow, collection); - return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; - }, + ({id, flow, collection}) => runJsonTool(gatewayRequest(() => socket.knowledge().loadKgCore(id, flow, collection))), ); - return { server, socket }; + return {server, socket}; } -export async function run(): Promise { - const { server, socket } = createMcpServer({ - gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/rpc", - user: process.env.USER_ID ?? "mcp", - flowId: process.env.FLOW_ID ?? "default", - ...(process.env.GATEWAY_SECRET !== undefined - ? { token: process.env.GATEWAY_SECRET } - : {}), - }); - +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(); - await server.connect(transport); - process.on("SIGINT", () => { - socket.close(); - process.exit(0); + 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 { + return stdioRuntime.runPromise(runProgram); +} + +export function runMain(): void { + NodeRuntime.runMain(runProgram); }