| 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,6 +9,7 @@ import { OrchestrationCommandSchema } from "@t3tools/contracts"; |
| 9 | import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; | 9 | import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; |
| 10 | 10 | ||
| 11 | import { createLogger } from "../logger"; | 11 | import { createLogger } from "../logger"; |
| 12 | import type { OrchestrationEventStore } from "./eventStore"; | ||
| 12 | import { SqliteEventStore } from "./eventStore"; | 13 | import { SqliteEventStore } from "./eventStore"; |
| 13 | import { createEmptyReadModel, reduceEvent } from "./reducer"; | 14 | import { createEmptyReadModel, reduceEvent } from "./reducer"; |
| 14 | import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; | 15 | import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; |
| ... | @@ -190,7 +191,7 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve | ... | @@ -190,7 +191,7 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEve |
| 190 | export class OrchestrationEngine { | 191 | export class OrchestrationEngine { |
| 191 | private readonly logger = createLogger("orchestration"); | 192 | private readonly logger = createLogger("orchestration"); |
| 192 | private readonly runtime = Runtime.defaultRuntime; | 193 | private readonly runtime = Runtime.defaultRuntime; |
| 193 | private readonly eventStore: SqliteEventStore; | 194 | private readonly eventStore: OrchestrationEventStore; |
| 194 | 195 | ||
| 195 | private readModel: OrchestrationReadModel; | 196 | private readModel: OrchestrationReadModel; |
| 196 | private commandQueue: Queue.Queue<CommandEnvelope>; | 197 | private commandQueue: Queue.Queue<CommandEnvelope>; |
| ... | @@ -200,9 +201,15 @@ export class OrchestrationEngine { | ... | @@ -200,9 +201,15 @@ export class OrchestrationEngine { |
| 200 | private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); | 201 | private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); |
| 201 | private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); | 202 | private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); |
| 202 | 203 | ||
| 203 | constructor(stateDir: string) { | 204 | constructor(stateDir: string); |
| 204 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | 205 | constructor(eventStore: OrchestrationEventStore); |
| 205 | this.eventStore = new SqliteEventStore(dbPath); | 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 | this.readModel = createEmptyReadModel(new Date().toISOString()); | 213 | this.readModel = createEmptyReadModel(new Date().toISOString()); |
| 207 | this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded<CommandEnvelope>()); | 214 | this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded<CommandEnvelope>()); |
| 208 | this.readModelPubSub = Runtime.runSync(this.runtime)( | 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 +6,13 @@ import type { SQLOutputValue } from "node:sqlite"; |
| 6 | import type { OrchestrationEvent } from "@t3tools/contracts"; | 6 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 7 | import { Effect } from "effect"; | 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 | interface EventRow { | 16 | interface EventRow { |
| 10 | sequence: number; | 17 | sequence: number; |
| 11 | event_id: string; | 18 | event_id: string; |
| ... | @@ -50,7 +57,7 @@ function readRequiredNumber( | ... | @@ -50,7 +57,7 @@ function readRequiredNumber( |
| 50 | throw new Error(`Invalid SQLite value for ${String(column)}: expected number`); | 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 | private readonly db: DatabaseSync; | 61 | private readonly db: DatabaseSync; |
| 55 | private readonly insertStatement: StatementSync; | 62 | private readonly insertStatement: StatementSync; |
| 56 | private closed = false; | 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,7 +36,7 @@ import { |
| 36 | import { TerminalManager } from "./terminalManager"; | 36 | import { TerminalManager } from "./terminalManager"; |
| 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 39 | import { OrchestrationEngine } from "./orchestration/engine"; | 39 | import { createOrchestrationEngine } from "./orchestration/runtime"; |
| 40 | 40 | ||
| 41 | const MIME_TYPES: Record<string, string> = { | 41 | const MIME_TYPES: Record<string, string> = { |
| 42 | ".html": "text/html; charset=utf-8", | 42 | ".html": "text/html; charset=utf-8", |
| ... | @@ -107,7 +107,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -107,7 +107,7 @@ export function createServer(options: ServerOptions) { |
| 107 | const projectRegistry = | 107 | const projectRegistry = |
| 108 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); | 108 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); |
| 109 | const gitManager = providedGitManager ?? new GitManager(); | 109 | const gitManager = providedGitManager ?? new GitManager(); |
| 110 | const orchestrationEngine = new OrchestrationEngine( | 110 | const orchestrationEngine = createOrchestrationEngine( |
| 111 | stateDir ?? path.join(os.homedir(), ".t3", "userdata"), | 111 | stateDir ?? path.join(os.homedir(), ".t3", "userdata"), |
| 112 | ); | 112 | ); |
| 113 | const clients = new Set<WebSocket>(); | 113 | const clients = new Set<WebSocket>(); |