Migrate config service to ref-backed Effect runtime

This commit is contained in:
elpresidank 2026-06-02 00:40:44 -05:00
parent b4ee2b691f
commit 88db18fbda
4 changed files with 907 additions and 755 deletions

View file

@ -12,23 +12,23 @@ Verified source roots:
- Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4` - Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4`
- Installed Effect beta used by this workspace: `ts/node_modules/effect` - Installed Effect beta used by this workspace: `ts/node_modules/effect`
Current signal counts from `ts/packages` after the 2026-06-02 strict tsgo Current signal counts from `ts/packages` after the 2026-06-02 config service
slice: runtime slice:
| Signal | Count | | Signal | Count |
| --- | ---: | | --- | ---: |
| `Effect.runPromise` | 208 | | `Effect.runPromise` | 207 |
| `Map<` | 58 | | `Map<` | 65 |
| `WebSocket` | 45 | | `WebSocket` | 51 |
| `new Map` | 45 | | `new Map` | 47 |
| `toPromiseRequestor` | 19 | | `toPromiseRequestor` | 19 |
| `makeAsyncProcessor` | 19 | | `makeAsyncProcessor` | 19 |
| `receive(` | 18 | | `receive(` | 18 |
| `while (` | 13 | | `while (` | 12 |
| `new Error` | 14 | | `new Error` | 14 |
| `new Promise` | 10 | | `new Promise` | 10 |
| `JSON.parse` | 8 | | `JSON.parse` | 8 |
| `localStorage` | 8 | | `localStorage` | 9 |
| `JSON.stringify` | 6 | | `JSON.stringify` | 6 |
| `setTimeout` | 4 | | `setTimeout` | 4 |
| `process.env` | 3 | | `process.env` | 3 |
@ -78,7 +78,7 @@ Notes:
### 2026-06-02: Strict Base, CLI, MCP, And tsgo Slice ### 2026-06-02: Strict Base, CLI, MCP, And tsgo Slice
- Status: migrated, root-verified, ready to commit. - Status: migrated, root-verified, committed, and pushed.
- Completed: - Completed:
- Base messaging, NATS backend, producer, consumer, subscriber, - Base messaging, NATS backend, producer, consumer, subscriber,
request/response, runtime factories, processor programs, flow specs, and request/response, runtime factories, processor programs, flow specs, and
@ -102,6 +102,37 @@ Notes:
- `cd ts && bun run build` - `cd ts && bun run build`
- `git diff --check` - `git diff --check`
### 2026-06-02: ConfigService Ref-Backed State Slice
- Status: migrated and root-verified.
- Completed:
- `ts/packages/flow/src/config/service.ts` now models runtime state as a
`SynchronizedRef<ConfigServiceState>` instead of adding mutable
`store`, `version`, consumer, and producer fields onto the processor
object.
- Config operations have Effect-returning handlers with Promise facades only
on the exported compatibility methods.
- Request narrowing now uses `effect/Predicate` rather than request-record
type assertions.
- Persistence remains schema-backed and now reads/writes snapshots from the
ref-backed state.
- The consume loop now uses `Effect.whileLoop`; the remaining
`consumer.receive(2000)` call is a pubsub boundary for this service.
- Service startup now exposes `runMain()` through `NodeRuntime.runMain`.
The legacy `run()` Promise facade uses `ManagedRuntime`, and
`ts/scripts/run-config.ts` delegates directly to `runMain()` instead of
owning its own catch/process-exit wrapper.
- Config-service tests cover tagged invalid mutation errors, workspace
persistence, legacy load, concurrent ref-backed mutations, and push
publishing from the stored producer handle.
- Verification:
- `bun run --cwd ts/packages/flow build`
- `bun run --cwd ts/packages/flow test`
- `cd ts && bun run check`
- `cd ts && bun run build`
- `cd ts && bun run test`
- `git diff --check`
## Subagent Findings To Preserve ## Subagent Findings To Preserve
- MCP/workbench: - MCP/workbench:
@ -111,8 +142,9 @@ Notes:
the client API is less Promise-first. the client API is less Promise-first.
- MCP env is now Config-backed; continue that policy for future MCP settings. - MCP env is now Config-backed; continue that policy for future MCP settings.
- Flow stateful services: - Flow stateful services:
- Config, librarian, cores, and flow-manager still have mutable poller - Config service ref-backed state is complete. Librarian, cores, and
service objects. These remain good candidates for `Context` services, flow-manager still have mutable poller service objects. These remain good
candidates for `Context` services,
scoped layers, `Ref`/`SynchronizedRef`, `Schedule`, and managed scoped layers, `Ref`/`SynchronizedRef`, `Schedule`, and managed
persistence. persistence.
- Persistence IO should move toward `FileSystem` or `KeyValueStore` where - Persistence IO should move toward `FileSystem` or `KeyValueStore` where
@ -140,10 +172,9 @@ Notes:
## Ranked Findings ## Ranked Findings
### P0: Convert Stateful Flow Services To Scoped Effect Services ### P0: Continue Stateful Flow Services To Scoped Effect Services
- TrustGraph evidence: - TrustGraph evidence:
- `ts/packages/flow/src/config/service.ts`
- `ts/packages/flow/src/librarian/service.ts` - `ts/packages/flow/src/librarian/service.ts`
- `ts/packages/flow/src/cores/service.ts` - `ts/packages/flow/src/cores/service.ts`
- `ts/packages/flow/src/flow-manager/service.ts` - `ts/packages/flow/src/flow-manager/service.ts`
@ -152,8 +183,12 @@ Notes:
`Effect.addFinalizer`, `Config`, `Schema`, `FileSystem`, `Effect.addFinalizer`, `Config`, `Schema`, `FileSystem`,
`KeyValueStore`. `KeyValueStore`.
- Rewrite shape: - Rewrite shape:
- Model one service at a time as a `Context` service plus scoped layer. - Model one remaining service at a time as a `Context` service plus scoped
layer or ref-backed state slice.
- Store mutable service state in `Ref` or `SynchronizedRef`. - Store mutable service state in `Ref` or `SynchronizedRef`.
- Run service main programs with platform runtime entrypoints such as
`NodeRuntime.runMain`; keep `ManagedRuntime` only for compatibility
Promise facades.
- Replace polling sleep loops with schedules where behavior allows. - Replace polling sleep loops with schedules where behavior allows.
- Decode persisted payloads and config with schemas at boundaries. - Decode persisted payloads and config with schemas at boundaries.
- Tests: - Tests:
@ -267,8 +302,8 @@ Notes:
## Recommended PR Order ## Recommended PR Order
1. Config service scoped state migration. 1. RAG and agent requestor bridge removal.
2. RAG and agent requestor bridge removal. 2. Librarian, cores, or flow-manager scoped state migration.
3. Client RPC managed runtime/scoped layer cleanup. 3. Client RPC managed runtime/scoped layer cleanup.
4. Base processor registry and constructor shim redesign. 4. Base processor registry and constructor shim redesign.
5. Gateway RPC callback and client streaming completion cleanup. 5. Gateway RPC callback and client streaming completion cleanup.

View file

@ -1,7 +1,9 @@
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Effect, SynchronizedRef } from "effect";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { topics } from "@trustgraph/base";
import { import {
ConfigServiceError, ConfigServiceError,
makeConfigService, makeConfigService,
@ -16,9 +18,15 @@ import type {
} from "@trustgraph/base"; } from "@trustgraph/base";
class NoopPubSub implements PubSubBackend { class NoopPubSub implements PubSubBackend {
async createProducer<T>(_options: CreateProducerOptions): Promise<BackendProducer<T>> { readonly sentByTopic = new Map<string, Array<unknown>>();
async createProducer<T>(options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
return { return {
send: async () => undefined, send: async (message) => {
const sent = this.sentByTopic.get(options.topic) ?? [];
sent.push(message);
this.sentByTopic.set(options.topic, sent);
},
flush: async () => undefined, flush: async () => undefined,
close: async () => undefined, close: async () => undefined,
}; };
@ -48,10 +56,12 @@ const makeService = (persistPath?: string) =>
describe("ConfigService operations", () => { describe("ConfigService operations", () => {
it("uses tagged errors for invalid mutations", async () => { it("uses tagged errors for invalid mutations", async () => {
const service = makeService(); const service = makeService();
const putRequest: ConfigRequest = { operation: "put" };
const deleteRequest: ConfigRequest = { operation: "delete" };
const putError = await service.handlePut({ operation: "put" } as ConfigRequest) const putError = await service.handlePut(putRequest)
.catch((caught: unknown) => caught); .catch((caught: unknown) => caught);
const deleteError = await service.handleDelete({ operation: "delete" } as ConfigRequest) const deleteError = await service.handleDelete(deleteRequest)
.catch((caught: unknown) => caught); .catch((caught: unknown) => caught);
expect(putError).toBeInstanceOf(ConfigServiceError); expect(putError).toBeInstanceOf(ConfigServiceError);
@ -64,13 +74,14 @@ describe("ConfigService operations", () => {
const dir = await mkdtemp(join(tmpdir(), "trustgraph-config-service-")); const dir = await mkdtemp(join(tmpdir(), "trustgraph-config-service-"));
const persistPath = join(dir, "config.json"); const persistPath = join(dir, "config.json");
const service = makeService(persistPath); const service = makeService(persistPath);
const putRequest: ConfigRequest = {
await service.handlePut({
operation: "put", operation: "put",
values: [ values: [
{ workspace: "alpha", type: "prompt", key: "system", value: "hello" }, { workspace: "alpha", type: "prompt", key: "system", value: "hello" },
], ],
} as ConfigRequest); };
await service.handlePut(putRequest);
const persisted = await Bun.file(persistPath).json(); const persisted = await Bun.file(persistPath).json();
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
@ -97,10 +108,11 @@ describe("ConfigService operations", () => {
const service = makeService(persistPath); const service = makeService(persistPath);
await service.loadFromDisk(); await service.loadFromDisk();
const response = service.handleGet({ const getRequest: ConfigRequest = {
operation: "get", operation: "get",
keys: ["prompt", "system"], keys: ["prompt", "system"],
} as ConfigRequest); };
const response = service.handleGet(getRequest);
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
expect(response).toEqual({ expect(response).toEqual({
@ -110,4 +122,58 @@ describe("ConfigService operations", () => {
}, },
}); });
}); });
it("serializes concurrent mutations through ref-backed state", async () => {
const service = makeService();
const requests: Array<ConfigRequest> = [
{ operation: "put", values: [{ type: "prompt", key: "a", value: "one" }] },
{ operation: "put", values: [{ type: "prompt", key: "b", value: "two" }] },
{ operation: "put", values: [{ workspace: "beta", type: "prompt", key: "c", value: "three" }] },
];
await Promise.all(requests.map((request) => service.handlePut(request)));
expect(service.handleGet({ operation: "get", keys: ["prompt"] })).toEqual({
version: 3,
values: {
a: "one",
b: "two",
},
});
expect(service.handleGetValuesAllWorkspaces({ operation: "getvalues-all-ws", keys: ["prompt"] }).values).toEqual([
{ workspace: "default", type: "prompt", key: "a", value: "one" },
{ workspace: "default", type: "prompt", key: "b", value: "two" },
{ workspace: "beta", type: "prompt", key: "c", value: "three" },
]);
});
it("pushes config from the stored producer handle", async () => {
const backend = new NoopPubSub();
const service = makeConfigService({
id: "config-test",
manageProcessSignals: false,
pubsub: backend,
});
const pushProducer = await backend.createProducer<{
readonly version: number;
readonly config: Record<string, unknown>;
}>({ topic: topics.configPush });
await Effect.runPromise(
SynchronizedRef.update(service.state, (state) => ({
...state,
pushProducer,
})),
);
await service.pushConfig();
await service.handlePut({
operation: "put",
values: [{ type: "prompt", key: "system", value: "hello" }],
});
expect(backend.sentByTopic.get(topics.configPush)).toEqual([
{ version: 0, config: {} },
{ version: 1, config: { prompt: { system: "hello" } } },
]);
});
}); });

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,6 @@
* NATS_URL (default: nats://localhost:4222) * NATS_URL (default: nats://localhost:4222)
* CONFIG_PERSIST_PATH (optional, e.g., ./data/config.json) * CONFIG_PERSIST_PATH (optional, e.g., ./data/config.json)
*/ */
import { run } from "../packages/flow/src/config/service.js"; import {runMain} from "../packages/flow/src/config/service.js";
run().catch((err) => { runMain();
console.error("Config service failed:", err);
process.exit(1);
});