Migrate strict Effect runtime surfaces

This commit is contained in:
elpresidank 2026-06-02 00:22:04 -05:00
parent f6878d4dd7
commit b4ee2b691f
35 changed files with 1717 additions and 1410 deletions

View file

@ -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<RequestResponse>` 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<RequestResponse>` 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<string, Promise<RequestResponse<unknown, unknown>>>`, 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<undefined>` 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.

View file

@ -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;
};

View file

@ -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<T>(msg: JsMsg, decoded: T): NatsMessage<T> {
};
}
const hasJsMsg = Predicate.hasProperty("_jsMsg");
function isAckableJsMsg(value: unknown): value is Pick<JsMsg, "ack" | "nak"> {
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<T>(message: Message<T>): message is NatsMessage<T> {
return hasJsMsg(message) && isAckableJsMsg(message._jsMsg);
}
function makeNatsProducer<T>(
js: JetStreamClient,
subject: string,
schema?: S.Top,
schema?: S.Codec<T, unknown>,
): BackendProducer<T> {
return {
send: async (message, properties) => {
const encoded = schema !== undefined
? S.encodeUnknownSync(schema as S.Codec<unknown, unknown>)(message)
: message;
const data = sc.encode(JSON.stringify(encoded));
const opts: Record<string, unknown> = {};
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<string, unknown> = {};
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<T>(
subscription: string,
initialPosition: "latest" | "earliest",
streamName: string,
schema?: S.Top,
schema?: S.Codec<T, unknown>,
): InitializableBackendConsumer<T> {
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<unknown, unknown>)(parsed) as T
: parsed as T;
return makeNatsMessage(msg, decoded);
},
acknowledge: async (message) => {
const natsMsg = message as NatsMessage<T>;
natsMsg._jsMsg.ack();
},
negativeAcknowledge: async (message) => {
const natsMsg = message as NatsMessage<T>;
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<string>();
const ensureConnected = async (): Promise<void> => {
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<string> => {
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 <T>(options: CreateProducerOptions) => {
await ensureConnected();
await ensureStream(options.topic);
const client = js;
if (client === null) throw new Error("NATS backend not connected");
return makeNatsProducer<T>(client, options.topic, options.schema);
},
createConsumer: async <T>(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<T>(
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: <T>(options: CreateProducerOptions<T>) =>
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<T>(client, options.topic, options.schema);
}),
),
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
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<T>(
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;
}
}),
),
};
}

View file

@ -20,10 +20,10 @@ import { pubSubError } from "../errors.js";
export interface PubSubService {
readonly backend: PubSubBackend;
readonly createProducer: <T>(
options: CreateProducerOptions,
options: CreateProducerOptions<T>,
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
readonly createConsumer: <T>(
options: CreateConsumerOptions,
options: CreateConsumerOptions<T>,
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
}
@ -41,12 +41,12 @@ export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgrap
export function makePubSubService(backend: PubSubBackend): PubSubService {
return {
backend,
createProducer: <T>(options: CreateProducerOptions) =>
createProducer: <T>(options: CreateProducerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createProducer<T>(options),
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
}),
createConsumer: <T>(options: CreateConsumerOptions) =>
createConsumer: <T>(options: CreateConsumerOptions<T>) =>
Effect.tryPromise({
try: () => backend.createConsumer<T>(options),
catch: (error) => pubSubError(`createConsumer:${options.topic}`, error),

View file

@ -29,21 +29,21 @@ export interface BackendConsumer<T = unknown> {
export type ConsumerType = "shared" | "exclusive" | "failover";
export type InitialPosition = "latest" | "earliest";
export interface CreateProducerOptions {
export interface CreateProducerOptions<T = unknown> {
topic: string;
schema?: S.Top;
schema?: S.Codec<T, unknown>;
}
export interface CreateConsumerOptions {
export interface CreateConsumerOptions<T = unknown> {
topic: string;
subscription: string;
initialPosition?: InitialPosition;
consumerType?: ConsumerType;
schema?: S.Top;
schema?: S.Codec<T, unknown>;
}
export interface PubSubBackend {
createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>>;
createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>>;
createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>>;
close(): Promise<void>;
}

View file

@ -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,

View file

@ -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<T> = (
@ -44,83 +51,140 @@ export interface Consumer<T> {
export function makeConsumer<T>(options: ConsumerOptions<T>): Consumer<T> {
let backend: BackendConsumer<T> | 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<T>, flow: FlowContext): Promise<void> => {
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<string, string>,
flow: FlowContext,
): Effect.Effect<void, TooManyRequestsError | MessagingHandlerError> =>
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<T>,
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<void> => {
while (running) {
let msg: Message<T> | 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<T>({
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<T>({
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -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<T> {
readonly start: () => Promise<void>;
@ -23,28 +24,38 @@ export function makeProducer<T>(
let effectProducer: EffectProducer<T> | null = null;
return {
start: async () => {
const backend = await pubsub.createProducer<T>({ 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<T>({ 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;
}),
),
);
}
}),
),
};
}

View file

@ -44,37 +44,42 @@ export function makeRequestResponse<TReq, TRes>(
let runtime: RequestResponseRuntime<TReq, TRes> | 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<TReq, TRes>(
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<TReq, TRes>(
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<TReq, TRes>(
* 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

View file

@ -44,9 +44,9 @@ export type EffectMessageHandler<T, E = never, R = never> = (
flow: FlowContext<R>,
) => Effect.Effect<void, E, R>;
export interface EffectProducerOptions {
export interface EffectProducerOptions<T = unknown> {
readonly topic: string;
readonly schema?: S.Top;
readonly schema?: S.Codec<T, unknown>;
readonly metrics?: ProducerMetrics;
}
@ -62,7 +62,7 @@ export interface EffectConsumerOptions<T, E = never, R = never> {
readonly handler: EffectMessageHandler<T, E, R>;
readonly concurrency?: number;
readonly initialPosition?: "latest" | "earliest";
readonly schema?: S.Top;
readonly schema?: S.Codec<T, unknown>;
readonly receiveTimeoutMs?: number;
readonly errorBackoffMs?: number;
readonly rateLimitRetryMs?: number;
@ -73,12 +73,12 @@ export interface EffectConsumer {
readonly fibers: ReadonlyArray<Fiber.Fiber<void, never>>;
}
export interface EffectRequestResponseOptions {
export interface EffectRequestResponseOptions<TReq = unknown, TRes = unknown> {
readonly requestTopic: string;
readonly responseTopic: string;
readonly subscription: string;
readonly requestSchema?: S.Top;
readonly responseSchema?: S.Top;
readonly requestSchema?: S.Codec<TReq, unknown>;
readonly responseSchema?: S.Codec<TRes, unknown>;
}
export interface EffectRequestOptions<TRes, E = never, R = never> {
@ -96,7 +96,7 @@ export interface EffectRequestResponse<TReq, TRes> {
export interface ProducerFactoryService {
readonly make: <T>(
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
) => Effect.Effect<EffectProducer<T>, PubSubError, Scope.Scope>;
}
@ -109,7 +109,7 @@ export interface ConsumerFactoryService {
export interface RequestResponseFactoryService {
readonly make: <TReq, TRes>(
options: EffectRequestResponseOptions,
options: EffectRequestResponseOptions<TReq, TRes>,
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, PubSubError, Scope.Scope>;
}
@ -138,7 +138,7 @@ export class FlowRuntime extends Context.Service<FlowRuntime, FlowRuntimeService
export function makeEffectProducerHandle<T>(
backend: BackendProducer<T>,
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
): EffectProducer<T> {
return {
send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) =>
@ -168,9 +168,9 @@ export function makeEffectProducerHandle<T>(
export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* <T>(
pubsub: PubSubService,
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
) {
const createOptions: CreateProducerOptions = options.schema === undefined
const createOptions: CreateProducerOptions<T> = options.schema === undefined
? { topic: options.topic }
: { topic: options.topic, schema: options.schema };
const backend = yield* pubsub.createProducer<T>(createOptions);
@ -326,7 +326,7 @@ export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPub
options: EffectConsumerOptions<T, E, R>,
flow: FlowContext<R>,
) {
const createOptions: CreateConsumerOptions = {
const createOptions: CreateConsumerOptions<T> = {
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<TReq, TRes>,
) {
const producerOptions: CreateProducerOptions = options.requestSchema === undefined
const producerOptions: CreateProducerOptions<TReq> = options.requestSchema === undefined
? { topic: options.requestTopic }
: { topic: options.requestTopic, schema: options.requestSchema };
const producerBackend = yield* pubsub.createProducer<TReq>(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<TRes> = {
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")(<T>(options: EffectProducerOptions) =>
make: Effect.fn("ProducerFactory.make")(<T>(options: EffectProducerOptions<T>) =>
makeEffectProducerFromPubSub<T>(pubsub, options),
),
};
@ -526,13 +526,11 @@ export function makeRequestResponseFactoryService(
pubsub: PubSubService,
config: MessagingRuntimeConfig,
): RequestResponseFactoryService {
const make = Effect.fn("RequestResponseFactory.make")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
) {
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
}) as RequestResponseFactoryService["make"];
return { make };
return {
make: Effect.fn("RequestResponseFactory.make")(<TReq, TRes>(
options: EffectRequestResponseOptions<TReq, TRes>,
) => makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options)),
};
}
export const ProducerFactoryLive = Layer.effect(
@ -589,7 +587,7 @@ export const MessagingRuntimeLive = Layer.mergeAll(
);
export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* <T>(
options: EffectProducerOptions,
options: EffectProducerOptions<T>,
) {
const pubsub = yield* PubSub;
return yield* makeEffectProducerFromPubSub<T>(pubsub, options);
@ -605,7 +603,7 @@ export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(func
});
export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* <TReq, TRes>(
options: EffectRequestResponseOptions,
options: EffectRequestResponseOptions<TReq, TRes>,
) {
const pubsub = yield* PubSub;
const config = yield* loadMessagingRuntimeConfig();

View file

@ -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<T> = {
queue: AsyncQueue<T>;
@ -32,28 +34,33 @@ export function makeAsyncQueue<T>(): AsyncQueue<T> {
buffer.push(item);
}
},
pop: async (timeoutMs) => {
pop: (timeoutMs) => {
const buffered = buffer.shift();
if (buffered !== undefined) return buffered;
return new Promise<T>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
if (buffered !== undefined) return Promise.resolve(buffered);
const take = Effect.callback<T>((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<T>(
): Subscriber<T> {
let backend: BackendConsumer<T> | null = null;
let running = false;
let fiber: Fiber.Fiber<void, never> | null = null;
// ID-specific subscriptions (request/response correlation)
const idSubscribers = new Map<string, Resolver<T>>();
// Wildcard subscribers (receive all messages)
const allSubscribers = new Map<string, Resolver<T>>();
const dispatchLoop = async (): Promise<void> => {
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<T>({
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<T>({
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<T>();
idSubscribers.set(id, { queue });

View file

@ -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<RunError = ProcessorLifecycleError, RunRequirements = never> {
readonly [processorRunErrorType]?: RunError;
readonly [processorRunRequirementsType]?: RunRequirements;
readonly start: () => Promise<void>;
readonly start: (context: Context.Context<RunRequirements>) => Promise<void>;
readonly stop: () => Promise<void>;
startEffect(): unknown;
stopEffect(): unknown;
startEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
stopEffect: Effect.Effect<void, ProcessorLifecycleError>;
}
export interface AsyncProcessorRuntime<
@ -53,8 +53,8 @@ export interface AsyncProcessorRuntime<
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
readonly onShutdown: (callback: () => Promise<void>) => void;
readonly run: () => Promise<void>;
runEffect(): unknown;
readonly run: (context: Context.Context<RunRequirements>) => Promise<void>;
runEffect: Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements>;
}
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<void, RunError | ProcessorLifecycleError>,
);
},
stop: async () => {
await Effect.runPromise(
processor.stopEffect() as Effect.Effect<void, ProcessorLifecycleError>,
);
},
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<void, RunError, RunRequirements>
);
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<void, RunError>,
),
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<void, RunError, RunRequirements>;
});
},
};
@ -208,13 +203,21 @@ export const AsyncProcessor = Object.assign(
return makeAsyncProcessor(config);
},
{
async launch<T extends ProcessorRuntime<unknown, unknown>>(
launch<T extends ProcessorRuntime<unknown, never>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void> {
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(
<RunError = ProcessorLifecycleError, RunRequirements = never>(
config: ProcessorConfig,
): AsyncProcessor<RunError, RunRequirements>;
launch<T extends ProcessorRuntime<unknown, unknown>>(
launch<T extends ProcessorRuntime<unknown, never>>(
this: new (config: ProcessorConfig) => T,
id: string,
): Promise<void>;

View file

@ -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<FlowRequirements = never>
readonly configHandlers: ConfigHandler[];
readonly isRunning: () => boolean;
readonly registerConfigHandler: (handler: ConfigHandler) => void;
readonly registerSpecification: <Requirements extends FlowRequirements>(
spec: Spec<Requirements>,
) => void;
readonly registerSpecification: (spec: Spec<FlowRequirements>) => void;
readonly specifications: ReadonlyArray<Spec<FlowRequirements>>;
}
@ -106,6 +105,20 @@ const ConfigPushSchema = S.Struct({
config: S.Record(S.String, S.Unknown),
});
const isStringRecord = (value: unknown): value is Record<string, unknown> =>
Predicate.isObject(value) && !Array.isArray(value);
const isTopicsRecord = (value: unknown): value is Record<string, string> =>
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<string, FlowDefinition> | 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<FlowRequirements = never>(
},
});
const startEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
const effect = base.startEffect() as FlowProcessorStartEffect<FlowRequirements>;
const makeStartEffect = (): FlowProcessorStartEffect<FlowRequirements> => {
const effect = base.startEffect;
return options.provide?.(effect) ?? effect;
};
@ -362,24 +379,29 @@ export function makeFlowProcessor<FlowRequirements = never>(
...base,
specifications,
registerSpecification: (spec) => {
specifications.push(spec as Spec<FlowRequirements>);
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<void, PubSubError | FlowRuntimeError | ProcessorLifecycleError>;
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;

View file

@ -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<Requirements = never>(
definition: FlowDefinition,
specifications: ReadonlyArray<Spec<Requirements>>,
) {
const producers = new Map<string, EffectProducer<unknown>>();
const producers = new Map<string, EffectProducer<never>>();
const consumers = new Map<string, EffectConsumer>();
const requestors = new Map<string, EffectRequestResponse<unknown, unknown>>();
const requestors = new Map<string, EffectRequestResponse<never, unknown>>();
const parameters = new Map<string, unknown>();
let compatibilityScope: Scope.Closeable | null = null;
const ensureCompatibilityScope = async (): Promise<Scope.Closeable> => {
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 = <TRes>(
options: FlowRequestOptions<TRes> | undefined,
@ -105,41 +106,58 @@ export function makeFlow<Requirements = never>(
}
});
},
async start(): Promise<void> {
if (compatibilityScope !== null) {
await flow.stop();
}
await flow.runInCompatibilityScope(
flow.startEffect() as Effect.Effect<void, PubSubError, SpecRuntimeRequirements>,
pubsub,
start(context: Context.Context<Requirements>): Promise<void> {
return Effect.runPromise(
Effect.gen(function* () {
if (compatibilityScope !== null) {
yield* flow.stopEffect();
}
yield* flow.runInCompatibilityScopeEffect(flow.startEffect(), pubsub, context);
}),
);
},
async stop(): Promise<void> {
const scope = compatibilityScope;
compatibilityScope = null;
if (scope !== null) {
await Effect.runPromise(Scope.close(scope, Exit.void));
}
flow.clearResources();
stop(): Promise<void> {
return Effect.runPromise(flow.stopEffect());
},
async runInCompatibilityScope<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements>,
stopEffect(): Effect.Effect<void> {
return Effect.gen(function* () {
const scope = compatibilityScope;
compatibilityScope = null;
if (scope !== null) {
yield* Scope.close(scope, Exit.void);
}
flow.clearResources();
});
},
runInCompatibilityScopeEffect<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
): Promise<A> {
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<Requirements>,
) {
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<A, E>(
effect: Effect.Effect<A, E, SpecRuntimeRequirements | Requirements>,
runtimePubsub: PubSubBackend,
context: Context.Context<Requirements>,
): Promise<A> {
return Effect.runPromise(flow.runInCompatibilityScopeEffect(effect, runtimePubsub, context));
},
clearResources(): void {
producers.clear();
@ -147,13 +165,13 @@ export function makeFlow<Requirements = never>(
requestors.clear();
parameters.clear();
},
registerProducer(registerName: string, producer: EffectProducer<unknown>): void {
registerProducer<T>(registerName: string, producer: EffectProducer<T>): void {
producers.set(registerName, producer);
},
registerConsumer(registerName: string, consumer: EffectConsumer): void {
consumers.set(registerName, consumer);
},
registerRequestor(registerName: string, rr: EffectRequestResponse<unknown, unknown>): void {
registerRequestor<TReq, TRes>(registerName: string, rr: EffectRequestResponse<TReq, TRes>): void {
requestors.set(registerName, rr);
},
setParameter(parameterName: string, value: unknown): void {

View file

@ -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> = Processor extends ProcessorRuntime<infer Error, unknown> ? Error : never;
type ProcessorRunRequirements<Processor> = Processor extends ProcessorRuntime<unknown, infer Requirements> ? Requirements : never;
export interface ProcessorProgramOptions<
Config extends ProcessorConfig,
Error,
Requirements,
Processor extends ProcessorRuntime<unknown, unknown>,
LoadError,
LoadRequirements,
RunError,
RunRequirements,
> {
readonly id: string;
readonly make: (config: Config) => Processor;
readonly loadConfig?: Effect.Effect<Config, Error, Requirements>;
readonly make: (config: Config) => ProcessorRuntime<RunError, RunRequirements>;
readonly loadConfig?: Effect.Effect<Config, LoadError, LoadRequirements>;
}
export interface FlowProcessorProgramOptions<
@ -68,18 +66,14 @@ export interface FlowProcessorProgramOptions<
) => Layer.Layer<FlowRequirements, Error, LayerRequirements>;
}
export function runProcessorScoped<
export const runProcessorScoped = Effect.fn("runProcessorScoped")(function* <
Config extends ProcessorConfig,
Processor extends ProcessorRuntime<unknown, unknown>,
RunError,
RunRequirements,
>(
config: Config,
make: (config: Config) => Processor,
): Effect.Effect<
void,
ProcessorRunError<Processor> | ProcessorLifecycleError,
PubSub | Scope.Scope | ProcessorRunRequirements<Processor>
> {
return Effect.gen(function* () {
make: (config: Config) => ProcessorRuntime<RunError, RunRequirements>,
) {
const pubsub = yield* PubSub;
const runtimeConfig = {
...config,
@ -103,23 +97,17 @@ export function runProcessorScoped<
),
);
yield* (
processor.startEffect() as Effect.Effect<
void,
ProcessorRunError<Processor> | ProcessorLifecycleError,
ProcessorRunRequirements<Processor>
>
);
});
}
yield* processor.startEffect;
});
export function makeProcessorProgram<
Config extends ProcessorConfig,
Error = never,
Requirements = never,
Processor extends ProcessorRuntime<unknown, unknown> = ProcessorRuntime,
LoadError = never,
LoadRequirements = never,
RunError = ProcessorLifecycleError,
RunRequirements = never,
>(
options: ProcessorProgramOptions<Config, Error, Requirements, Processor>,
options: ProcessorProgramOptions<Config, LoadError, LoadRequirements, RunError, RunRequirements>,
) {
return Effect.scoped(
Effect.gen(function* () {
@ -147,7 +135,7 @@ export function makeProcessorProgram<
),
),
);
const processorEffect = runProcessorScoped<Config, Processor>(
const processorEffect = runProcessorScoped<Config, RunError, RunRequirements>(
runtimeConfig,
options.make,
);
@ -173,12 +161,37 @@ export function makeFlowProcessorProgram<
FlowRequirements = never,
LayerRequirements = never,
>(
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements>,
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, LayerRequirements> & {
readonly layer: (config: Config) => Layer.Layer<FlowRequirements, Error, LayerRequirements>;
},
): Effect.Effect<
void,
never,
Error | EffectConfig.ConfigError | PubSubError | FlowRuntimeError,
LayerRequirements
> {
>;
export function makeFlowProcessorProgram<
Config extends ProcessorConfig,
Error = never,
FlowRequirements = never,
>(
options: FlowProcessorProgramOptions<Config, Error, FlowRequirements, FlowRequirements> & {
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<Config, Error, FlowRequirements, LayerRequirements>,
) {
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<FlowRequirements, Error, LayerRequirements>);
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)));
}),
);
}

View file

@ -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<Llm, LlmServiceShape>()(
) {}
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<void, MessagingDeliveryError>;
},
) {
const context = yield* Effect.context<never>();
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;
}

View file

@ -62,14 +62,8 @@ export function makeConsumerSpec<T, E = never, R = never>(
return {
name,
addEffect,
add: async (flow, pubsub, definition) => {
const effect = addEffect(flow as Flow<R>, definition) as Effect.Effect<
void,
PubSubError,
SpecRuntimeRequirements
>;
await flow.runInCompatibilityScope(effect, pubsub);
},
add: (flow, pubsub, definition, context) =>
flow.runInCompatibilityScope(addEffect(flow, definition), pubsub, context),
};
}

View file

@ -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)),
};
}

View file

@ -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<T>(name: string): ProducerSpec<T> {
const topic = definition.topics?.[name] ?? name;
const factory = yield* ProducerFactory;
const producer = yield* factory.make<T>({ topic });
flow.registerProducer(name, producer as EffectProducer<unknown>);
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),
};
}

View file

@ -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<TReq, TRes>(
responseTopic,
subscription: `${flow.processorId}-${flow.name}-${name}`,
});
flow.registerRequestor(name, requestor as EffectRequestResponse<unknown, unknown>);
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),
};
}

View file

@ -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<Requirements = never> {
flow: Flow<Requirements>,
definition: FlowDefinition,
): Effect.Effect<void, SpecRuntimeError, SpecRuntimeRequirements | Requirements>;
add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void>;
add(
flow: Flow<Requirements>,
pubsub: PubSubBackend,
definition: FlowDefinition,
context: Context.Context<Requirements>,
): Promise<void>;
}

View file

@ -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>", "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<void>((resolve, reject) => {
yield* Effect.callback<void, ReturnType<typeof cliCommandError>>((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();
}
});
}),
)),
);
}

View file

@ -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("<key>", "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("<key>", "Config key (format: type/key)")
.argument("<value>", "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("<key>", "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);
}),
)),
);
}

View file

@ -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...>", "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);
}),
)),
);
}

View file

@ -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("<id>", "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 <name>", "Blueprint name")
.option("-d, --description <text>", "Flow description", "")
.option("-p, --parameters <json>", "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<string, unknown> | 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("<id>", "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);
}),
)),
);
}

View file

@ -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 <n>", "Max entities", "50")
.option("--triple-limit <n>", "Max triples per entity", "30")
.option("--collection <name>", "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("<query>", "Natural language query")
.option("--doc-limit <n>", "Max documents", "20")
.option("--collection <name>", "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);
}),
)),
);
}

View file

@ -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 <text>", "Comments", "")
.option("--tags <tags...>", "Document tags")
.option("--id <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("<id>", "Document ID to remove")
.option("--collection <name>", "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);
}),
)),
);
}

View file

@ -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 <iri>", "Object IRI or literal")
.option("-l, --limit <n>", "Max results", "20")
.option("--collection <name>", "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);
}),
)),
);
}

View file

@ -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>()(
"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<BaseApi> {
export function createSocketEffect(opts: CliOpts): Effect.Effect<BaseApi, CliCommandError> {
const socket = createTrustGraphSocket(opts.user, opts.token, opts.gateway);
// Wait for the socket to reach an open state
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
unsub();
reject(new Error("Timed out waiting for WebSocket connection"));
}, 15_000);
return Effect.callback<void, CliCommandError>((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<BaseApi> {
return Effect.runPromise(createSocketEffect(opts));
}
export const withSocket = <A, E, R>(
cmd: Command,
use: (socket: BaseApi, opts: CliOpts) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
createSocketEffect(getOpts(cmd)),
(socket) => use(socket, getOpts(cmd)),
(socket) =>
Effect.sync(() => {
socket.close();
}),
);

View file

@ -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<string, any>;
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<string, WorkspaceStore>();

View file

@ -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<string, KnowledgeCore>();

View file

@ -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<string, any>;
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<string, FlowInstance>();

View file

@ -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<string, any>;
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<string, DocumentMetadata>();

View file

@ -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 = <T extends Tool.Any>(
tool: T,
const annotateTool = <Name extends string, Config extends {
readonly parameters: S.Top
readonly success: S.Top
readonly failure: S.Top
readonly failureMode: Tool.FailureMode
}, Requirements>(
tool: Tool.Tool<Name, Config, Requirements>,
annotations: {
readonly title: string
readonly readOnly: boolean
@ -17,14 +24,14 @@ const annotateTool = <T extends Tool.Any>(
readonly openWorld: boolean
readonly strict?: boolean
},
): T =>
): Tool.Tool<Name, Config, Requirements> =>
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>("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<TrustGraphMcpConfig, TrustGraphMcpConfigShape>()(
"@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<TrustGraphSocket, BaseApi>
}
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 = <E>(
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<AgentSuccess, AgentError>((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)

View file

@ -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<string, unknown> {
readonly content: Array<ToolTextContent>
}
class StdioMcpError extends S.TaggedErrorClass<StdioMcpError>()(
"StdioMcpError",
{
cause: S.DefectWithStack,
message: S.String,
},
) {
}
const encodeJsonText = S.encodeUnknownEffect(S.UnknownFromJsonString);
const toErrorMessage = (cause: unknown): string => {
if (Predicate.isError(cause) && cause.message.length > 0) {
return cause.message;
}
if (Predicate.isString(cause) && cause.length > 0) {
return cause;
}
if (Predicate.isObject(cause) && Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message) && cause.message.length > 0) {
return cause.message;
}
return "TrustGraph MCP stdio operation failed";
};
const stdioMcpError = (cause: unknown) =>
StdioMcpError.make({cause, message: toErrorMessage(cause)});
const textResult = (text: string): ToolTextResult => ({
content: [{type: "text", text}],
});
const gatewayRequest = <A>(request: () => Promise<A>) =>
Effect.tryPromise({
try: request,
catch: stdioMcpError,
});
const jsonText = (value: unknown) =>
encodeJsonText(value).pipe(
Effect.mapError(stdioMcpError),
);
const runTextTool = (effect: Effect.Effect<string, StdioMcpError>) =>
Effect.runPromise(effect.pipe(Effect.map(textResult)));
const runJsonTool = (effect: Effect.Effect<unknown, StdioMcpError>) =>
Effect.runPromise(effect.pipe(Effect.flatMap(jsonText), Effect.map(textResult)));
export function createMcpServer(config: {
gatewayUrl: string;
@ -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<void>((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<string, StdioMcpError>((resume) => {
let fullAnswer = "";
socket.flow(flowId).agent(
question,
() => {},
() => {},
(chunk, complete) => {
fullAnswer += chunk;
if (complete) {
resume(Effect.succeed(fullAnswer));
}
},
(cause) => resume(Effect.fail(stdioMcpError(cause))),
);
}),
),
);
// --- 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<void> {
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<void> {
return stdioRuntime.runPromise(runProgram);
}
export function runMain(): void {
NodeRuntime.runMain(runProgram);
}