| author | |
| committer | |
| log | 2bece01a3be88f5bde92e7ae5633b2d3181955a1 |
| tree | 1dc0804fe0c86f9899b026500807fe2b98624c02 |
| parent | 4f4691676acad990c6a51221e2522d4ffb131415 |
7 files changed, 132 insertions(+), 7 deletions(-)
apps/server/src/orchestration/engine.ts+11-4| ... | ... | @@ -9,6 +9,7 @@ import { OrchestrationCommandSchema } from "@t3tools/contracts"; |
| 9 | 9 | import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; |
| 10 | 10 | |
| 11 | 11 | import { createLogger } from "../logger"; |
| 12 | import type { OrchestrationEventStore } from "./eventStore"; | |
| 12 | 13 | import { SqliteEventStore } from "./eventStore"; |
| 13 | 14 | import { createEmptyReadModel, reduceEvent } from "./reducer"; |
| 14 | 15 | import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; |
| ... | ... | @@ -190,7 +191,7 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve |
| 190 | 191 | export class OrchestrationEngine { |
| 191 | 192 | private readonly logger = createLogger("orchestration"); |
| 192 | 193 | private readonly runtime = Runtime.defaultRuntime; |
| 193 | private readonly eventStore: SqliteEventStore; | |
| 194 | private readonly eventStore: OrchestrationEventStore; | |
| 194 | 195 | |
| 195 | 196 | private readModel: OrchestrationReadModel; |
| 196 | 197 | private commandQueue: Queue.Queue<CommandEnvelope>; |
| ... | ... | @@ -200,9 +201,15 @@ export class OrchestrationEngine { |
| 200 | 201 | private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); |
| 201 | 202 | private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); |
| 202 | 203 | |
| 203 | constructor(stateDir: string) { | |
| 204 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 205 | this.eventStore = new SqliteEventStore(dbPath); | |
| 204 | constructor(stateDir: string); | |
| 205 | constructor(eventStore: OrchestrationEventStore); | |
| 206 | constructor(stateDirOrEventStore: string | OrchestrationEventStore) { | |
| 207 | if (typeof stateDirOrEventStore === "string") { | |
| 208 | const dbPath = path.join(stateDirOrEventStore, "orchestration.sqlite"); | |
| 209 | this.eventStore = new SqliteEventStore(dbPath); | |
| 210 | } else { | |
| 211 | this.eventStore = stateDirOrEventStore; | |
| 212 | } | |
| 206 | 213 | this.readModel = createEmptyReadModel(new Date().toISOString()); |
| 207 | 214 | this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded<CommandEnvelope>()); |
| 208 | 215 | this.readModelPubSub = Runtime.runSync(this.runtime)( |
apps/server/src/orchestration/eventStore.ts+8-1| ... | ... | @@ -6,6 +6,13 @@ import type { SQLOutputValue } from "node:sqlite"; |
| 6 | 6 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 7 | 7 | import { Effect } from "effect"; |
| 8 | 8 | |
| 9 | export interface OrchestrationEventStore { | |
| 10 | append(event: Omit<OrchestrationEvent, "sequence">): Effect.Effect<OrchestrationEvent>; | |
| 11 | readFromSequence(sequenceExclusive: number, limit?: number): Effect.Effect<OrchestrationEvent[]>; | |
| 12 | readAll(): Effect.Effect<OrchestrationEvent[]>; | |
| 13 | close(): void; | |
| 14 | } | |
| 15 | ||
| 9 | 16 | interface EventRow { |
| 10 | 17 | sequence: number; |
| 11 | 18 | event_id: string; |
| ... | ... | @@ -50,7 +57,7 @@ function readRequiredNumber( |
| 50 | 57 | throw new Error(`Invalid SQLite value for ${String(column)}: expected number`); |
| 51 | 58 | } |
| 52 | 59 | |
| 53 | export class SqliteEventStore { | |
| 60 | export class SqliteEventStore implements OrchestrationEventStore { | |
| 54 | 61 | private readonly db: DatabaseSync; |
| 55 | 62 | private readonly insertStatement: StatementSync; |
| 56 | 63 | private closed = false; |
apps/server/src/orchestration/layers.test.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { createOrchestrationEngine } from "./runtime"; | |
| 8 | ||
| 9 | const tempDirs: string[] = []; | |
| 10 | ||
| 11 | function makeTempDir(prefix: string): string { | |
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | |
| 13 | tempDirs.push(dir); | |
| 14 | return dir; | |
| 15 | } | |
| 16 | ||
| 17 | afterEach(() => { | |
| 18 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | |
| 19 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 20 | } | |
| 21 | }); | |
| 22 | ||
| 23 | describe("orchestration layers", () => { | |
| 24 | it("creates an engine through Context.Tag wiring", async () => { | |
| 25 | const stateDir = makeTempDir("t3code-orchestration-layer-"); | |
| 26 | const createdAt = new Date().toISOString(); | |
| 27 | const engine = createOrchestrationEngine(stateDir); | |
| 28 | ||
| 29 | await engine.start(); | |
| 30 | await engine.dispatch({ | |
| 31 | type: "project.create", | |
| 32 | commandId: "cmd-layer-1", | |
| 33 | projectId: "project-layer-1", | |
| 34 | name: "layer", | |
| 35 | cwd: "/tmp/layer", | |
| 36 | model: "gpt-5-codex", | |
| 37 | createdAt, | |
| 38 | }); | |
| 39 | const firstSnapshot = engine.getSnapshot(); | |
| 40 | await engine.stop(); | |
| 41 | ||
| 42 | const restarted = createOrchestrationEngine(stateDir); | |
| 43 | await restarted.start(); | |
| 44 | expect(restarted.getSnapshot()).toEqual(firstSnapshot); | |
| 45 | await restarted.stop(); | |
| 46 | }); | |
| 47 | }); |
apps/server/src/orchestration/layers.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import path from "node:path"; | |
| 2 | ||
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { OrchestrationEngine } from "./engine"; | |
| 6 | import { SqliteEventStore } from "./eventStore"; | |
| 7 | import { | |
| 8 | OrchestrationConfig, | |
| 9 | OrchestrationEngineService, | |
| 10 | OrchestrationEventStoreService, | |
| 11 | } from "./services"; | |
| 12 | ||
| 13 | export const OrchestrationEventStoreLive = Layer.effect( | |
| 14 | OrchestrationEventStoreService, | |
| 15 | Effect.map(OrchestrationConfig, ({ stateDir }) => { | |
| 16 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 17 | return new SqliteEventStore(dbPath); | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | export const OrchestrationEngineLive = Layer.effect( | |
| 22 | OrchestrationEngineService, | |
| 23 | Effect.map(OrchestrationEventStoreService, (eventStore) => new OrchestrationEngine(eventStore)), | |
| 24 | ); | |
| 25 | ||
| 26 | export const OrchestrationLive = OrchestrationEngineLive.pipe( | |
| 27 | Layer.provide(OrchestrationEventStoreLive), | |
| 28 | ); |
apps/server/src/orchestration/runtime.ts created+14| ... | ... | @@ -0,0 +1,14 @@ |
| 1 | import { Effect, Layer, Runtime } from "effect"; | |
| 2 | ||
| 3 | import type { OrchestrationEngine } from "./engine"; | |
| 4 | import { OrchestrationLive } from "./layers"; | |
| 5 | import { OrchestrationConfig, OrchestrationEngineService } from "./services"; | |
| 6 | ||
| 7 | export function createOrchestrationEngine(stateDir: string): OrchestrationEngine { | |
| 8 | const orchestrationLayer = OrchestrationLive.pipe( | |
| 9 | Layer.provide(Layer.succeed(OrchestrationConfig, { stateDir })), | |
| 10 | ); | |
| 11 | return Runtime.runSync(Runtime.defaultRuntime)( | |
| 12 | Effect.provide(OrchestrationEngineService, orchestrationLayer), | |
| 13 | ); | |
| 14 | } |
apps/server/src/orchestration/services.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import { Context } from "effect"; | |
| 2 | ||
| 3 | import { OrchestrationEngine } from "./engine"; | |
| 4 | import type { OrchestrationEventStore } from "./eventStore"; | |
| 5 | ||
| 6 | export interface OrchestrationConfigShape { | |
| 7 | readonly stateDir: string; | |
| 8 | } | |
| 9 | ||
| 10 | export class OrchestrationConfig extends Context.Tag("orchestration/Config")< | |
| 11 | OrchestrationConfig, | |
| 12 | OrchestrationConfigShape | |
| 13 | >() {} | |
| 14 | ||
| 15 | export class OrchestrationEventStoreService extends Context.Tag( | |
| 16 | "orchestration/EventStore", | |
| 17 | )<OrchestrationEventStoreService, OrchestrationEventStore>() {} | |
| 18 | ||
| 19 | export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< | |
| 20 | OrchestrationEngineService, | |
| 21 | OrchestrationEngine | |
| 22 | >() {} |
apps/server/src/wsServer.ts+2-2| ... | ... | @@ -36,7 +36,7 @@ import { |
| 36 | 36 | import { TerminalManager } from "./terminalManager"; |
| 37 | 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 38 | 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 39 | import { OrchestrationEngine } from "./orchestration/engine"; | |
| 39 | import { createOrchestrationEngine } from "./orchestration/runtime"; | |
| 40 | 40 | |
| 41 | 41 | const MIME_TYPES: Record<string, string> = { |
| 42 | 42 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -107,7 +107,7 @@ export function createServer(options: ServerOptions) { |
| 107 | 107 | const projectRegistry = |
| 108 | 108 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); |
| 109 | 109 | const gitManager = providedGitManager ?? new GitManager(); |
| 110 | const orchestrationEngine = new OrchestrationEngine( | |
| 110 | const orchestrationEngine = createOrchestrationEngine( | |
| 111 | 111 | stateDir ?? path.join(os.homedir(), ".t3", "userdata"), |
| 112 | 112 | ); |
| 113 | 113 | const clients = new Set<WebSocket>(); |